16 Commits

Author SHA1 Message Date
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
30 changed files with 2561 additions and 260 deletions
+130
View File
@@ -5,6 +5,136 @@ All notable changes to Terra-View will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.13.2] - 2026-05-30
PWA-cache fix for mobile operators. v0.13.0 added the inline PDF preview, `.TXT` download, and Review form to `event-modal.js`, but mobile devices using Terra-View as a PWA never saw any of it — the service worker had `CACHE_VERSION = 'v1'` (unchanged since v0.12.x), so the activate handler never evicted the stale cache and mobile users kept getting served the pre-v0.13.0 modal forever.
### Fixed
- **Service worker cache version bumped + tied to the app version**. `CACHE_VERSION` in `backend/static/sw.js` is now `'v0.13.2'`, which causes the SW's activate handler to delete the old `sfm-static-v1` / `sfm-dynamic-v1` / `sfm-data-v1` caches on first visit after the upgrade. Going forward the convention is: any release that touches a static asset must bump `CACHE_VERSION` to match `backend/main.py`'s `VERSION`. Comment in `sw.js` documents this.
- **`event-modal.js` precached** alongside `mobile.js` / `offline-db.js` etc. Lifecycle is now tied to the SW version bump explicitly — old modal JS gets evicted on activate, new modal JS is fetched and cached during install.
### What mobile users will see after deploy
On next page navigation the SW update check fires, the new SW installs (skipWaiting), activate evicts the v1 caches, `controllerchange` fires, the page reloads with the v0.13.x modal. On the worst-case device (no recent visit), it might take up to an hour for `registration.update()` to pick up the new SW — operators can force-refresh by closing and re-opening the PWA, or by clearing site data once.
---
## [0.13.1] - 2026-05-29
Same-day patch on top of v0.13.0. Fixes the mic-chart unit default — v0.13.0 shipped with `dBL` as the default, but the PDF report renders the mic axis in psi, so the website chart and the printed report didn't match. Operator caught it within an hour of rollout. Also relabels the modal's "Captured at" field to "Time received" so it isn't mistaken for the device's trigger time.
### Fixed
- **Event-detail modal: mic chart now defaults to psi**, matching the PDF report's mic axis. The waveform/histogram chart's mic channel now renders in raw psi by default; operators who specifically prefer dB(L) on charts can flip it via Settings → General → "Event Report — Mic Channel Units". Peaks everywhere else (table tiles, modal Peaks section, KPI summaries) stay in dB(L) as before — this is strictly a chart-axis change.
- **Modal label: "Captured at" → "Time received"** (+ tooltip clarifying it's the SFM ingestion time, not the unit-local trigger time at the top of the modal). Same change in seismo-relay's standalone webapp for consistency.
### Migration Notes
The bundled `backend/migrate_add_mic_unit_pref.py` is now idempotent across both the v0.13.0 "add column" path and the v0.13.0 → v0.13.1 default flip. Existing rows sitting at the original `'dBL'` default (i.e. nobody touched the setting yet — true for almost everyone) get bumped to `'psi'` on migration.
```bash
cd /home/serversdown/terra-view
docker compose build terra-view && docker compose up -d terra-view
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_mic_unit_pref.py
```
If you _did_ deliberately set the chart to dB(L) via Settings between v0.13.0 rollout and this patch, the migration will reset it — one click in Settings to restore. Trade-off considered acceptable given the very small user base and the freshness of the v0.13.0 release.
---
## [0.13.0] - 2026-05-29
The "SFM integration Phase 1" release. Closes the 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. The shared event-detail modal (used on `/sfm`, `/unit/{id}`, `/admin/events`, and `/projects/{p}/nrl/{l}`) gains a Chart.js waveform/histogram chart, inline PDF preview, original `.TXT` download, and a review form with false-trigger flag + reviewer + notes. `/admin/events` finally gets the modal too. A new Settings field controls the mic chart's display unit.
### Added — Event-detail modal: Chart.js waveform/histogram panels
- **4-channel stacked plots** (MicL → Long → Vert → Tran, matching BW Event Report layout) inside the existing `partials/event_detail_modal.html` shell. Ported from seismo-relay's standalone `sfm/sfm_webapp.html:2555-2880`; theme-aware grid + tick colors (light/dark mode via Tailwind's `dark` class on `<html>`).
- **Waveform mode**: line plot, symmetric Y-axis around zero for geo channels, dashed trigger overlay at `t=0` with triangle markers above and below, zero-baseline dashed line + "0.0" label on the right margin. Downsamples at >3000 samples to keep render time bounded.
- **Histogram mode**: bar plot, zero-anchored Y with minimum range (`0.05 in/s` geo, `0.001 psi` mic) so quiet events don't fill the panel. X-axis uses `time_axis.interval_times` (HH:MM:SS labels emitted by seismo-relay v0.20.0+) when available, otherwise falls back to interval index. Trigger/zero-baseline overlays suppressed (no trigger concept on histograms).
- **Mic conversion** — converts raw psi samples to dB(L) for the chart when the operator's `mic_unit_pref` is "dBL" (the default). Rectifies the AC waveform (`abs()`) and floors at `MIC_DBL_FLOOR = 60` so the chart reads as an SPL-vs-time curve instead of a sparse pattern of isolated spikes above the floor. Peak label uses the unrectified value.
- **Chart cleanup** — `_destroyCharts()` runs on modal close so repeated open/close doesn't leak Chart.js instances.
- Chart.js 4.4.1 pinned via cdn.jsdelivr at the bottom of the modal partial; matches the standalone webapp's reference version.
### Added — Event-detail modal: PDF preview + downloads + review form
- **"Show Event Report PDF"** toggle opens an inline iframe inside the modal (no second-layer modal, no new browser tab). Iframe lazy-loads on first reveal — closing the modal without opening the PDF never spends bandwidth on the fetch. Sized 80vh / 600px min so a typical letter-portrait single-page report fits with browser-native zoom + download + print controls available. Companion "Download PDF" button for direct save.
- **"Original .TXT report"** download link, rendered only when `sidecar.source.txt_filename` is present (events ingested with seismo-relay's `.TXT` preservation pattern, post-2026-05-27). Hidden for legacy events to avoid 404 dead links.
- **Inline Review form** — `false_trigger` checkbox + reviewer text input + notes textarea + Save button. Persists via `PATCH /api/sfm/db/events/{id}/sidecar` with `{review: {...}}`. Status line shows last-reviewed timestamp + save success/failure feedback. On save fires a `sfm-event-review-saved` `CustomEvent` on `window` so the host page's table can refresh without a full reload — wired up on `/sfm`, `/unit/{id}`, `/admin/events`, and `/projects/{p}/nrl/{l}`.
### Added — `/admin/events` row click opens the modal
- The SFM Event DB Manager at `/admin/events` previously had no detail view — admins had to copy an event ID and load the standalone webapp on port 8200. Now table rows are clickable: `onclick` on `<tr>` calls `showEventDetail(id)`, with `event.stopPropagation()` on the checkbox cell so bulk-selection clicks don't also open the modal.
- `partials/event_detail_modal.html` + `event-modal.js` are now included on this page, matching the existing pattern on `/sfm`, `/unit/{id}`, and `/projects/{p}/nrl/{l}`.
### Added — `mic_unit_pref` user setting (Settings → General)
- **New `user_preferences.mic_unit_pref` column**, "dBL" default with "psi" as the alternate value. Controls only the event-report modal's waveform chart mic axis — peak values in every other surface (event tables, KPI tiles, modal Peaks section) stay in dB(L) regardless.
- Surfaced as a single dropdown on Settings → General, below the auto-refresh interval. Round-trips through `GET/PUT /api/settings/preferences`.
- New `backend/migrate_add_mic_unit_pref.py` script for existing databases — idempotent ALTER TABLE.
### Fixed — Docker Compose: SFM container can finally read the DB
- `../seismo-relay-prod-snap` is now bind-mounted into the SFM container at the same absolute host path it had outside, so the symlinked `seismo_relay.db` + `waveforms/` directory inside `bridges/captures/` resolve. Without it, SFM 500'd on every `/db/*` proxy call because the symlink target wasn't visible from inside the container. Read-write (not `:ro`) because SFM opens the DB in WAL mode, which requires creating `-wal` and `-shm` sidecar files even for reads.
### Migration Notes
```bash
cd /home/serversdown/terra-view
# Apply the new column to the database — required. Idempotent.
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_mic_unit_pref.py
# Rebuild + restart both Terra-View and SFM (compose mounts changed).
docker compose build terra-view && docker compose up -d
```
Set Settings → General → "Event Report — Mic Channel Units" if "psi" is preferred over the default "dB(L)". Setting persists in the DB and is fetched once per modal open.
### What's NOT in this release
Device-control endpoints (`/device/*` — start/stop monitoring, push compliance config, erase events, etc.) remain unexposed in the Terra-View UI. They proxy through transparently but no page calls them. Phase 2 of the SFM integration will bring them online once the SFM auth layer lands (a hard prerequisite — anything reachable through Terra-View's URL needs to be gated against unauthenticated callers).
---
## [0.12.1] - 2026-05-20
Field-operations polish — three small features and two correctness fixes that smooth out the deployment workflow added in v0.12.0. The new Unit Swap wizard and editable deployment timeline are the operator-facing items; the swap/unassign/promote roster-flag fix closes a long-standing data-consistency hole.
### Added — Unit Swap wizard (`/tools/unit-swap`)
- **Mobile-first 4-step wizard** for the common field operation: pick project → pick location → choose incoming unit (with optional modem swap) → review + confirm. Designed for tap-driven use on a phone in the field; works on desktop too.
- **Benched-candidate awareness**: `GET /api/projects/.../available-units?include_benched=true` and `available-modems?include_benched=true` now return units/modems with `deployed=False` alongside the active fleet — exactly the inventory a tech pulls off the shelf. Each row carries a `deployed` boolean for badge rendering. Default (`include_benched=false`) is unchanged, so the existing location-detail swap modal isn't affected.
- **`POST /locations/{loc}/swap` enhancements**:
- Flips the incoming unit (and modem) back to `deployed=True` if either was on the bench, keeping the legacy `RosterUnit.deployed` 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), the stale back-reference is broken before re-pairing.
- **`locations-with-assignments`** response now includes `modem.deployed`, so the wizard can badge the current modem in the location card, "Keep current modem" choice, picker rows, and review screen.
- Tile on `/tools` for discovery; sidebar entry in the Tools nav cluster.
### Added — Editable deployment timeline on `/unit/{id}`
- **Per-row inline edit (pencil icon)** on each assignment in the unit's Deployment Timeline. Opens a modal with `assigned_at`, `assigned_until` (with an "open-ended" checkbox that clears the end date), and notes. Saves via the existing `PATCH /api/projects/{pid}/assignments/{aid}`; delete (for misclicks) via the existing `DELETE`.
- **"+ Add deployment record" button** at the top of the timeline for backfilling historical windows — useful when orphan events sit outside any assignment. Modal flow: project → location → assigned_at → assigned_until (optional open-ended) → notes.
- **Closed-window assignments** now accepted by `POST /api/projects/.../locations/{loc}/assign`: the blanket "location already has an active assignment" check became overlap detection against same-location windows. Closed historical assignments that don't overlap an existing one are accepted (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.
### Fixed
- **`RosterUnit.deployed` now flips correctly on swap / unassign / promote-pending** (`POST /locations/{loc}/swap`, `POST /assignments/{aid}/unassign`, `POST /deployments/pending/{id}/promote`). The legacy `deployed` flag drives heartbeat polling and benched-vs-deployed roster filters; before this fix, those three workflows ended an assignment without flipping the flag, so the outgoing unit kept being polled and showed up as "deployed" forever. 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. Promote-pending additionally handles the case where the target location already has an active assignment — previously this 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 benched + unpaired, and an `assignment_swapped` `UnitHistory` row is written.
- **Deployment timeline now respects user timezone for display *and* edits.** 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. Two-sided fix:
- **Display**: `services/deployment_timeline.py` converts every emitted timestamp (`starts_at`, `ends_at`, `event_overlay.peak_pvs_at`, `last_event`) through `utc_to_local()` using the user's configured timezone from `UserPreferences` before serializing. Frontend slicing keeps working — it just slices a local-time string now.
- **Write**: `PATCH /api/projects/{pid}/assignments/{aid}` and `POST /locations/{loc}/assign` interpret a *naive* `assigned_at` / `assigned_until` ISO string as the user's local time and convert to UTC via `local_to_utc()`. Explicit tz-aware strings (`...Z` or `...+00:00`) skip the conversion, so programmatic callers that already speak UTC keep working.
### Migration Notes
No schema changes. Static code-only release — pull and restart:
```bash
cd /home/serversdown/terra-view
docker compose build terra-view && docker compose up -d terra-view
```
---
## [0.12.0] - 2026-05-17
Field-deployment workflow + fleet-wide deployment views + SFM event DB management. The headline is the mobile capture flow: a field tech can now arrive on site, take one photo of the installed seismograph, and walk away — classification (which project, which location) happens later at a desk through the new pending-deployment hopper. EXIF GPS is auto-extracted on capture, so the resulting `UnitAssignment` lands with coordinates without anyone typing them.
+8 -3
View File
@@ -1,4 +1,4 @@
# Terra-View v0.12.0
# Terra-View v0.13.2
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
## Features
@@ -9,15 +9,20 @@ Backend API and HTMX-powered web interface for managing a mixed fleet of seismog
- **Touch Optimized**: 44x44px minimum touch targets, hamburger menu, bottom navigation bar
- **Mobile Card View**: Compact unit cards with status dots, tap-to-navigate locations, and detail modals
- **Background Sync**: Queue edits while offline and automatically sync when connection returns
- **Field-Deployment Workflow**: One-photo mobile capture at `/deploy` → desk-side classification at `/tools/pending-deployments` → automatic UnitAssignment creation with EXIF GPS
- **Unit Swap Wizard** (`/tools/unit-swap`): mobile-first 4-step flow for swapping a vibration unit (and optionally its modem) at a monitoring location. Surfaces benched-fleet candidates as eligible incoming units; cleans up stale modem back-references on swap
- **Editable Deployment Timeline** on every unit detail page: inline edit / delete each assignment, plus an "Add deployment record" button for backfilling historical windows. Frees-up previously-orphaned events when their timestamp now falls inside an assignment
- **Web Dashboard**: Modern, responsive UI with dark/light mode, live HTMX updates, and integrated fleet map
- **Fleet Monitoring**: Track deployed, benched, retired, and ignored units in separate buckets with unknown-emitter triage
- **Roster Management**: Full CRUD + CSV import/export, device-type aware metadata, and inline actions from the roster tables
- **Settings & Safeguards**: `/settings` page exposes roster stats, exports, replace-all imports, and danger-zone reset tools
- **Device & Modem Metadata**: Capture calibration windows, modem pairings, phone/IP details, and addresses per unit
- **Status Management**: Automatically mark deployed units as OK, Pending (>12h), or Missing (>24h) based on recent telemetry
- **Data Ingestion**: Accept reports from emitter scripts via REST API
- **SFM Event DB Manager** (`/admin/events`): cross-unit event browser with bulk false-trigger flagging and admin-only hard-delete (cleans on-disk binaries + sidecars too) for purging bogus events from misbehaving units
- **Deployment-History Calendar + Gantt** (`/tools/deployment-history`): fleet-wide 12-month calendar with side-panel day drill-down, plus "Gantt by Project" / "Gantt by Unit" tabs
- **Photo Management**: Upload and view photos for each unit
- **Interactive Maps**: Leaflet-based maps showing unit locations with tap-to-navigate for mobile
- **Interactive Maps**: Leaflet-based maps showing unit locations with tap-to-navigate for mobile (reusable location-map partial across project overview + Vibration tab)
- **Timezone-Aware Timeline**: deployment assignments display and edit in the user's configured local timezone; UTC stays canonical on disk
- **SQLite Storage**: Lightweight, file-based database for easy deployment
- **Database Management**: Comprehensive backup and restore system
- **Manual Snapshots**: Create on-demand backups with descriptions
+9 -1
View File
@@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine)
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
# Initialize FastAPI app
VERSION = "0.12.0"
VERSION = "0.13.2"
if ENVIRONMENT == "development":
_build = os.getenv("BUILD_NUMBER", "0")
if _build and _build != "0":
@@ -291,6 +291,14 @@ async def pending_deployments_page(request: Request):
return templates.TemplateResponse("admin/pending_deployments.html", {"request": request})
@app.get("/tools/unit-swap", response_class=HTMLResponse)
async def unit_swap_page(request: Request):
"""Mobile-first wizard for swapping a vibration unit (and optionally its
modem) at a monitoring location. Pick project → location → incoming
unit → modem decision → confirm → optional photo of the new install."""
return templates.TemplateResponse("admin/unit_swap.html", {"request": request})
@app.get("/modems", response_class=HTMLResponse)
async def modems_page(request: Request):
"""Field modems management dashboard"""
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""
Database migration: Add mic_unit_pref column to user_preferences.
Adds a single field controlling the mic channel's unit on the event-
report waveform chart in the SFM event detail modal. "psi" (default —
matches the PDF report's mic axis) or "dBL". Peaks and KPI tiles
elsewhere are always dBL regardless.
History: v0.13.0 originally shipped this with default "dBL", which
made the website chart inconsistent with the PDF. v0.13.1 flips the
default to "psi" so they match. This migration is idempotent and
covers three cases:
1. Fresh DB without the column — adds it with default 'psi'.
2. DB upgraded from v0.13.0 (column exists, value 'dBL') — flips to
'psi' on the assumption no operator deliberately picked 'dBL' yet.
3. DB upgraded from later — flip step is a no-op for non-'dBL' values.
"""
import sqlite3
from pathlib import Path
def migrate():
possible_paths = [
Path("data/seismo_fleet.db"),
Path("data/sfm.db"),
Path("data/seismo.db"),
]
db_path = next((p for p in possible_paths if p.exists()), None)
if db_path is None:
print(f"Database not found in any of: {[str(p) for p in possible_paths]}")
print("Will be created with the new column when models.py initialises.")
return
print(f"Using database: {db_path}")
conn = sqlite3.connect(db_path)
cur = conn.cursor()
cur.execute("PRAGMA table_info(user_preferences)")
existing = {row[1] for row in cur.fetchall()}
if "mic_unit_pref" not in existing:
cur.execute(
"ALTER TABLE user_preferences "
"ADD COLUMN mic_unit_pref TEXT DEFAULT 'psi'"
)
# Backfill any rows where the column ended up NULL.
cur.execute(
"UPDATE user_preferences SET mic_unit_pref = 'psi' "
"WHERE mic_unit_pref IS NULL"
)
print("Added mic_unit_pref column (default 'psi').")
else:
print("mic_unit_pref column already exists.")
# v0.13.0 → v0.13.1 default-flip: rows still sitting at the original
# 'dBL' default get bumped to 'psi'. If any operator deliberately
# chose 'dBL' through Settings before this migration runs they'd
# get reset — acceptable trade-off given the small user base and
# the fact the setting is one click to restore.
cur.execute("UPDATE user_preferences SET mic_unit_pref = 'psi' "
"WHERE mic_unit_pref = 'dBL'")
flipped = cur.rowcount
if flipped:
print(f"Flipped {flipped} row(s) from 'dBL' to 'psi' (v0.13.0 default).")
conn.commit()
conn.close()
if __name__ == "__main__":
migrate()
+5
View File
@@ -135,6 +135,11 @@ class UserPreferences(Base):
calibration_warning_days = Column(Integer, default=30)
status_ok_threshold_hours = Column(Integer, default=12)
status_pending_threshold_hours = Column(Integer, default=24)
# Mic display units on the event-report waveform chart only — peaks
# and KPI tiles elsewhere are always dBL. "psi" (default — matches
# the PDF report) or "dBL". Default flipped in v0.13.1 after
# operator feedback that the chart should mirror the PDF.
mic_unit_pref = Column(String, default="psi")
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+3 -1
View File
@@ -9,6 +9,7 @@ import logging
import httpx
from backend.database import get_db
from backend.models import UnitHistory, Emitter, RosterUnit
from backend.services.unit_location import get_active_location
log = logging.getLogger(__name__)
@@ -140,6 +141,7 @@ def get_recent_callins(hours: int = 6, limit: int = None, db: Session = Depends(
days = int(hours_ago / 24)
time_ago = f"{days}d ago"
loc = get_active_location(db, emitter.id) if roster_unit else None
call_in = {
"unit_id": emitter.id,
"last_seen": emitter.last_seen.isoformat(),
@@ -148,7 +150,7 @@ def get_recent_callins(hours: int = 6, limit: int = None, db: Session = Depends(
"device_type": roster_unit.device_type if roster_unit else "seismograph",
"deployed": roster_unit.deployed if roster_unit else False,
"note": roster_unit.note if roster_unit and roster_unit.note else "",
"location": roster_unit.address if roster_unit and roster_unit.address else (roster_unit.location if roster_unit else "")
"location": (loc or {}).get("address") or (loc or {}).get("name") or ""
}
call_ins.append(call_in)
+5 -3
View File
@@ -750,15 +750,17 @@ async def get_unit_quick_info(unit_id: str, db: Session = Depends(get_db)):
# Last seen from emitter
emitter = db.query(Emitter).filter(Emitter.unit_type == unit_id).first()
from backend.services.unit_location import get_active_location
loc = get_active_location(db, u.id)
return {
"id": u.id,
"unit_type": u.unit_type,
"deployed": u.deployed,
"out_for_calibration": u.out_for_calibration or False,
"note": u.note or "",
"project_id": u.project_id or "",
"address": u.address or u.location or "",
"coordinates": u.coordinates or "",
"project_id": (loc or {}).get("project_id") or u.project_id or "",
"address": (loc or {}).get("address") or "",
"coordinates": (loc or {}).get("coordinates") or "",
"deployed_with_modem_id": u.deployed_with_modem_id or "",
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
"next_calibration_due": u.next_calibration_due.isoformat() if u.next_calibration_due else (expiry.isoformat() if expiry else None),
+12 -10
View File
@@ -14,6 +14,7 @@ import logging
from backend.database import get_db
from backend.models import RosterUnit
from backend.services.unit_location import get_active_location
from backend.templates_config import templates
logger = logging.getLogger(__name__)
@@ -85,8 +86,7 @@ async def get_modem_units(
(RosterUnit.id.ilike(search_term)) |
(RosterUnit.ip_address.ilike(search_term)) |
(RosterUnit.hardware_model.ilike(search_term)) |
(RosterUnit.phone_number.ilike(search_term)) |
(RosterUnit.location.ilike(search_term))
(RosterUnit.phone_number.ilike(search_term))
)
modems = query.order_by(
@@ -128,6 +128,8 @@ async def get_modem_units(
if filter_status and status != filter_status:
continue
# Inherit location from the paired device's active assignment.
loc = get_active_location(db, modem.id) if paired else None
modem_list.append({
"id": modem.id,
"ip_address": modem.ip_address,
@@ -135,8 +137,8 @@ async def get_modem_units(
"hardware_model": modem.hardware_model,
"deployed": modem.deployed,
"retired": modem.retired,
"location": modem.location,
"project_id": modem.project_id,
"location": (loc or {}).get("address") or (loc or {}).get("name") or "",
"project_id": (loc or {}).get("project_id") or modem.project_id,
"paired_device": paired,
"status": status
})
@@ -165,14 +167,15 @@ async def get_paired_device(modem_id: str, db: Session = Depends(get_db)):
).first()
if device:
loc = get_active_location(db, device.id)
return {
"paired": True,
"device": {
"id": device.id,
"device_type": device.device_type,
"deployed": device.deployed,
"project_id": device.project_id,
"location": device.location or device.address
"project_id": (loc or {}).get("project_id") or device.project_id,
"location": (loc or {}).get("address") or (loc or {}).get("name") or ""
}
}
@@ -314,8 +317,6 @@ async def get_pairable_devices(
query = query.filter(
(RosterUnit.id.ilike(search_term)) |
(RosterUnit.project_id.ilike(search_term)) |
(RosterUnit.location.ilike(search_term)) |
(RosterUnit.address.ilike(search_term)) |
(RosterUnit.note.ilike(search_term))
)
@@ -338,12 +339,13 @@ async def get_pairable_devices(
if hide_paired and is_paired_to_other:
continue
loc = get_active_location(db, device.id)
device_list.append({
"id": device.id,
"device_type": device.device_type,
"deployed": device.deployed,
"project_id": device.project_id,
"location": device.location or device.address,
"project_id": (loc or {}).get("project_id") or device.project_id,
"location": (loc or {}).get("address") or (loc or {}).get("name") or "",
"note": device.note,
"paired_modem_id": device.deployed_with_modem_id,
"is_paired_to_this": is_paired_to_this,
+36
View File
@@ -336,6 +336,36 @@ async def promote_pending(
db.add(location)
db.flush()
# If this location already has an active assignment, the /deploy
# capture means someone replaced that unit in the field — close the
# old assignment, break the outgoing unit's modem pairing, and bench
# it so the heartbeat / polling subsystem stops chasing it.
existing_active = db.query(UnitAssignment).filter(
UnitAssignment.location_id == location.id,
UnitAssignment.assigned_until == None, # noqa: E711
).first()
if existing_active and existing_active.unit_id != pd.unit_id:
existing_active.assigned_until = pd.captured_at
existing_active.status = "completed"
old_unit = db.query(RosterUnit).filter_by(id=existing_active.unit_id).first()
if old_unit:
if old_unit.deployed_with_modem_id:
old_modem = db.query(RosterUnit).filter_by(
id=old_unit.deployed_with_modem_id, device_type="modem"
).first()
if old_modem and old_modem.deployed_with_unit_id == old_unit.id:
old_modem.deployed_with_unit_id = None
old_unit.deployed_with_modem_id = None
if old_unit.deployed:
old_unit.deployed = False
_record_history(
db, unit_id=existing_active.unit_id,
change_type="assignment_swapped",
old_value=location.name,
new_value=f"superseded by /deploy capture → {pd.unit_id}",
notes=notes,
)
# Create the assignment. assigned_at = pending capture time (so
# events emitted after the install are correctly attributed back
# to this location).
@@ -354,6 +384,12 @@ async def promote_pending(
db.add(assignment)
db.flush()
# Incoming unit is in the field again — flip it back to deployed
# if it was on the bench (mirrors the swap endpoint).
incoming_unit = db.query(RosterUnit).filter_by(id=pd.unit_id).first()
if incoming_unit and not incoming_unit.deployed:
incoming_unit.deployed = True
# Promote the pending row.
pd.status = "assigned"
pd.promoted_at = datetime.utcnow()
+229 -34
View File
@@ -10,6 +10,7 @@ from fastapi.responses import HTMLResponse, JSONResponse
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from datetime import datetime
from zoneinfo import ZoneInfo
from typing import Optional
import uuid
import json
@@ -34,7 +35,7 @@ from backend.models import (
ScheduledAction,
)
from backend.templates_config import templates
from backend.utils.timezone import local_to_utc
from backend.utils.timezone import local_to_utc, utc_to_local # noqa: F401
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
@@ -262,6 +263,84 @@ async def get_project_locations_json(
]
@router.get("/locations-with-assignments")
async def get_locations_with_assignments(
project_id: str,
db: Session = Depends(get_db),
location_type: Optional[str] = Query(None),
):
"""
Locations + their currently-active assignment + current unit + paired modem
in one call. Used by the Unit Swap tool's location picker so a field tech
can see what's deployed where without N+1 round-trips.
Empty locations come back with assignment/unit/modem all null.
Removed locations are always excluded you don't swap onto a dead slot.
"""
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
query = db.query(MonitoringLocation).filter_by(project_id=project_id).filter(
MonitoringLocation.removed_at == None # noqa: E711
)
if location_type:
query = query.filter_by(location_type=location_type)
locations = query.order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all()
results = []
for loc in locations:
assignment = db.query(UnitAssignment).filter(
and_(
UnitAssignment.location_id == loc.id,
UnitAssignment.assigned_until == None, # noqa: E711
)
).first()
unit_payload = None
modem_payload = None
if assignment:
unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
if unit:
unit_payload = {
"id": unit.id,
"device_type": unit.device_type,
"unit_type": unit.unit_type,
"slm_model": unit.slm_model,
"deployed_with_modem_id": unit.deployed_with_modem_id,
}
if unit.deployed_with_modem_id:
modem = db.query(RosterUnit).filter_by(
id=unit.deployed_with_modem_id, device_type="modem"
).first()
if modem:
modem_payload = {
"id": modem.id,
"hardware_model": modem.hardware_model,
"ip_address": modem.ip_address,
"phone_number": modem.phone_number,
"deployed": bool(modem.deployed),
}
results.append({
"id": loc.id,
"name": loc.name,
"location_type": loc.location_type,
"description": loc.description,
"address": loc.address,
"coordinates": loc.coordinates,
"assignment": {
"id": assignment.id,
"assigned_at": assignment.assigned_at.isoformat() if assignment.assigned_at else None,
"notes": assignment.notes,
} if assignment else None,
"unit": unit_payload,
"modem": modem_payload,
})
return results
@router.post("/locations/create")
async def create_location(
project_id: str,
@@ -645,6 +724,19 @@ async def assign_unit_to_location(
):
"""
Assign a unit to a monitoring location.
Accepts form fields:
- unit_id required
- assigned_at optional ISO datetime; defaults to now. Set this
when backfilling a historical deployment whose
events landed in the orphan bucket.
- assigned_until optional ISO datetime; absent = open-ended /
active.
- notes optional free text
Refuses only when the *new window would overlap* an existing active
open-ended assignment at the same location. Closed historical windows
that don't overlap are allowed (and required for orphan-event backfill).
"""
location = db.query(MonitoringLocation).filter_by(
id=location_id,
@@ -670,23 +762,50 @@ async def assign_unit_to_location(
detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'",
)
# Check if location already has an active assignment (active = assigned_until IS NULL)
existing_assignment = db.query(UnitAssignment).filter(
and_(
UnitAssignment.location_id == location_id,
UnitAssignment.assigned_until == None,
)
).first()
# Parse dates. Naive datetimes from datetime-local inputs are
# interpreted as user-local and converted to UTC for storage; explicit
# tz-aware ISO strings (Z / +00:00) skip the conversion.
def _parse_user_dt(s: str | None, field: str):
if not s:
return None
try:
parsed = datetime.fromisoformat(s)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail=f"Invalid {field}: {s!r}")
if parsed.tzinfo is None:
return local_to_utc(parsed)
return parsed.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
if existing_assignment:
assigned_at = _parse_user_dt(form_data.get("assigned_at"), "assigned_at") or datetime.utcnow()
assigned_until = _parse_user_dt(form_data.get("assigned_until"), "assigned_until")
if assigned_until is not None and assigned_until <= assigned_at:
raise HTTPException(
status_code=400,
detail=f"Location already has an active unit assignment ({existing_assignment.unit_id}). Use swap to replace it.",
status_code=400, detail="assigned_until must be after assigned_at.",
)
# Create new assignment
assigned_until_str = form_data.get("assigned_until")
assigned_until = datetime.fromisoformat(assigned_until_str) if assigned_until_str else None
# Reject only if the new window overlaps an existing assignment at the
# SAME location. Closed historical windows that sit before the current
# active assignment are fine — that's the backfill case.
new_end_for_overlap = assigned_until or datetime.utcnow()
existing = db.query(UnitAssignment).filter(
UnitAssignment.location_id == location_id
).all()
for other in existing:
other_start = other.assigned_at
other_end = other.assigned_until or datetime.utcnow()
if assigned_at < other_end and new_end_for_overlap > other_start:
other_window = (
f"{other.assigned_at:%Y-%m-%d}"
+ (f"{other.assigned_until:%Y-%m-%d}" if other.assigned_until else " → present")
)
raise HTTPException(
status_code=400,
detail=(
f"New window overlaps an existing assignment at this "
f"location ({other.unit_id} {other_window}). Use swap or "
f"edit that record instead."
),
)
assignment = UnitAssignment(
id=str(uuid.uuid4()),
@@ -694,8 +813,9 @@ async def assign_unit_to_location(
location_id=location_id,
project_id=project_id,
device_type=unit.device_type,
assigned_at=assigned_at,
assigned_until=assigned_until,
status="active",
status="active" if assigned_until is None else "completed",
notes=form_data.get("notes"),
)
@@ -752,6 +872,20 @@ async def unassign_unit(
assignment.status = "completed"
assignment.assigned_until = datetime.utcnow()
# Unit is leaving the field — bench it so the heartbeat / polling
# subsystem stops chasing it. Also break the modem pairing both ways.
unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
if unit:
if unit.deployed_with_modem_id:
modem = db.query(RosterUnit).filter_by(
id=unit.deployed_with_modem_id, device_type="modem"
).first()
if modem and modem.deployed_with_unit_id == unit.id:
modem.deployed_with_unit_id = None
unit.deployed_with_modem_id = None
if unit.deployed:
unit.deployed = False
location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first()
_record_assignment_history(
db,
@@ -786,6 +920,11 @@ async def update_assignment(
- assigned_until: ISO datetime, or null/"" to mark indefinite (active)
- notes: string
Naive datetimes (no tz suffix) are interpreted as the user's
configured timezone and converted to UTC for storage. Send an
explicit "+00:00" / "Z" suffix to skip the conversion (programmatic
callers that already have UTC).
Sets `status` to "active" when assigned_until is cleared, "completed"
when it's set in the past.
"""
@@ -816,12 +955,14 @@ async def update_assignment(
)
try:
# Accept "YYYY-MM-DDTHH:MM" from datetime-local inputs or full ISO.
new_assigned_at = datetime.fromisoformat(raw)
parsed = datetime.fromisoformat(raw)
except (TypeError, ValueError):
raise HTTPException(
status_code=400,
detail=f"Invalid assigned_at datetime: {raw!r}",
)
# Naive (no tz) → treat as user's local time and store as UTC.
new_assigned_at = local_to_utc(parsed) if parsed.tzinfo is None else parsed.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
if "assigned_until" in payload:
raw = payload["assigned_until"]
@@ -829,12 +970,13 @@ async def update_assignment(
new_assigned_until = None
else:
try:
new_assigned_until = datetime.fromisoformat(raw)
parsed = datetime.fromisoformat(raw)
except (TypeError, ValueError):
raise HTTPException(
status_code=400,
detail=f"Invalid assigned_until datetime: {raw!r}",
)
new_assigned_until = local_to_utc(parsed) if parsed.tzinfo is None else parsed.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
if "notes" in payload:
raw = payload["notes"]
@@ -1192,6 +1334,22 @@ async def swap_unit_on_location(
new_value=f"swapped out → {unit_id}",
notes=notes,
)
# Clear the outgoing unit's modem pairing so the bidirectional
# deployed_with_modem_id / deployed_with_unit_id back-reference
# doesn't orphan onto the unit that just left the field.
old_unit = db.query(RosterUnit).filter_by(id=current.unit_id).first()
if old_unit:
if old_unit.deployed_with_modem_id:
old_modem = db.query(RosterUnit).filter_by(
id=old_unit.deployed_with_modem_id, device_type="modem"
).first()
if old_modem and old_modem.deployed_with_unit_id == current.unit_id:
old_modem.deployed_with_unit_id = None
old_unit.deployed_with_modem_id = None
# Bench the outgoing unit — it's no longer in the field, so
# the heartbeat / polling subsystem should stop chasing it.
if old_unit.deployed:
old_unit.deployed = False
# Create new assignment
new_assignment = UnitAssignment(
@@ -1218,12 +1376,28 @@ async def swap_unit_on_location(
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
if not modem:
raise HTTPException(status_code=404, detail=f"Modem '{modem_id}' not found")
# Symmetric cleanup: if this modem still claims a previous partner
# (a different seismograph whose deployed_with_modem_id never got
# cleared in a past swap), break that stale link before re-pairing.
if modem.deployed_with_unit_id and modem.deployed_with_unit_id != unit_id:
prev_partner = db.query(RosterUnit).filter_by(id=modem.deployed_with_unit_id).first()
if prev_partner and prev_partner.deployed_with_modem_id == modem_id:
prev_partner.deployed_with_modem_id = None
unit.deployed_with_modem_id = modem_id
modem.deployed_with_unit_id = unit_id
# If the modem was on the bench, swapping it into the field puts it
# back in rotation.
if not modem.deployed:
modem.deployed = True
else:
# Clear modem pairing if not provided
unit.deployed_with_modem_id = None
# If the incoming unit was benched, putting it in the field flips it
# back to deployed (so polling / dashboards see it as in rotation).
if not unit.deployed:
unit.deployed = True
db.commit()
return JSONResponse({
@@ -1241,23 +1415,32 @@ async def swap_unit_on_location(
async def get_available_modems(
project_id: str,
db: Session = Depends(get_db),
include_benched: bool = Query(False),
):
"""
Get all deployed, non-retired modems for the modem assignment dropdown.
Get all non-retired modems for the modem assignment dropdown.
By default only deployed (in-rotation) modems are returned, preserving
the existing behavior for callers like the location-detail swap modal.
Pass ``include_benched=true`` to also include benched modems
(``RosterUnit.deployed == False``) useful when picking a modem to
pull off the bench for a field swap. Each row's ``deployed`` flag is
returned so the UI can badge benched candidates.
"""
modems = db.query(RosterUnit).filter(
and_(
RosterUnit.device_type == "modem",
RosterUnit.deployed == True,
RosterUnit.retired == False,
)
).order_by(RosterUnit.id).all()
filters = [
RosterUnit.device_type == "modem",
RosterUnit.retired == False,
]
if not include_benched:
filters.append(RosterUnit.deployed == True)
modems = db.query(RosterUnit).filter(and_(*filters)).order_by(RosterUnit.id).all()
return [
{
"id": m.id,
"hardware_model": m.hardware_model,
"ip_address": m.ip_address,
"deployed": bool(m.deployed),
}
for m in modems
]
@@ -1268,22 +1451,31 @@ async def get_available_units(
project_id: str,
location_type: str = Query(...),
db: Session = Depends(get_db),
include_benched: bool = Query(False),
):
"""
Get list of available units for assignment to a location.
Filters by device type matching the location type.
By default only deployed (in-rotation) units are returned, preserving
the existing location-detail swap-modal behavior. Pass
``include_benched=true`` to also include benched units
(``RosterUnit.deployed == False``) exactly the candidates you'd
pull off the bench for a field swap. Each row carries a ``deployed``
flag so the UI can badge benched picks.
"""
# Determine required device type
required_device_type = "slm" if location_type == "sound" else "seismograph"
# Get all units of the required type that are deployed and not retired
all_units = db.query(RosterUnit).filter(
and_(
RosterUnit.device_type == required_device_type,
RosterUnit.deployed == True,
RosterUnit.retired == False,
)
).all()
# Get all units of the required type that aren't retired (and optionally
# exclude benched units).
filters = [
RosterUnit.device_type == required_device_type,
RosterUnit.retired == False,
]
if not include_benched:
filters.append(RosterUnit.deployed == True)
all_units = db.query(RosterUnit).filter(and_(*filters)).all()
# Filter out units that already have active assignments (active = assigned_until IS NULL)
assigned_unit_ids = db.query(UnitAssignment.unit_id).filter(
@@ -1291,12 +1483,15 @@ async def get_available_units(
).distinct().all()
assigned_unit_ids = [uid[0] for uid in assigned_unit_ids]
# These units have no active assignment by definition, so there's no
# current location to show — leave the field empty.
available_units = [
{
"id": unit.id,
"device_type": unit.device_type,
"location": unit.address or unit.location,
"location": "",
"model": unit.slm_model if unit.device_type == "slm" else unit.unit_type,
"deployed": bool(unit.deployed),
}
for unit in all_units
if unit.id not in assigned_unit_ids
+16 -61
View File
@@ -12,6 +12,7 @@ from backend.database import get_db
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences, DeploymentRecord
import uuid
from backend.services.slmm_sync import sync_slm_to_slmm
from backend.services.unit_location import get_active_location
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
logger = logging.getLogger(__name__)
@@ -182,9 +183,6 @@ async def add_roster_unit(
out_for_calibration: str = Form(None),
note: str = Form(""),
project_id: str = Form(None),
location: str = Form(None),
address: str = Form(None),
coordinates: str = Form(None),
# Seismograph-specific fields
last_calibrated: str = Form(None),
next_calibration_due: str = Form(None),
@@ -249,9 +247,6 @@ async def add_roster_unit(
out_for_calibration=out_for_calibration_bool,
note=note,
project_id=project_id,
location=location,
address=address,
coordinates=coordinates,
last_updated=datetime.utcnow(),
# Seismograph-specific fields
last_calibrated=last_cal_date,
@@ -273,19 +268,15 @@ async def add_roster_unit(
slm_measurement_range=slm_measurement_range if slm_measurement_range else None,
)
# Auto-fill data from modem if pairing and fields are empty
# Auto-fill data from modem if pairing and fields are empty.
# Location/address/coordinates now come from MonitoringLocation via the
# active UnitAssignment, so there's nothing to copy from the modem row.
if deployed_with_modem_id:
modem = db.query(RosterUnit).filter(
RosterUnit.id == deployed_with_modem_id,
RosterUnit.device_type == "modem"
).first()
if modem:
if not unit.location and modem.location:
unit.location = modem.location
if not unit.address and modem.address:
unit.address = modem.address
if not unit.coordinates and modem.coordinates:
unit.coordinates = modem.coordinates
if not unit.project_id and modem.project_id:
unit.project_id = modem.project_id
if not unit.note and modem.note:
@@ -493,6 +484,8 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
if not unit:
raise HTTPException(status_code=404, detail="Unit not found")
active_loc = get_active_location(db, unit_id)
return {
"id": unit.id,
"device_type": unit.device_type or "seismograph",
@@ -504,9 +497,11 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
"allocated_to_project_id": getattr(unit, 'allocated_to_project_id', None) or "",
"note": unit.note or "",
"project_id": unit.project_id or "",
"location": unit.location or "",
"address": unit.address or "",
"coordinates": unit.coordinates or "",
"active_location": active_loc,
# Convenience fields so the unit-detail page can read the same shape
# whether or not there's an active assignment.
"address": (active_loc or {}).get("address") or "",
"coordinates": (active_loc or {}).get("coordinates") or "",
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else "",
"next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else "",
"deployed_with_modem_id": unit.deployed_with_modem_id or "",
@@ -538,9 +533,6 @@ async def edit_roster_unit(
allocated_to_project_id: str = Form(None),
note: str = Form(""),
project_id: str = Form(None),
location: str = Form(None),
address: str = Form(None),
coordinates: str = Form(None),
# Seismograph-specific fields
last_calibrated: str = Form(None),
next_calibration_due: str = Form(None),
@@ -565,8 +557,6 @@ async def edit_roster_unit(
cascade_deployed: str = Form(None),
cascade_retired: str = Form(None),
cascade_project: str = Form(None),
cascade_location: str = Form(None),
cascade_coordinates: str = Form(None),
cascade_note: str = Form(None),
db: Session = Depends(get_db)
):
@@ -620,9 +610,6 @@ async def edit_roster_unit(
unit.allocated_to_project_id = allocated_to_project_id if allocated_bool else None
unit.note = note
unit.project_id = project_id
unit.location = location
unit.address = address
unit.coordinates = coordinates
unit.last_updated = datetime.utcnow()
# Seismograph-specific fields
@@ -630,20 +617,15 @@ async def edit_roster_unit(
unit.next_calibration_due = next_cal_date
unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None
# Auto-fill data from modem if pairing and fields are empty
# Auto-fill data from modem if pairing and fields are empty.
# Location/address/coordinates live on MonitoringLocation now, nothing
# to copy across roster rows.
if deployed_with_modem_id:
modem = db.query(RosterUnit).filter(
RosterUnit.id == deployed_with_modem_id,
RosterUnit.device_type == "modem"
).first()
if modem:
# Only fill if the device field is empty
if not unit.location and modem.location:
unit.location = modem.location
if not unit.address and modem.address:
unit.address = modem.address
if not unit.coordinates and modem.coordinates:
unit.coordinates = modem.coordinates
if not unit.project_id and modem.project_id:
unit.project_id = modem.project_id
if not unit.note and modem.note:
@@ -769,26 +751,6 @@ async def edit_roster_unit(
record_history(db, paired_unit.id, "project_change", "project_id",
old_paired_project or "", project_id or "", f"cascade from {unit_id}")
# Cascade address/location
if cascade_location in ['true', 'True', '1', 'yes']:
old_paired_address = paired_unit.address
old_paired_location = paired_unit.location
paired_unit.address = address
paired_unit.location = location
paired_unit.last_updated = datetime.utcnow()
if old_paired_address != address:
record_history(db, paired_unit.id, "address_change", "address",
old_paired_address or "", address or "", f"cascade from {unit_id}")
# Cascade coordinates
if cascade_coordinates in ['true', 'True', '1', 'yes']:
old_paired_coords = paired_unit.coordinates
paired_unit.coordinates = coordinates
paired_unit.last_updated = datetime.utcnow()
if old_paired_coords != coordinates:
record_history(db, paired_unit.id, "coordinates_change", "coordinates",
old_paired_coords or "", coordinates or "", f"cascade from {unit_id}")
# Cascade note
if cascade_note in ['true', 'True', '1', 'yes']:
old_paired_note = paired_unit.note
@@ -1011,9 +973,8 @@ async def import_csv(
- retired: Boolean
- note: Notes about the unit
- project_id: Project identifier
- location: Location description
- address: Street address
- coordinates: GPS coordinates (lat;lon or lat,lon)
(Location / address / coordinates are not roster fields anymore they
live on the MonitoringLocation a unit is assigned to.)
Seismograph-specific:
- last_calibrated: Date (YYYY-MM-DD)
@@ -1126,9 +1087,6 @@ async def import_csv(
existing_unit.retired = _parse_bool(row.get('retired', '')) if row.get('retired') else existing_unit.retired
existing_unit.note = _get_csv_value(row, 'note', existing_unit.note)
existing_unit.project_id = _get_csv_value(row, 'project_id', existing_unit.project_id)
existing_unit.location = _get_csv_value(row, 'location', existing_unit.location)
existing_unit.address = _get_csv_value(row, 'address', existing_unit.address)
existing_unit.coordinates = _get_csv_value(row, 'coordinates', existing_unit.coordinates)
existing_unit.last_updated = datetime.utcnow()
# Seismograph-specific fields
@@ -1194,9 +1152,6 @@ async def import_csv(
retired=_parse_bool(row.get('retired', '')),
note=_get_csv_value(row, 'note', ''),
project_id=_get_csv_value(row, 'project_id'),
location=_get_csv_value(row, 'location'),
address=_get_csv_value(row, 'address'),
coordinates=_get_csv_value(row, 'coordinates'),
last_updated=datetime.utcnow(),
# Seismograph fields - auto-calc next_calibration_due from last_calibrated
last_calibrated=last_cal,
+14 -9
View File
@@ -12,6 +12,7 @@ from pathlib import Path
from backend.database import get_db
from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
from backend.services.database_backup import DatabaseBackupService
from backend.services.unit_location import bulk_active_locations
router = APIRouter(prefix="/api/settings", tags=["settings"])
@@ -21,11 +22,14 @@ def export_roster_csv(db: Session = Depends(get_db)):
"""Export all roster units to CSV"""
units = db.query(RosterUnit).all()
# Create CSV in memory
# Create CSV in memory. Location lives on MonitoringLocation now, so
# we don't export legacy address/coordinates/location columns here —
# round-trip CSV editing would otherwise look like it edits unit
# location, when it can't.
output = io.StringIO()
fieldnames = [
'unit_id', 'unit_type', 'device_type', 'deployed', 'retired',
'note', 'project_id', 'location', 'address', 'coordinates',
'note', 'project_id',
'last_calibrated', 'next_calibration_due', 'deployed_with_modem_id',
'ip_address', 'phone_number', 'hardware_model'
]
@@ -42,9 +46,6 @@ def export_roster_csv(db: Session = Depends(get_db)):
'retired': 'true' if unit.retired else 'false',
'note': unit.note or '',
'project_id': unit.project_id or '',
'location': unit.location or '',
'address': unit.address or '',
'coordinates': unit.coordinates or '',
'last_calibrated': unit.last_calibrated.strftime('%Y-%m-%d') if unit.last_calibrated else '',
'next_calibration_due': unit.next_calibration_due.strftime('%Y-%m-%d') if unit.next_calibration_due else '',
'deployed_with_modem_id': unit.deployed_with_modem_id or '',
@@ -82,6 +83,7 @@ def get_table_stats(db: Session = Depends(get_db)):
def get_all_roster_units(db: Session = Depends(get_db)):
"""Get all roster units for management table"""
units = db.query(RosterUnit).order_by(RosterUnit.id).all()
active_locs = bulk_active_locations(db, units)
return [{
"id": unit.id,
@@ -90,10 +92,10 @@ def get_all_roster_units(db: Session = Depends(get_db)):
"deployed": unit.deployed,
"retired": unit.retired,
"note": unit.note or "",
"project_id": unit.project_id or "",
"location": unit.location or "",
"address": unit.address or "",
"coordinates": unit.coordinates or "",
"project_id": (active_locs.get(unit.id) or {}).get("project_id") or unit.project_id or "",
"address": (active_locs.get(unit.id) or {}).get("address") or "",
"coordinates": (active_locs.get(unit.id) or {}).get("coordinates") or "",
"location_name": (active_locs.get(unit.id) or {}).get("name") or "",
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else None,
"next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else None,
"deployed_with_modem_id": unit.deployed_with_modem_id or "",
@@ -267,6 +269,7 @@ class PreferencesUpdate(BaseModel):
calibration_warning_days: Optional[int] = None
status_ok_threshold_hours: Optional[int] = None
status_pending_threshold_hours: Optional[int] = None
mic_unit_pref: Optional[str] = None
@router.get("/preferences")
@@ -293,6 +296,7 @@ def get_preferences(db: Session = Depends(get_db)):
"calibration_warning_days": prefs.calibration_warning_days,
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
"mic_unit_pref": prefs.mic_unit_pref or "psi",
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
}
@@ -334,6 +338,7 @@ def update_preferences(
"calibration_warning_days": prefs.calibration_warning_days,
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
"mic_unit_pref": prefs.mic_unit_pref or "psi",
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
}
+4 -2
View File
@@ -14,6 +14,7 @@ import os
from backend.database import get_db
from backend.models import RosterUnit
from backend.services.unit_location import get_active_location
from backend.templates_config import templates
logger = logging.getLogger(__name__)
@@ -58,13 +59,14 @@ async def get_slm_summary(unit_id: str, db: Session = Depends(get_db)):
except Exception as e:
logger.warning(f"Failed to get SLM status for {unit_id}: {e}")
loc = get_active_location(db, unit_id)
return {
"unit_id": unit_id,
"device_type": "slm",
"deployed": unit.deployed,
"model": unit.slm_model or "NL-43",
"location": unit.address or unit.location,
"coordinates": unit.coordinates,
"location": (loc or {}).get("address") or (loc or {}).get("name") or "",
"coordinates": (loc or {}).get("coordinates") or "",
"note": unit.note,
"status": status_data,
"last_check": unit.slm_last_check.isoformat() if unit.slm_last_check else None,
+12 -16
View File
@@ -5,6 +5,7 @@ from typing import Dict, Any, Optional
from backend.database import get_db
from backend.services.snapshot import emit_status_snapshot
from backend.services.unit_location import get_active_location
from backend.models import RosterUnit
router = APIRouter(prefix="/api", tags=["units"])
@@ -13,7 +14,8 @@ router = APIRouter(prefix="/api", tags=["units"])
@router.get("/unit/{unit_id}")
def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
"""
Returns detailed data for a single unit.
Returns detailed data for a single unit, including its active deployment
location (or None if benched / unassigned).
"""
snapshot = emit_status_snapshot()
@@ -21,17 +23,7 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
unit_data = snapshot["units"][unit_id]
# Mock coordinates for now (will be replaced with real data)
mock_coords = {
"BE1234": {"lat": 37.7749, "lon": -122.4194, "location": "San Francisco, CA"},
"BE5678": {"lat": 34.0522, "lon": -118.2437, "location": "Los Angeles, CA"},
"BE9012": {"lat": 40.7128, "lon": -74.0060, "location": "New York, NY"},
"BE3456": {"lat": 41.8781, "lon": -87.6298, "location": "Chicago, IL"},
"BE7890": {"lat": 29.7604, "lon": -95.3698, "location": "Houston, TX"},
}
coords = mock_coords.get(unit_id, {"lat": 39.8283, "lon": -98.5795, "location": "Unknown"})
active_loc = get_active_location(db, unit_id)
return {
"id": unit_id,
@@ -41,7 +33,7 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
"last_file": unit_data.get("fname", ""),
"deployed": unit_data["deployed"],
"note": unit_data.get("note", ""),
"coordinates": coords
"active_location": active_loc,
}
@@ -49,12 +41,16 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)):
"""
Get unit data directly from the roster (for settings/configuration).
Address/coordinates come from the active MonitoringLocation, not the
roster row.
"""
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
if not unit:
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
active_loc = get_active_location(db, unit_id)
return {
"id": unit.id,
"unit_type": unit.unit_type,
@@ -62,9 +58,9 @@ def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)):
"deployed": unit.deployed,
"retired": unit.retired,
"note": unit.note,
"location": unit.location,
"address": unit.address,
"coordinates": unit.coordinates,
"active_location": active_loc,
"address": (active_loc or {}).get("address") or "",
"coordinates": (active_loc or {}).get("coordinates") or "",
"slm_host": unit.slm_host,
"slm_tcp_port": unit.slm_tcp_port,
"slm_ftp_port": unit.slm_ftp_port,
+33 -7
View File
@@ -39,9 +39,35 @@ from backend.services.sfm_events import (
_fetch_events_for_serial,
_iso_utc,
)
from backend.utils.timezone import utc_to_local
log = logging.getLogger("backend.services.deployment_timeline")
def _iso_local(dt) -> Optional[str]:
"""Serialize a datetime / ISO-string in the user's configured timezone.
The timeline frontend slices these strings to character 19 to produce
"YYYY-MM-DD HH:MM:SS" no JS-side timezone conversion happens. We
therefore emit *already-local* timestamps here so the displayed time
matches what the operator actually saw on the wall clock.
Accepts either a ``datetime`` (DB column) or an ISO ``str`` (SFM
response). Returns ``None`` for ``None`` input. Naive ISO strings
from SFM are interpreted as UTC.
"""
if dt is None:
return None
if isinstance(dt, str):
try:
dt = datetime.fromisoformat(dt.replace("Z", "").replace(" ", "T"))
except ValueError:
return dt # give up gracefully — emit whatever SFM sent
local = utc_to_local(dt)
if local is None:
return None
return local.replace(tzinfo=None).isoformat()
# Don't emit synthetic gap entries shorter than this (seconds). Avoids visual
# clutter from a sub-second handoff during a swap workflow.
_MIN_GAP_SECONDS = 24 * 3600 # 1 day
@@ -185,8 +211,8 @@ async def deployment_timeline_for_unit(
overlays[a.id] = {
"event_count": len(events),
"peak_pvs": peak,
"peak_pvs_at": peak_at,
"last_event": last_ev,
"peak_pvs_at": _iso_local(peak_at),
"last_event": _iso_local(last_ev),
}
# 4. Build entries. Start by emitting assignment rows + gap rows between
@@ -202,8 +228,8 @@ async def deployment_timeline_for_unit(
entry = {
"kind": "assignment",
"starts_at": _iso_utc(a.assigned_at),
"ends_at": _iso_utc(a.assigned_until),
"starts_at": _iso_local(a.assigned_at),
"ends_at": _iso_local(a.assigned_until),
"duration_days": round(duration_days, 1) if duration_days is not None else None,
"assignment_id": a.id,
"location_id": a.location_id,
@@ -227,8 +253,8 @@ async def deployment_timeline_for_unit(
if gap_seconds >= _MIN_GAP_SECONDS:
entries.append({
"kind": "gap",
"starts_at": _iso_utc(gap_start),
"ends_at": _iso_utc(gap_end),
"starts_at": _iso_local(gap_start),
"ends_at": _iso_local(gap_end),
"duration_days": round(gap_seconds / 86400, 1),
"context": "between assignments",
})
@@ -241,7 +267,7 @@ async def deployment_timeline_for_unit(
continue
entries.append({
"kind": "state_change",
"starts_at": _iso_utc(h.changed_at),
"starts_at": _iso_local(h.changed_at),
"ends_at": None,
"duration_days": None,
"change_type": h.change_type,
+21 -6
View File
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from backend.database import get_db_session
from backend.models import Emitter, RosterUnit, IgnoredUnit
from backend.services.unit_location import bulk_active_locations
log = logging.getLogger(__name__)
@@ -137,6 +138,10 @@ def emit_status_snapshot():
emitters = {e.id: e for e in db.query(Emitter).all()}
ignored = {i.id for i in db.query(IgnoredUnit).all()}
# Active-assignment location lookup for all roster units (direct only;
# modems inherit from their paired device below in the derive loop).
active_locs = bulk_active_locations(db, list(roster.values()))
# SFM event-forwards are now the primary "last seen" signal for
# seismographs. Watcher heartbeats stay as a backup — if SFM is down
# or hasn't seen a serial, we fall back to Emitter.last_seen.
@@ -225,10 +230,13 @@ def emit_status_snapshot():
"ip_address": r.ip_address,
"phone_number": r.phone_number,
"hardware_model": r.hardware_model,
# Location for mapping
"location": r.location or "",
"address": r.address or "",
"coordinates": r.coordinates or "",
# Location for mapping — sourced from active UnitAssignment
# → MonitoringLocation. Empty for benched / unassigned.
"address": (active_locs.get(unit_id) or {}).get("address") or "",
"coordinates": (active_locs.get(unit_id) or {}).get("coordinates") or "",
"location_name": (active_locs.get(unit_id) or {}).get("name") or "",
"project_id": (active_locs.get(unit_id) or {}).get("project_id") or "",
"location_id": (active_locs.get(unit_id) or {}).get("location_id") or "",
}
# --- Add unexpected emitter-only units ---
@@ -267,10 +275,12 @@ def emit_status_snapshot():
"ip_address": None,
"phone_number": None,
"hardware_model": None,
# Location fields
"location": "",
# Location fields — unknown units have no assignment
"address": "",
"coordinates": "",
"location_name": "",
"project_id": "",
"location_id": "",
}
# --- Derive modem status from paired devices ---
@@ -301,6 +311,11 @@ def emit_status_snapshot():
unit_data["last"] = paired_unit.get("last")
unit_data["last_seen_source"] = paired_unit.get("last_seen_source", "none")
unit_data["derived_from"] = paired_unit_id
# Inherit deployment location too — modems don't carry
# their own UnitAssignment.
for k in ("address", "coordinates", "location_name", "project_id", "location_id"):
if not unit_data.get(k):
unit_data[k] = paired_unit.get(k, "")
# Separate buckets for UI
active_units = {
+125
View File
@@ -0,0 +1,125 @@
"""
Active-assignment location resolution for roster units.
`RosterUnit.location`, `.address`, `.coordinates` are legacy per-unit fields.
The current source of truth for "where is this unit deployed right now" is the
active `UnitAssignment` (assigned_until IS NULL) pointing at a
`MonitoringLocation`, which carries the canonical address/coordinates/name.
Modems don't get their own `UnitAssignment` — they're paired with a
seismograph or SLM via `deployed_with_unit_id`. A deployed modem inherits the
location of its paired device's active assignment.
Returned dict shape (or None if no active assignment resolvable):
{
"location_id": "uuid",
"project_id": "uuid",
"name": "NRL-001",
"address": "123 Main St" | None,
"coordinates": "34.0522,-118.2437" | None,
"via_paired_unit_id": "BE1234" | None, # set only for modems
}
"""
from typing import Optional
from sqlalchemy.orm import Session
from backend.models import MonitoringLocation, RosterUnit, UnitAssignment
def _serialize(loc: MonitoringLocation, via_paired_unit_id: Optional[str] = None) -> dict:
return {
"location_id": loc.id,
"project_id": loc.project_id,
"name": loc.name,
"address": loc.address or None,
"coordinates": loc.coordinates or None,
"via_paired_unit_id": via_paired_unit_id,
}
def _active_location_for_unit_id(db: Session, unit_id: str) -> Optional[MonitoringLocation]:
"""Return the MonitoringLocation tied to this unit's active assignment, if any."""
row = (
db.query(MonitoringLocation)
.join(UnitAssignment, UnitAssignment.location_id == MonitoringLocation.id)
.filter(
UnitAssignment.unit_id == unit_id,
UnitAssignment.assigned_until == None, # noqa: E711
)
.order_by(UnitAssignment.assigned_at.desc())
.first()
)
return row
def get_active_location(db: Session, unit_id: str) -> Optional[dict]:
"""
Resolve the active deployment location for a unit.
Seismographs / SLMs: their own active UnitAssignment.
Modems: follow `deployed_with_unit_id` to the paired device's active
assignment (modems don't carry their own assignment).
"""
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
if unit is None:
return None
if (unit.device_type or "seismograph") == "modem":
paired_id = unit.deployed_with_unit_id
if not paired_id:
return None
loc = _active_location_for_unit_id(db, paired_id)
return _serialize(loc, via_paired_unit_id=paired_id) if loc else None
loc = _active_location_for_unit_id(db, unit_id)
return _serialize(loc) if loc else None
def bulk_active_locations(db: Session, units: list[RosterUnit]) -> dict[str, dict]:
"""
Resolve active locations for many units in two queries. Use this from
snapshot-style loops to avoid N+1 lookups.
Returns {unit_id: <serialized location dict>} only populated for units
that resolve to an active assignment. Modems are resolved by walking
`deployed_with_unit_id` to the paired device's entry in the same map.
"""
if not units:
return {}
direct_unit_ids = [
u.id for u in units
if (u.device_type or "seismograph") != "modem"
]
direct: dict[str, MonitoringLocation] = {}
if direct_unit_ids:
rows = (
db.query(UnitAssignment.unit_id, MonitoringLocation)
.join(MonitoringLocation, MonitoringLocation.id == UnitAssignment.location_id)
.filter(
UnitAssignment.unit_id.in_(direct_unit_ids),
UnitAssignment.assigned_until == None, # noqa: E711
)
.order_by(UnitAssignment.assigned_at.desc())
.all()
)
# First row wins per unit_id (most recent assigned_at).
for unit_id, loc in rows:
direct.setdefault(unit_id, loc)
out: dict[str, dict] = {
uid: _serialize(loc) for uid, loc in direct.items()
}
# Modems inherit from paired device.
for u in units:
if (u.device_type or "seismograph") != "modem":
continue
paired_id = u.deployed_with_unit_id
if paired_id and paired_id in direct:
out[u.id] = _serialize(direct[paired_id], via_paired_unit_id=paired_id)
return out
+474 -10
View File
@@ -28,6 +28,27 @@
(function () {
const MODAL_ID = 'event-detail-modal';
// ── Chart.js constants (ported from sfm_webapp.html:2555-2880) ──
const _CHANNEL_COLORS = {
MicL: '#e066ff', // purple — distinct from the geo channels
Long: '#3b82f6', // blue
Vert: '#22c55e', // green
Tran: '#ef4444', // red
};
const _CHANNEL_ORDER = ['MicL', 'Long', 'Vert', 'Tran'];
// dB(L) reference pressure — 20 µPa expressed in psi (Instantel native unit).
const DBL_REF = 2.9e-9;
// Mic display floor — sound-pressure AC samples sit at the digitisation
// noise floor most of the time (1-2 ADC counts ≈ 20-40 dBL). Without
// a floor, the chart looks like a sparse pattern of "moments when sound
// briefly exceeded the Y-axis bottom" instead of an SPL-vs-time curve.
const MIC_DBL_FLOOR = 60;
let _charts = {}; // ch → Chart instance
let _micUnitPref = 'psi'; // refreshed via fetch on first chart render
let _micUnitPrefLoaded = false; // one-shot fetch guard
function _esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;')
@@ -224,32 +245,370 @@
</div>`;
}
function _renderReview(s, eventId) {
const rev = s.review || {};
const ft = !!rev.false_trigger;
const reviewer = rev.reviewer || '';
const notes = rev.notes || '';
const reviewedAt = rev.reviewed_at
? rev.reviewed_at.replace('T', ' ').slice(0, 19)
: null;
return `<div class="bg-gray-50 dark:bg-slate-900/40 border border-gray-200 dark:border-slate-700 rounded-lg p-4">
<div class="flex flex-wrap items-center gap-x-6 gap-y-3">
<label class="inline-flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" id="event-review-ft" ${ft ? 'checked' : ''}
class="w-4 h-4 rounded text-seismo-orange focus:ring-seismo-orange">
<span class="font-medium">Flag as false trigger</span>
</label>
<div class="flex items-center gap-2 text-sm flex-1 min-w-[180px]">
<label for="event-review-reviewer" class="text-gray-500">Reviewer</label>
<input type="text" id="event-review-reviewer" value="${_esc(reviewer)}"
placeholder="Initials or name"
class="flex-1 px-2 py-1 text-sm bg-white dark:bg-slate-800 border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-seismo-orange">
</div>
</div>
<div class="mt-3">
<label for="event-review-notes" class="block text-xs text-gray-500 mb-1">Notes</label>
<textarea id="event-review-notes" rows="2"
placeholder="Optional context — what caused the FT, follow-up actions, etc."
class="w-full px-2 py-1 text-sm bg-white dark:bg-slate-800 border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-seismo-orange">${_esc(notes)}</textarea>
</div>
<div class="flex items-center justify-between gap-3 mt-3">
<span id="event-review-status" class="text-xs text-gray-500 dark:text-gray-400">
${reviewedAt ? `Last reviewed ${reviewedAt}` : 'Not yet reviewed.'}
</span>
<button type="button"
onclick="window.saveEventReview('${_esc(eventId)}')"
class="px-4 py-1.5 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg text-sm font-medium transition-colors">
Save
</button>
</div>
</div>`;
}
// ── Waveform / histogram chart helpers ──────────────────────────
async function _loadMicUnitPref() {
if (_micUnitPrefLoaded) return _micUnitPref;
try {
const r = await fetch('/api/settings/preferences');
if (r.ok) {
const prefs = await r.json();
_micUnitPref = prefs.mic_unit_pref === 'dBL' ? 'dBL' : 'psi';
}
} catch (e) {
// Network error → silent fall back to default 'psi'.
}
_micUnitPrefLoaded = true;
return _micUnitPref;
}
function _psiToDbl(psi) {
if (psi == null || !(psi > 0)) return null;
return 20 * Math.log10(psi / DBL_REF);
}
// Rectifying psi→dBL converter for per-sample values — see comments in
// sfm_webapp.html:2592-2607 for the floor rationale.
function _psiToDblForChart(psi) {
if (psi == null) return MIC_DBL_FLOOR;
const a = Math.abs(psi);
if (a === 0) return MIC_DBL_FLOOR;
const dbl = 20 * Math.log10(a / DBL_REF);
return dbl > MIC_DBL_FLOOR ? dbl : MIC_DBL_FLOOR;
}
// Adaptive decimal formatter — sensible precision in the normal range,
// scientific notation only at the extremes.
function _fmtPeak(v, unit) {
if (v == null || (typeof v === 'number' && !isFinite(v))) return '';
if (typeof v !== 'number') return String(v) + (unit ? ' ' + unit : '');
if (v === 0) return '0' + (unit ? ' ' + unit : '');
const a = Math.abs(v);
const u = unit ? ' ' + unit : '';
if (a >= 0.0001 && a < 10000) {
const d = a >= 100 ? 1 : a >= 10 ? 2 : a >= 1 ? 3 : a >= 0.1 ? 4 : 5;
return v.toFixed(d) + u;
}
return v.toExponential(2) + u;
}
function _destroyCharts() {
Object.values(_charts).forEach(c => { try { c.destroy(); } catch (e) { /* noop */ } });
_charts = {};
}
// Returns true when Tailwind dark mode is active (the `dark` class is
// toggled on <html> by Terra-View's theme handler). Drives chart grid
// + tick colors so they have contrast on both backgrounds.
function _isDark() {
return document.documentElement.classList.contains('dark');
}
function _renderWaveformInto(containerId, data, micUnit) {
const container = document.getElementById(containerId);
if (!container) return;
container.innerHTML = '';
_destroyCharts();
const channels = data.channels || {};
const ta = data.time_axis || {};
const sr = ta.sample_rate || 1024;
const dtMs = ta.dt_ms || (1000.0 / sr);
const t0Ms = ta.t0_ms != null ? ta.t0_ms : 0;
const isHistogram = String(data.record_type || '').toLowerCase().includes('histogram');
const withData = _CHANNEL_ORDER.filter(ch =>
channels[ch] && (channels[ch].values || []).length > 0
);
const lastCh = withData[withData.length - 1];
// Theme-aware chart colors. Tailwind dark uses bg-slate-800 (~#1e293b);
// light is white. Grids + ticks need contrast on both.
const dark = _isDark();
const gridColor = dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
const tickColor = dark ? '#94a3b8' : '#64748b';
if (withData.length === 0) {
container.innerHTML = `<div class="text-sm text-gray-500 dark:text-gray-400 italic py-6 text-center">
No waveform samples decoded codec walker returned 0 valid blocks for this event.
</div>`;
return;
}
for (const ch of _CHANNEL_ORDER) {
const chData = channels[ch];
if (!chData) continue;
let values = chData.values || [];
let chUnit = chData.unit || '';
let chPeak = chData.peak;
// Mic: convert psi → dBL when the user pref is dBL (default).
if (ch === 'MicL' && chUnit === 'psi' && micUnit === 'dBL') {
values = values.map(_psiToDblForChart);
chPeak = _psiToDbl(chPeak);
chUnit = 'dB(L)';
}
const wrap = document.createElement('div');
wrap.className = 'bg-gray-50 dark:bg-slate-900/40 border border-gray-200 dark:border-slate-700 rounded-md px-3 pr-8 pb-1 pt-1 mb-1';
const lbl = document.createElement('div');
lbl.className = 'text-[10px] font-semibold uppercase tracking-wider mb-0.5 flex justify-between items-baseline';
lbl.style.color = _CHANNEL_COLORS[ch];
const peakStr = chPeak != null ? `peak ${_fmtPeak(chPeak, chUnit)}` : '';
lbl.innerHTML = `<span>${ch}</span><span class="text-gray-500 dark:text-gray-400 font-normal">${peakStr}</span>`;
wrap.appendChild(lbl);
if (values.length === 0) {
const e = document.createElement('div');
e.className = 'h-20 flex items-center justify-center text-xs text-gray-400 italic';
e.textContent = 'no samples decoded';
wrap.appendChild(e);
container.appendChild(wrap);
continue;
}
const canvasWrap = document.createElement('div');
canvasWrap.className = 'relative';
canvasWrap.style.height = '100px';
const canvas = document.createElement('canvas');
canvasWrap.appendChild(canvas);
wrap.appendChild(canvasWrap);
container.appendChild(wrap);
// X-axis: waveforms use ms-relative-to-trigger; histograms use
// the BW-reported interval timestamps (HH:MM:SS) when the server
// aggregated to BW intervals, else interval index.
let times;
if (isHistogram) {
const intervalTimes = ta.interval_times || [];
times = (intervalTimes.length === values.length)
? intervalTimes
: values.map((_, i) => i + 1);
} else {
times = values.map((_, i) => t0Ms + i * dtMs);
}
// Downsample for rendering when very long.
const MAX = 3000;
let rT = times, rV = values;
if (values.length > MAX) {
const step = Math.ceil(values.length / MAX);
rT = times.filter((_, i) => i % step === 0);
rV = values.filter((_, i) => i % step === 0);
}
const showX = (ch === lastCh);
const xAxisLabel = isHistogram ? '' : ' ms';
const fmtTick = i => {
const v = rT[i];
if (typeof v === 'number') {
const s = Number.isInteger(v) ? String(v) : v.toFixed(1);
return s + xAxisLabel;
}
return String(v) + xAxisLabel;
};
// Y-axis bounds — see sfm_webapp.html:2744-2786 for the rationale.
let yBounds = {};
const isGeo = ch !== 'MicL';
if (isGeo && !isHistogram) {
let absMax = 0;
for (const v of values) {
const a = Math.abs(v);
if (a > absMax) absMax = a;
}
const padded = (absMax || 1) * 1.10;
yBounds = { min: -padded, max: padded };
} else if (isGeo && isHistogram) {
const HIST_GEO_MIN_INS = 0.05;
let peak = 0;
for (const v of values) { const a = Math.abs(v); if (a > peak) peak = a; }
yBounds = { min: 0, max: Math.max(peak * 1.10, HIST_GEO_MIN_INS) };
} else if (ch === 'MicL' && micUnit === 'dBL') {
const peakDbl = (typeof chPeak === 'number' && isFinite(chPeak))
? chPeak + 5 : 100;
yBounds = { min: MIC_DBL_FLOOR, max: Math.max(peakDbl, MIC_DBL_FLOOR + 20) };
} else if (ch === 'MicL' && isHistogram && micUnit === 'psi') {
const HIST_MIC_MIN_PSI = 0.001;
let peak = 0;
for (const v of values) { const a = Math.abs(v); if (a > peak) peak = a; }
yBounds = { min: 0, max: Math.max(peak * 1.10, HIST_MIC_MIN_PSI) };
}
_charts[ch] = new Chart(canvas, {
type: isHistogram ? 'bar' : 'line',
data: {
labels: rT.map(t => (typeof t === 'number' ? (Number.isInteger(t) ? String(t) : t.toFixed(2)) : t)),
datasets: isHistogram ? [{
data: rV,
backgroundColor: _CHANNEL_COLORS[ch],
borderWidth: 0,
barPercentage: 1.0,
categoryPercentage: 1.0,
}] : [{
data: rV,
borderColor: _CHANNEL_COLORS[ch],
borderWidth: 1,
pointRadius: 0,
tension: 0,
}],
},
options: {
animation: false, responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
mode: 'index', intersect: false,
callbacks: {
title: items => isHistogram
? `interval ${items[0].label}`
: `t = ${items[0].label} ms`,
label: item => `${ch}: ${_fmtPeak(item.raw, chUnit)}`,
},
},
},
scales: {
x: {
type: 'category', display: showX,
ticks: { color: tickColor, maxTicksLimit: 8, maxRotation: 0, callback: (v, i) => fmtTick(i) },
grid: { color: gridColor, drawTicks: showX },
},
y: {
...yBounds,
ticks: { color: tickColor, maxTicksLimit: 4 },
grid: { color: gridColor },
title: { display: true, text: chUnit, color: tickColor, font: { size: 9 } },
},
},
},
plugins: isHistogram ? [] : [{
id: 'overlays',
afterDraw(chart) {
const ctx = chart.ctx, x = chart.scales.x, y = chart.scales.y;
const zi = rT.findIndex(t => parseFloat(t) >= 0);
if (zi >= 0) {
const px = x.getPixelForValue(zi);
ctx.save();
ctx.beginPath(); ctx.moveTo(px, y.top); ctx.lineTo(px, y.bottom);
ctx.strokeStyle = 'rgba(239,68,68,0.8)'; ctx.lineWidth = 1.2;
ctx.setLineDash([4, 3]); ctx.stroke(); ctx.restore();
ctx.save();
ctx.fillStyle = '#ef4444';
ctx.beginPath();
ctx.moveTo(px - 4, y.top - 7); ctx.lineTo(px + 4, y.top - 7); ctx.lineTo(px, y.top - 1);
ctx.closePath(); ctx.fill();
ctx.beginPath();
ctx.moveTo(px - 4, y.bottom + 7); ctx.lineTo(px + 4, y.bottom + 7); ctx.lineTo(px, y.bottom + 1);
ctx.closePath(); ctx.fill();
ctx.restore();
}
const zy = y.getPixelForValue(0);
if (zy >= y.top && zy <= y.bottom) {
ctx.save();
ctx.strokeStyle = gridColor; ctx.lineWidth = 0.8;
ctx.setLineDash([2, 2]);
ctx.beginPath(); ctx.moveTo(x.left, zy); ctx.lineTo(x.right, zy); ctx.stroke();
ctx.restore();
ctx.save();
ctx.fillStyle = tickColor; ctx.font = '10px monospace';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText('0.0', x.right + 6, zy);
ctx.restore();
}
},
}],
});
}
}
function _renderFileInfo(s, eventId) {
const bw = s.blastware || {};
const src = s.source || {};
const sizeKb = bw.filesize ? (bw.filesize / 1024).toFixed(1) : null;
const canDownloadBinary = !!(bw.available && bw.filename && eventId);
const txtFilename = src && src.txt_filename;
const reportPdfUrl = `/api/sfm/db/events/${encodeURIComponent(eventId)}/report.pdf`;
const reportTxtUrl = `/api/sfm/db/events/${encodeURIComponent(eventId)}/ascii_report.txt`;
const downloadButtons = `
<div class="flex flex-wrap gap-2 mb-4">
<button type="button"
onclick="window.toggleEventPdfPreview()"
class="inline-flex items-center gap-2 px-4 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg text-sm font-medium transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<span id="event-pdf-toggle-label">Show Event Report PDF</span>
</button>
<a href="${reportPdfUrl}" download
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Download PDF
</a>
${canDownloadBinary ? `
<a href="/api/sfm/db/events/${encodeURIComponent(eventId)}/blastware_file"
download="${_esc(bw.filename)}"
class="inline-flex items-center gap-2 px-4 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg text-sm font-medium transition-colors">
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Download Blastware file
<span class="text-xs opacity-80 ml-1">(${_esc(bw.filename)}${sizeKb ? `, ${sizeKb} KB` : ''})</span>
Blastware binary
<span class="text-xs opacity-60 ml-1">${sizeKb ? `(${sizeKb} KB)` : ''}</span>
</a>
` : `
<span class="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 rounded-lg text-sm cursor-not-allowed">
` : ''}
${txtFilename ? `
<a href="${reportTxtUrl}" download="${_esc(txtFilename)}"
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
Blastware file unavailable
</span>
`}
Original .TXT report
</a>
` : ''}
<button type="button"
onclick="window.toggleEventJsonViewer()"
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm transition-colors">
@@ -267,6 +626,11 @@
Download sidecar JSON
</a>
</div>
<div id="event-pdf-preview" class="hidden mb-4 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-gray-50 dark:bg-slate-900">
<iframe id="event-pdf-iframe" title="Event Report PDF preview"
class="w-full" style="height:80vh; min-height:600px; border:0;"
data-pdf-url="${reportPdfUrl}"></iframe>
</div>
<div id="event-json-viewer" class="hidden mb-4">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Sidecar JSON</span>
@@ -283,7 +647,9 @@
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
<div class="sm:col-span-2"><span class="text-gray-500">Blastware file</span> <span class="font-mono text-xs ml-1">${_esc(bw.filename || '')}</span> ${sizeKb ? `<span class="text-xs text-gray-500 ml-2">(${sizeKb} KB)</span>` : ''}</div>
<div class="sm:col-span-2"><span class="text-gray-500">SHA-256</span> <span class="font-mono text-xs ml-1 break-all">${_esc(bw.sha256 || '')}</span></div>
<div><span class="text-gray-500">Captured at</span> <span class="font-medium ml-1">${_esc(src.captured_at ? src.captured_at.slice(0, 19).replace('T', ' ') : '')}</span></div>
<div title="When SFM received and stored this event — NOT the unit-local trigger time (see Timestamp at the top of the modal for that).">
<span class="text-gray-500">Time received</span> <span class="font-medium ml-1">${_esc(src.captured_at ? src.captured_at.slice(0, 19).replace('T', ' ') : '')}</span>
</div>
<div><span class="text-gray-500">Tool version</span> <span class="font-mono text-xs ml-1">${_esc(src.tool_version || '')}</span></div>
</div>`;
}
@@ -345,6 +711,10 @@
${_sectionHeader('Peak Particle Velocity')}
${_renderPeakValues(s)}
${_sectionHeader('Waveform')}
<div id="event-waveform-status" class="text-xs text-gray-500 dark:text-gray-400 italic mb-2">Loading waveform</div>
<div id="event-waveform-charts" class="space-y-0.5"></div>
${(s.bw_report && (s.bw_report.mic || s.peak_values?.mic_psi != null)) ? `
${_sectionHeader('Microphone')}
${_renderMic(s)}
@@ -358,14 +728,43 @@
${_renderDeviceMetadata(s)}
` : ''}
${_sectionHeader('Review')}
${_renderReview(s, eventId)}
${_sectionHeader('Source File')}
${_renderFileInfo(s, eventId)}
`;
// Waveform load runs after the sidecar content is in the DOM, in
// parallel with the mic-unit-pref fetch. Either may complete first.
try {
const [wfRes, micUnit] = await Promise.all([
fetch(`/api/sfm/db/events/${encodeURIComponent(eventId)}/waveform.json`),
_loadMicUnitPref(),
]);
if (wfRes.status === 404) {
document.getElementById('event-waveform-status').textContent =
'No waveform data — codec returned 0 valid blocks for this event.';
return;
}
if (!wfRes.ok) {
document.getElementById('event-waveform-status').textContent =
'Failed to load waveform: HTTP ' + wfRes.status;
return;
}
const wfData = await wfRes.json();
document.getElementById('event-waveform-status').textContent = '';
_renderWaveformInto('event-waveform-charts', wfData, micUnit);
} catch (e) {
const st = document.getElementById('event-waveform-status');
if (st) st.textContent = 'Waveform fetch failed: ' + _esc(e.message);
}
};
window.closeEventDetailModal = function () {
const modal = document.getElementById(MODAL_ID);
if (modal) modal.classList.add('hidden');
_destroyCharts();
};
window.toggleEventJsonViewer = function () {
@@ -376,6 +775,71 @@
if (label) label.textContent = isHidden ? 'View JSON' : 'Hide JSON';
};
window.toggleEventPdfPreview = function () {
const preview = document.getElementById('event-pdf-preview');
const iframe = document.getElementById('event-pdf-iframe');
const label = document.getElementById('event-pdf-toggle-label');
if (!preview || !iframe) return;
const isHidden = preview.classList.toggle('hidden');
// Lazy-load the PDF: only set the iframe src on first reveal, so
// closing the event modal without opening the PDF never spends
// bandwidth on it.
if (!isHidden && !iframe.src) {
iframe.src = iframe.dataset.pdfUrl || '';
}
if (label) label.textContent = isHidden ? 'Show Event Report PDF' : 'Hide Event Report PDF';
// Scroll the iframe into view on first reveal so the operator
// doesn't have to hunt for it after clicking.
if (!isHidden) {
preview.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
window.saveEventReview = async function (eventId) {
const ft = document.getElementById('event-review-ft');
const reviewer = document.getElementById('event-review-reviewer');
const notes = document.getElementById('event-review-notes');
const status = document.getElementById('event-review-status');
if (!ft || !reviewer || !notes) return;
const payload = {
review: {
false_trigger: ft.checked,
reviewer: reviewer.value.trim() || null,
notes: notes.value.trim() || null,
}
};
if (status) {
status.textContent = 'Saving…';
status.className = 'text-xs text-gray-500 dark:text-gray-400';
}
try {
const r = await fetch(`/api/sfm/db/events/${encodeURIComponent(eventId)}/sidecar`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!r.ok) {
const t = await r.text().catch(() => '');
throw new Error('HTTP ' + r.status + (t ? `${t.slice(0, 120)}` : ''));
}
if (status) {
status.textContent = 'Saved.';
status.className = 'text-xs text-green-600 dark:text-green-400';
}
// Notify the host page so its event-list FT badge / row state
// can refresh. Pages opt in by listening for this event.
window.dispatchEvent(new CustomEvent('sfm-event-review-saved', {
detail: { eventId, review: payload.review },
}));
} catch (e) {
if (status) {
status.textContent = 'Save failed: ' + e.message;
status.className = 'text-xs text-red-600 dark:text-red-400';
}
}
};
window.copyEventJson = function () {
const pre = document.getElementById('event-json-pre');
const label = document.getElementById('event-json-copy-label');
+11 -2
View File
@@ -1,18 +1,27 @@
/* Service Worker for Seismo Fleet Manager PWA */
/* Network-first strategy with cache fallback for real-time data */
const CACHE_VERSION = 'v1';
// IMPORTANT: bump this on every release that touches a precached or
// runtime-cached static asset (event-modal.js, mobile.js, style.css,
// templates served at /, etc.). The activate handler deletes any cache
// not matching CACHE_VERSION, so old SW caches get evicted and mobile
// PWA users actually receive the new bundles instead of being stuck on
// the pre-bump version. Convention: keep it in sync with the Terra-View
// version string in backend/main.py.
const CACHE_VERSION = 'v0.13.2';
const STATIC_CACHE = `sfm-static-${CACHE_VERSION}`;
const DYNAMIC_CACHE = `sfm-dynamic-${CACHE_VERSION}`;
const DATA_CACHE = `sfm-data-${CACHE_VERSION}`;
// Files to precache (critical app shell)
// Files to precache (critical app shell). event-modal.js is included
// so its cache lifecycle is tied to the SW version bump explicitly.
const STATIC_FILES = [
'/',
'/static/style.css',
'/static/mobile.css',
'/static/mobile.js',
'/static/offline-db.js',
'/static/event-modal.js',
'/static/manifest.json',
'https://cdn.tailwindcss.com',
'https://unpkg.com/htmx.org@1.9.10',
+13
View File
@@ -11,6 +11,10 @@ services:
- ENVIRONMENT=production
- SLMM_BASE_URL=http://host.docker.internal:8100
- SFM_BASE_URL=http://sfm:8200
# Display timezone for server logs + any text-rendered timestamps.
# DB columns are stored UTC regardless; this only affects what
# operators see. Override here for non-US-East deployments.
- TZ=America/New_York
restart: unless-stopped
depends_on:
- slmm
@@ -56,9 +60,18 @@ services:
volumes:
- ../seismo-relay/sfm/data:/app/sfm/data
- ../seismo-relay/bridges/captures:/app/bridges/captures
# The DB + waveform store inside bridges/captures are symlinks
# pointing at the prod-snap directory. Mount its host path at
# the same absolute path inside the container so the symlinks
# resolve. Needed for SFM to query the events DB.
- ../seismo-relay-prod-snap:/home/serversdown/seismo-relay-prod-snap
environment:
- PYTHONUNBUFFERED=1
- PORT=8200
# Display timezone — affects server log timestamps, the PDF
# report renderer's UTC→local conversions, and matplotlib's
# datetime axes. DB columns (created_at etc.) are always UTC.
- TZ=America/New_York
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8200/health"]
+728
View File
@@ -0,0 +1,728 @@
{% extends "base.html" %}
{% block title %}Unit Swap - Seismo Fleet Manager{% endblock %}
{% block content %}
<div class="max-w-md mx-auto">
<div class="mb-4 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Unit Swap</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Swap a vibration unit (and modem) at a monitoring location.
</p>
</div>
<a href="/tools" class="text-xs text-seismo-orange hover:text-seismo-burgundy whitespace-nowrap">← Tools</a>
</div>
<!-- Stepper -->
<div class="flex items-center justify-between mb-4 text-[11px] text-gray-500 dark:text-gray-400">
<div id="swap-pill-1" class="flex items-center gap-1 text-seismo-orange font-medium">
<span class="w-5 h-5 rounded-full bg-seismo-orange text-white inline-flex items-center justify-center text-[10px]">1</span>
Project
</div>
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700 mx-1"></div>
<div id="swap-pill-2" class="flex items-center gap-1">
<span class="w-5 h-5 rounded-full bg-gray-200 dark:bg-gray-700 inline-flex items-center justify-center text-[10px]">2</span>
Location
</div>
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700 mx-1"></div>
<div id="swap-pill-3" class="flex items-center gap-1">
<span class="w-5 h-5 rounded-full bg-gray-200 dark:bg-gray-700 inline-flex items-center justify-center text-[10px]">3</span>
Unit
</div>
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700 mx-1"></div>
<div id="swap-pill-4" class="flex items-center gap-1">
<span class="w-5 h-5 rounded-full bg-gray-200 dark:bg-gray-700 inline-flex items-center justify-center text-[10px]">4</span>
Modem
</div>
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700 mx-1"></div>
<div id="swap-pill-5" class="flex items-center gap-1">
<span class="w-5 h-5 rounded-full bg-gray-200 dark:bg-gray-700 inline-flex items-center justify-center text-[10px]">5</span>
Confirm
</div>
</div>
<!-- Step 1: Project picker -->
<div id="swap-step-1" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-3">
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Which project?</span>
<input id="swap-project-search" type="search" autocomplete="off"
placeholder="Filter by number, client, or name…"
class="mt-2 w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
</label>
<div id="swap-project-list" class="max-h-96 overflow-y-auto space-y-1.5"></div>
</div>
<!-- Step 2: Location picker -->
<div id="swap-step-2" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-3">
<div class="flex items-center justify-between">
<div class="min-w-0">
<p class="text-xs text-gray-500 dark:text-gray-400">Project</p>
<p class="font-semibold text-seismo-orange truncate" id="swap-project-label"></p>
</div>
<button onclick="swapGoToStep(1)" class="text-xs text-gray-500 hover:text-seismo-orange whitespace-nowrap">Change</button>
</div>
<div>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Which location?</p>
<div id="swap-location-list" class="max-h-96 overflow-y-auto space-y-1.5"></div>
</div>
</div>
<!-- Step 3: New unit picker -->
<div id="swap-step-3" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-3">
<div class="flex items-center justify-between">
<div class="min-w-0 flex-1">
<p class="text-xs text-gray-500 dark:text-gray-400">Swapping at</p>
<p class="font-semibold text-seismo-orange truncate" id="swap-location-label"></p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Out: <span id="swap-old-unit-label" class="font-mono"></span>
</p>
</div>
<button onclick="swapGoToStep(2)" class="text-xs text-gray-500 hover:text-seismo-orange whitespace-nowrap">Change</button>
</div>
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Incoming unit</span>
<input id="swap-unit-search" type="search" autocomplete="off"
placeholder="Filter by serial…"
class="mt-2 w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Only seismographs without an active assignment.</p>
</label>
<div id="swap-unit-list" class="max-h-72 overflow-y-auto space-y-1.5"></div>
</div>
<!-- Step 4: Modem decision -->
<div id="swap-step-4" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4">
<div class="flex items-center justify-between">
<div class="min-w-0 flex-1">
<p class="text-xs text-gray-500 dark:text-gray-400">Incoming</p>
<p class="font-mono font-semibold text-seismo-orange" id="swap-new-unit-label"></p>
</div>
<button onclick="swapGoToStep(3)" class="text-xs text-gray-500 hover:text-seismo-orange whitespace-nowrap">Change</button>
</div>
<div>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2" id="swap-modem-question">Modem?</p>
<div id="swap-modem-choice-list" class="space-y-2"></div>
</div>
<div id="swap-modem-picker-wrap" class="hidden">
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Pick a modem</span>
<input id="swap-modem-search" type="search" autocomplete="off"
placeholder="Filter modems…"
class="mt-2 w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
</label>
<div id="swap-modem-list" class="max-h-60 overflow-y-auto space-y-1.5 mt-2"></div>
</div>
<button id="swap-modem-next"
onclick="swapAfterModem()"
class="w-full px-4 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium text-base disabled:opacity-50 disabled:cursor-not-allowed"
disabled>
Continue
</button>
</div>
<!-- Step 5: Review + confirm -->
<div id="swap-step-5" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Review the swap</h3>
<dl class="text-sm space-y-2">
<div class="flex justify-between gap-2">
<dt class="text-gray-500 dark:text-gray-400">Project</dt>
<dd class="text-right text-gray-900 dark:text-white font-medium truncate" id="swap-review-project"></dd>
</div>
<div class="flex justify-between gap-2">
<dt class="text-gray-500 dark:text-gray-400">Location</dt>
<dd class="text-right text-gray-900 dark:text-white font-medium truncate" id="swap-review-location"></dd>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-2 flex justify-between gap-2">
<dt class="text-gray-500 dark:text-gray-400">Unit out</dt>
<dd class="text-right font-mono text-gray-900 dark:text-white" id="swap-review-old-unit"></dd>
</div>
<div class="flex justify-between gap-2">
<dt class="text-gray-500 dark:text-gray-400">Unit in</dt>
<dd class="text-right font-mono text-seismo-orange font-semibold" id="swap-review-new-unit"></dd>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-2 flex justify-between gap-2">
<dt class="text-gray-500 dark:text-gray-400">Modem out</dt>
<dd class="text-right font-mono text-gray-900 dark:text-white" id="swap-review-old-modem"></dd>
</div>
<div class="flex justify-between gap-2">
<dt class="text-gray-500 dark:text-gray-400">Modem in</dt>
<dd class="text-right font-mono text-seismo-orange font-semibold" id="swap-review-new-modem"></dd>
</div>
</dl>
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Notes (optional)</span>
<textarea id="swap-notes" rows="2"
placeholder="Reason for swap, anything to remember…"
class="mt-2 w-full px-3 py-2 text-base border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white"></textarea>
</label>
<div id="swap-error" class="hidden text-sm text-red-600"></div>
<button id="swap-confirm-btn"
onclick="swapConfirm()"
class="w-full px-4 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium text-base">
Confirm swap
</button>
<button onclick="swapGoToStep(4)" class="w-full text-sm text-gray-500 hover:text-seismo-orange">← Back</button>
</div>
<!-- Step 6: Success + optional photo -->
<div id="swap-step-done" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4">
<div class="text-center">
<div class="w-14 h-14 mx-auto rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-3">Swap complete</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1" id="swap-done-summary"></p>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">Add a photo of the new install?</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Optional. EXIF GPS will populate the unit's coordinates.</p>
<input id="swap-photo-input" type="file" accept="image/*" capture="environment"
onchange="swapOnPhotoPicked(event)"
class="mt-2 w-full text-sm text-gray-500 file:mr-4 file:py-3 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-seismo-orange file:text-white hover:file:bg-orange-600">
<div id="swap-photo-preview-wrap" class="hidden mt-3">
<img id="swap-photo-preview" class="w-full rounded-lg border border-gray-200 dark:border-gray-700" alt="">
</div>
<div id="swap-photo-error" class="hidden text-sm text-red-600 mt-2"></div>
<div id="swap-photo-status" class="hidden text-sm text-green-600 mt-2"></div>
</div>
<div class="grid grid-cols-2 gap-2">
<button id="swap-photo-upload-btn"
onclick="swapUploadPhoto()"
class="px-4 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium disabled:opacity-50 disabled:cursor-not-allowed"
disabled>
Upload photo
</button>
<button onclick="swapReset()"
class="px-4 py-3 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg font-medium">
Done — another swap
</button>
</div>
</div>
</div>
<script>
const _swap = {
step: 1,
project: null, // { id, display, ... }
location: null, // { id, name, unit, modem }
new_unit: null, // { id, ... }
modem_action: null, // 'keep' | 'swap' | 'remove' | 'add' | 'none'
new_modem: null, // { id, ... }
all_projects: [],
all_units: [],
all_modems: [],
swap_result: null,
photo_file: null,
photo_preview_url: null,
};
function _esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function _badge(deployed) {
return deployed
? '<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">deployed</span>'
: '<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">benched</span>';
}
function swapGoToStep(n) {
_swap.step = n;
for (let i = 1; i <= 5; i++) {
const el = document.getElementById('swap-step-' + i);
if (el) el.classList.toggle('hidden', i !== n);
const pill = document.getElementById('swap-pill-' + i);
if (!pill) continue;
const dot = pill.querySelector('span');
if (i === n) {
pill.classList.remove('text-gray-500', 'dark:text-gray-400');
pill.classList.add('text-seismo-orange', 'font-medium');
dot.classList.remove('bg-gray-200', 'dark:bg-gray-700');
dot.classList.add('bg-seismo-orange', 'text-white');
} else if (i < n) {
pill.classList.remove('text-gray-500', 'dark:text-gray-400');
pill.classList.add('text-green-600', 'dark:text-green-400');
dot.classList.remove('bg-gray-200', 'dark:bg-gray-700', 'bg-seismo-orange', 'text-white');
dot.classList.add('bg-green-100', 'dark:bg-green-900/30', 'text-green-700', 'dark:text-green-300');
} else {
pill.classList.add('text-gray-500', 'dark:text-gray-400');
pill.classList.remove('text-seismo-orange', 'font-medium', 'text-green-600', 'dark:text-green-400');
dot.classList.add('bg-gray-200', 'dark:bg-gray-700');
dot.classList.remove('bg-seismo-orange', 'text-white', 'bg-green-100', 'dark:bg-green-900/30', 'text-green-700', 'dark:text-green-300');
}
}
document.getElementById('swap-step-done').classList.add('hidden');
}
// ── Step 1: project picker ──────────────────────────────────────────
async function _swapLoadProjects() {
const list = document.getElementById('swap-project-list');
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Loading…</p>';
try {
const r = await fetch('/api/projects/search-json?limit=50');
if (!r.ok) throw new Error('HTTP ' + r.status);
_swap.all_projects = await r.json();
_swapRenderProjects();
} catch (e) {
list.innerHTML = `<p class="text-sm text-red-500 px-3 py-2">Load failed: ${_esc(e.message)}</p>`;
}
}
function _swapRenderProjects() {
const q = document.getElementById('swap-project-search').value.trim().toLowerCase();
const list = document.getElementById('swap-project-list');
let items = _swap.all_projects;
if (q) {
items = items.filter(p => {
const hay = [(p.project_number||''), (p.client_name||''), (p.name||''), (p.display||'')]
.join(' ').toLowerCase();
return hay.includes(q);
});
}
if (items.length === 0) {
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">No matching projects.</p>';
return;
}
list.innerHTML = items.map(p => {
const num = p.project_number ? `<span class="font-mono text-xs text-gray-500 dark:text-gray-400">${_esc(p.project_number)}</span>` : '';
const client = p.client_name ? `<div class="text-xs text-gray-500 dark:text-gray-400 truncate">${_esc(p.client_name)}</div>` : '';
return `<button onclick='swapPickProject(${JSON.stringify(p.id)})'
class="w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
<div class="flex items-center justify-between gap-2">
<span class="font-semibold text-gray-900 dark:text-white truncate">${_esc(p.name)}</span>
${num}
</div>
${client}
</button>`;
}).join('');
}
function swapPickProject(projectId) {
const p = _swap.all_projects.find(x => x.id === projectId);
if (!p) return;
_swap.project = p;
document.getElementById('swap-project-label').textContent = p.name;
_swapLoadLocations();
swapGoToStep(2);
}
// ── Step 2: location picker ─────────────────────────────────────────
async function _swapLoadLocations() {
const list = document.getElementById('swap-location-list');
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Loading…</p>';
try {
const r = await fetch(`/api/projects/${encodeURIComponent(_swap.project.id)}/locations-with-assignments?location_type=vibration`);
if (!r.ok) throw new Error('HTTP ' + r.status);
const data = await r.json();
_swapRenderLocations(data);
} catch (e) {
list.innerHTML = `<p class="text-sm text-red-500 px-3 py-2">Load failed: ${_esc(e.message)}</p>`;
}
}
function _swapRenderLocations(locations) {
const list = document.getElementById('swap-location-list');
if (!locations || locations.length === 0) {
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">No vibration locations in this project.</p>';
return;
}
list.innerHTML = locations.map(loc => {
const unit = loc.unit;
const modem = loc.modem;
const unitLine = unit
? `<div class="text-xs text-gray-600 dark:text-gray-300 font-mono">${_esc(unit.id)}<span class="text-gray-400">${unit.unit_type ? ' · ' + _esc(unit.unit_type) : ''}</span></div>`
: `<div class="text-xs italic text-gray-400">Empty — first assign</div>`;
const modemLine = modem
? `<div class="text-[11px] text-gray-500 dark:text-gray-400 font-mono mt-0.5 flex items-center gap-2">
<span>+ modem ${_esc(modem.id)}</span>
${_badge(modem.deployed)}
</div>`
: '';
// Pass index for cleaner attribute escaping
return `<button data-locidx="${locations.indexOf(loc)}" onclick="_swapPickLocationByIdx(this.dataset.locidx)"
class="w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
<div class="flex items-center justify-between gap-2">
<span class="font-semibold text-gray-900 dark:text-white truncate">${_esc(loc.name)}</span>
</div>
${unitLine}
${modemLine}
</button>`;
}).join('');
_swap._locations_cache = locations;
}
function _swapPickLocationByIdx(idxStr) {
const idx = parseInt(idxStr, 10);
const loc = _swap._locations_cache[idx];
if (!loc) return;
_swap.location = loc;
document.getElementById('swap-location-label').textContent = loc.name;
document.getElementById('swap-old-unit-label').textContent = loc.unit ? loc.unit.id : '(empty)';
_swapLoadUnits();
swapGoToStep(3);
}
// ── Step 3: incoming unit picker ────────────────────────────────────
async function _swapLoadUnits() {
const list = document.getElementById('swap-unit-list');
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Loading…</p>';
try {
const r = await fetch(`/api/projects/${encodeURIComponent(_swap.project.id)}/available-units?location_type=vibration&include_benched=true`);
if (!r.ok) throw new Error('HTTP ' + r.status);
_swap.all_units = await r.json();
_swapRenderUnits();
} catch (e) {
list.innerHTML = `<p class="text-sm text-red-500 px-3 py-2">Load failed: ${_esc(e.message)}</p>`;
}
}
function _swapRenderUnits() {
const q = document.getElementById('swap-unit-search').value.trim().toLowerCase();
const list = document.getElementById('swap-unit-list');
let items = _swap.all_units;
if (q) {
items = items.filter(u => {
const hay = [(u.id||''), (u.model||''), (u.location||'')].join(' ').toLowerCase();
return hay.includes(q);
});
}
if (items.length === 0) {
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">No available seismographs.</p>';
return;
}
list.innerHTML = items.map(u => {
const model = u.model ? `<span class="text-xs text-gray-500 dark:text-gray-400">${_esc(u.model)}</span>` : '';
const loc = u.location ? `<div class="text-xs text-gray-500 dark:text-gray-400 truncate">${_esc(u.location)}</div>` : '';
const badge = _badge(u.deployed);
return `<button onclick='swapPickUnit(${JSON.stringify(u.id)})'
class="w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
<div class="flex items-center justify-between gap-2">
<span class="font-mono font-semibold text-gray-900 dark:text-white">${_esc(u.id)}</span>
<div class="flex items-center gap-2">
${badge}
${model}
</div>
</div>
${loc}
</button>`;
}).join('');
}
function swapPickUnit(unitId) {
const u = _swap.all_units.find(x => x.id === unitId);
if (!u) return;
_swap.new_unit = u;
document.getElementById('swap-new-unit-label').textContent = u.id;
_swapInitModemStep();
swapGoToStep(4);
}
// ── Step 4: modem decision ──────────────────────────────────────────
function _swapInitModemStep() {
_swap.modem_action = null;
_swap.new_modem = null;
document.getElementById('swap-modem-picker-wrap').classList.add('hidden');
document.getElementById('swap-modem-next').disabled = true;
const current = _swap.location && _swap.location.modem;
const choices = document.getElementById('swap-modem-choice-list');
const question = document.getElementById('swap-modem-question');
if (current) {
question.textContent = 'Modem at this location';
choices.innerHTML = `
<button data-action="keep" onclick="swapPickModemAction('keep')"
class="swap-modem-choice w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
<div class="flex items-center justify-between gap-2">
<div class="font-medium text-gray-900 dark:text-white">Keep <span class="font-mono">${_esc(current.id)}</span></div>
${_badge(current.deployed)}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Re-pair the existing modem to the incoming unit.</div>
</button>
<button data-action="swap" onclick="swapPickModemAction('swap')"
class="swap-modem-choice w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
<div class="font-medium text-gray-900 dark:text-white">Swap modem too</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Pick a different unassigned modem.</div>
</button>
<button data-action="remove" onclick="swapPickModemAction('remove')"
class="swap-modem-choice w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
<div class="font-medium text-gray-900 dark:text-white">No modem</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Incoming unit goes solo (no cellular).</div>
</button>`;
} else {
question.textContent = 'No modem at this location currently.';
choices.innerHTML = `
<button data-action="none" onclick="swapPickModemAction('none')"
class="swap-modem-choice w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
<div class="font-medium text-gray-900 dark:text-white">No modem</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Standalone / manual download.</div>
</button>
<button data-action="add" onclick="swapPickModemAction('add')"
class="swap-modem-choice w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
<div class="font-medium text-gray-900 dark:text-white">Add a modem</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Pair an unassigned modem with the incoming unit.</div>
</button>`;
}
}
function swapPickModemAction(action) {
_swap.modem_action = action;
_swap.new_modem = null;
// Highlight the picked choice; dim the others.
document.querySelectorAll('.swap-modem-choice').forEach(btn => {
if (btn.dataset.action === action) {
btn.classList.add('border-seismo-orange', 'bg-amber-50', 'dark:bg-amber-900/10');
} else {
btn.classList.remove('border-seismo-orange', 'bg-amber-50', 'dark:bg-amber-900/10');
}
});
const pickerWrap = document.getElementById('swap-modem-picker-wrap');
const nextBtn = document.getElementById('swap-modem-next');
if (action === 'swap' || action === 'add') {
pickerWrap.classList.remove('hidden');
nextBtn.disabled = true;
_swapLoadModems();
} else {
pickerWrap.classList.add('hidden');
nextBtn.disabled = false;
}
}
async function _swapLoadModems() {
const list = document.getElementById('swap-modem-list');
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Loading…</p>';
try {
const r = await fetch(`/api/projects/${encodeURIComponent(_swap.project.id)}/available-modems?include_benched=true`);
if (!r.ok) throw new Error('HTTP ' + r.status);
// Filter out the modem that's currently at this location (it's the "keep" option, not "swap").
let modems = await r.json();
const currentModemId = _swap.location && _swap.location.modem ? _swap.location.modem.id : null;
if (currentModemId) modems = modems.filter(m => m.id !== currentModemId);
_swap.all_modems = modems;
_swapRenderModems();
} catch (e) {
list.innerHTML = `<p class="text-sm text-red-500 px-3 py-2">Load failed: ${_esc(e.message)}</p>`;
}
}
function _swapRenderModems() {
const q = document.getElementById('swap-modem-search').value.trim().toLowerCase();
const list = document.getElementById('swap-modem-list');
let items = _swap.all_modems;
if (q) {
items = items.filter(m => {
const hay = [(m.id||''), (m.hardware_model||''), (m.ip_address||'')].join(' ').toLowerCase();
return hay.includes(q);
});
}
if (items.length === 0) {
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">No modems available.</p>';
return;
}
list.innerHTML = items.map(m => {
const hw = m.hardware_model ? `<span class="text-xs text-gray-500 dark:text-gray-400">${_esc(m.hardware_model)}</span>` : '';
const ip = m.ip_address ? `<div class="text-xs text-gray-500 dark:text-gray-400 font-mono">${_esc(m.ip_address)}</div>` : '';
const badge = _badge(m.deployed);
return `<button onclick='swapPickModem(${JSON.stringify(m.id)})'
class="swap-modem-pick w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors"
data-modem-id="${_esc(m.id)}">
<div class="flex items-center justify-between gap-2">
<span class="font-mono font-semibold text-gray-900 dark:text-white">${_esc(m.id)}</span>
<div class="flex items-center gap-2">
${badge}
${hw}
</div>
</div>
${ip}
</button>`;
}).join('');
}
function swapPickModem(modemId) {
const m = _swap.all_modems.find(x => x.id === modemId);
if (!m) return;
_swap.new_modem = m;
document.querySelectorAll('.swap-modem-pick').forEach(btn => {
if (btn.dataset.modemId === modemId) {
btn.classList.add('border-seismo-orange', 'bg-amber-50', 'dark:bg-amber-900/10');
} else {
btn.classList.remove('border-seismo-orange', 'bg-amber-50', 'dark:bg-amber-900/10');
}
});
document.getElementById('swap-modem-next').disabled = false;
}
function swapAfterModem() {
// Populate review screen
document.getElementById('swap-review-project').textContent = _swap.project.name;
document.getElementById('swap-review-location').textContent = _swap.location.name;
document.getElementById('swap-review-old-unit').textContent = _swap.location.unit ? _swap.location.unit.id : '(empty)';
document.getElementById('swap-review-new-unit').textContent = _swap.new_unit.id;
const oldModemEl = document.getElementById('swap-review-old-modem');
if (_swap.location.modem) {
oldModemEl.innerHTML = `${_esc(_swap.location.modem.id)} ${_badge(_swap.location.modem.deployed)}`;
} else {
oldModemEl.textContent = '(none)';
}
const newModemEl = document.getElementById('swap-review-new-modem');
if (_swap.modem_action === 'keep' && _swap.location.modem) {
newModemEl.innerHTML = `${_esc(_swap.location.modem.id)} <span class="text-xs text-gray-500">(kept)</span> ${_badge(_swap.location.modem.deployed)}`;
} else if ((_swap.modem_action === 'swap' || _swap.modem_action === 'add') && _swap.new_modem) {
newModemEl.innerHTML = `${_esc(_swap.new_modem.id)} ${_badge(_swap.new_modem.deployed)}`;
} else {
newModemEl.textContent = '(none)';
}
swapGoToStep(5);
}
// ── Step 5: confirm ─────────────────────────────────────────────────
async function swapConfirm() {
const btn = document.getElementById('swap-confirm-btn');
const err = document.getElementById('swap-error');
err.classList.add('hidden');
// Determine modem_id to send:
// 'keep' → current modem id (re-pair to new unit)
// 'swap' → newly-picked modem id
// 'add' → newly-picked modem id
// 'remove' → omit (endpoint clears new unit's pairing)
// 'none' → omit
let modemIdToSend = null;
if (_swap.modem_action === 'keep' && _swap.location.modem) {
modemIdToSend = _swap.location.modem.id;
} else if ((_swap.modem_action === 'swap' || _swap.modem_action === 'add') && _swap.new_modem) {
modemIdToSend = _swap.new_modem.id;
}
btn.disabled = true;
btn.textContent = 'Swapping…';
const fd = new FormData();
fd.append('unit_id', _swap.new_unit.id);
if (modemIdToSend) fd.append('modem_id', modemIdToSend);
const notes = document.getElementById('swap-notes').value.trim();
if (notes) fd.append('notes', notes);
try {
const url = `/api/projects/${encodeURIComponent(_swap.project.id)}/locations/${encodeURIComponent(_swap.location.id)}/swap`;
const r = await fetch(url, { method: 'POST', body: fd });
if (!r.ok) {
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
throw new Error(e.detail || 'HTTP ' + r.status);
}
_swap.swap_result = await r.json();
_swapShowDone();
} catch (e) {
err.textContent = e.message;
err.classList.remove('hidden');
btn.disabled = false;
btn.textContent = 'Confirm swap';
}
}
function _swapShowDone() {
for (let i = 1; i <= 5; i++) {
document.getElementById('swap-step-' + i).classList.add('hidden');
}
document.getElementById('swap-step-done').classList.remove('hidden');
const summary = `${_swap.new_unit.id} is now at ${_swap.location.name}` +
(_swap.location.unit ? ` (replacing ${_swap.location.unit.id}).` : '.');
document.getElementById('swap-done-summary').textContent = summary;
}
// ── Photo upload (optional) ─────────────────────────────────────────
function swapOnPhotoPicked(e) {
const file = e.target.files && e.target.files[0];
if (!file) return;
_swap.photo_file = file;
if (_swap.photo_preview_url) URL.revokeObjectURL(_swap.photo_preview_url);
_swap.photo_preview_url = URL.createObjectURL(file);
document.getElementById('swap-photo-preview').src = _swap.photo_preview_url;
document.getElementById('swap-photo-preview-wrap').classList.remove('hidden');
document.getElementById('swap-photo-upload-btn').disabled = false;
}
async function swapUploadPhoto() {
if (!_swap.photo_file) return;
const btn = document.getElementById('swap-photo-upload-btn');
const err = document.getElementById('swap-photo-error');
const ok = document.getElementById('swap-photo-status');
err.classList.add('hidden');
ok.classList.add('hidden');
btn.disabled = true;
btn.textContent = 'Uploading…';
const fd = new FormData();
fd.append('photo', _swap.photo_file);
try {
const url = `/api/unit/${encodeURIComponent(_swap.new_unit.id)}/upload-photo?auto_populate_coords=true`;
const r = await fetch(url, { method: 'POST', body: fd });
if (!r.ok) {
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
throw new Error(e.detail || 'HTTP ' + r.status);
}
const data = await r.json();
const coords = data && data.metadata && data.metadata.coordinates;
ok.textContent = coords ? `Uploaded. GPS: ${coords}` : 'Uploaded (no GPS in EXIF).';
ok.classList.remove('hidden');
btn.textContent = 'Uploaded';
} catch (e) {
err.textContent = e.message;
err.classList.remove('hidden');
btn.disabled = false;
btn.textContent = 'Upload photo';
}
}
function swapReset() {
if (_swap.photo_preview_url) URL.revokeObjectURL(_swap.photo_preview_url);
Object.assign(_swap, {
project: null, location: null, new_unit: null,
modem_action: null, new_modem: null,
all_projects: [], all_units: [], all_modems: [],
swap_result: null, photo_file: null, photo_preview_url: null,
});
document.getElementById('swap-project-search').value = '';
document.getElementById('swap-unit-search').value = '';
document.getElementById('swap-modem-search').value = '';
document.getElementById('swap-notes').value = '';
document.getElementById('swap-photo-input').value = '';
document.getElementById('swap-photo-preview-wrap').classList.add('hidden');
document.getElementById('swap-photo-error').classList.add('hidden');
document.getElementById('swap-photo-status').classList.add('hidden');
document.getElementById('swap-error').classList.add('hidden');
const confirmBtn = document.getElementById('swap-confirm-btn');
confirmBtn.disabled = false; confirmBtn.textContent = 'Confirm swap';
const photoBtn = document.getElementById('swap-photo-upload-btn');
photoBtn.disabled = true; photoBtn.textContent = 'Upload photo';
swapGoToStep(1);
_swapLoadProjects();
}
// Wire up live filtering inputs.
document.getElementById('swap-project-search').addEventListener('input', _swapRenderProjects);
document.getElementById('swap-unit-search').addEventListener('input', _swapRenderUnits);
document.getElementById('swap-modem-search').addEventListener('input', _swapRenderModems);
// Kick off.
_swapLoadProjects();
</script>
{% endblock %}
+13 -2
View File
@@ -192,8 +192,9 @@ function renderTable() {
? '<span class="px-2 py-0.5 rounded text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300">FT</span>'
: '';
const checked = _selected.has(ev.id) ? 'checked' : '';
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50">
<td class="px-3 py-2"><input type="checkbox" class="row-check" data-event-id="${_esc(ev.id)}" ${checked} onchange="onRowCheck(this)"></td>
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer"
onclick="showEventDetail('${_esc(ev.id)}')">
<td class="px-3 py-2" onclick="event.stopPropagation()"><input type="checkbox" class="row-check" data-event-id="${_esc(ev.id)}" ${checked} onchange="onRowCheck(this)"></td>
<td class="px-3 py-2 text-sm font-mono text-gray-700 dark:text-gray-300">${_esc(ev.serial)}</td>
<td class="px-3 py-2 text-sm text-gray-900 dark:text-white whitespace-nowrap">${_esc(ts)}</td>
<td class="px-3 py-2 text-sm font-mono text-right">${_fmtPpv(ev.tran_ppv)}</td>
@@ -355,5 +356,15 @@ async function flagSelected(value) {
}
// Initial empty state — let the user choose to load.
// Refresh the events table when the modal's review form saves — keeps
// the FT badge in sync without a full page reload.
window.addEventListener('sfm-event-review-saved', () => {
if (_events.length) loadEvents();
});
</script>
{# Shared event-detail modal — rendered by /static/event-modal.js #}
{% include 'partials/event_detail_modal.html' %}
<script src="/static/event-modal.js"></script>
{% endblock %}
+1
View File
@@ -150,6 +150,7 @@
(project tidy, metadata backfill, pair devices). #}
{% set _is_tools = (
request.url.path == '/tools'
or request.url.path.startswith('/tools/')
or request.url.path == '/pair-devices'
or request.url.path == '/settings/developer/project-tidy'
or request.url.path == '/settings/developer/metadata-backfill'
+86 -51
View File
@@ -150,46 +150,55 @@ setInterval(_refreshPendingDeployBanner, 30000);
</svg>
</div>
</div>
<div class="space-y-3 card-content" id="fleet-summary-content">
<div class="flex justify-between items-center">
<span class="text-gray-600 dark:text-gray-400">Total Units</span>
<span id="total-units" class="text-3xl md:text-2xl font-bold text-gray-900 dark:text-white">--</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 dark:text-gray-400">Deployed</span>
<span id="deployed-units" class="text-3xl md:text-2xl font-bold text-blue-600 dark:text-blue-400">--</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 dark:text-gray-400">Benched</span>
<span id="benched-units" class="text-3xl md:text-2xl font-bold text-gray-600 dark:text-gray-400">--</span>
</div>
<div class="flex justify-between items-center">
<span class="text-orange-600 dark:text-orange-400">Allocated</span>
<span id="allocated-units" class="text-3xl md:text-2xl font-bold text-orange-500 dark:text-orange-400">--</span>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">By Device Type:</p>
<div class="flex justify-between items-center mb-1">
<div class="space-y-4 card-content" id="fleet-summary-content">
<!-- Seismographs -->
<div>
<div class="flex justify-between items-center mb-1.5">
<div class="flex items-center">
<svg class="w-4 h-4 mr-1.5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
<a href="/seismographs" class="text-sm text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400">Seismographs</a>
<a href="/seismographs" class="text-sm font-semibold text-gray-800 dark:text-gray-200 hover:text-blue-600 dark:hover:text-blue-400">Seismographs</a>
</div>
<span id="seismo-count" class="font-semibold text-blue-600 dark:text-blue-400">--</span>
<span id="seismo-count" class="text-lg font-bold text-blue-600 dark:text-blue-400">--</span>
</div>
<div class="flex justify-between items-center mb-2">
<div class="pl-6 flex flex-col gap-0.5 text-sm">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Deployed</span>
<span id="seismo-deployed" class="font-medium text-gray-800 dark:text-gray-200">--</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Benched</span>
<span id="seismo-benched" class="font-medium text-gray-800 dark:text-gray-200">--</span>
</div>
</div>
</div>
<!-- Sound Level Meters -->
<div>
<div class="flex justify-between items-center mb-1.5">
<div class="flex items-center">
<svg class="w-4 h-4 mr-1.5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
</svg>
<a href="/sound-level-meters" class="text-sm text-gray-600 dark:text-gray-400 hover:text-purple-600 dark:hover:text-purple-400">Sound Level Meters</a>
<a href="/sound-level-meters" class="text-sm font-semibold text-gray-800 dark:text-gray-200 hover:text-purple-600 dark:hover:text-purple-400">Sound Level Meters</a>
</div>
<span id="slm-count" class="text-lg font-bold text-purple-600 dark:text-purple-400">--</span>
</div>
<div class="pl-6 flex flex-col gap-0.5 text-sm">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Deployed</span>
<span id="slm-deployed" class="font-medium text-gray-800 dark:text-gray-200">--</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Benched</span>
<span id="slm-benched" class="font-medium text-gray-800 dark:text-gray-200">--</span>
</div>
<span id="slm-count" class="font-semibold text-purple-600 dark:text-purple-400">--</span>
</div>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">Deployed Status:</p>
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">Call-in Status:</p>
<div class="flex justify-between items-center mb-2" title="Units reporting normally (last seen < 12 hours)">
<div class="flex items-center">
<span class="w-3 h-3 rounded-full bg-green-500 mr-2 flex items-center justify-center">
@@ -628,9 +637,14 @@ function updateFleetMapFiltered(allUnits) {
fleetMarkers.forEach(marker => fleetMap.removeLayer(marker));
fleetMarkers = [];
// Get deployed units with coordinates that pass the filter
// Get deployed units with coordinates that pass the filter.
// Modems are not plotted — they inherit the paired device's location,
// which would just stack a duplicate marker on the same pin.
const deployedUnits = Object.entries(allUnits || {})
.filter(([_, u]) => u.deployed && u.coordinates && unitPassesFilter(u));
.filter(([_, u]) => u.deployed
&& u.coordinates
&& (u.device_type || 'seismograph') !== 'modem'
&& unitPassesFilter(u));
if (deployedUnits.length === 0) {
return;
@@ -672,10 +686,12 @@ function updateFleetMapFiltered(allUnits) {
// Popup with device type
const deviceLabel = getDeviceTypeLabel(deviceType);
const locName = unit.location_name || '';
marker.bindPopup(`
<div class="p-2">
<h3 class="font-bold text-lg">${id}</h3>
<p class="text-sm text-gray-600">${deviceLabel}</p>
${locName ? `<p class="text-sm text-gray-700">📍 ${locName}</p>` : ''}
<p class="text-sm">Status: <span style="color: ${markerColor}">${unit.status}</span></p>
${unit.note ? `<p class="text-sm text-gray-600">${unit.note}</p>` : ''}
<a href="/unit/${id}" class="text-blue-600 hover:underline text-sm">View Details</a>
@@ -783,32 +799,51 @@ function updateDashboard(event) {
timeZoneName: 'short'
});
// ===== Fleet summary numbers (always unfiltered) =====
document.getElementById('total-units').textContent = data.summary?.total ?? 0;
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;
document.getElementById('benched-units').textContent = data.summary?.benched ?? 0;
document.getElementById('allocated-units').textContent = data.summary?.allocated ?? 0;
document.getElementById('status-ok').textContent = data.summary?.ok ?? 0;
document.getElementById('status-pending').textContent = data.summary?.pending ?? 0;
document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;
// ===== Fleet Summary: per-device-type counts (always unfiltered) =====
// Deployed = unit has an active UnitAssignment (location_id set by
// the snapshot helper). Benched = no active assignment.
// Retired, out-for-calibration, and roster-unknown units (emitters
// not in the roster) are excluded from totals.
const counts = {
seismograph: { total: 0, deployed: 0, benched: 0 },
sound_level_meter: { total: 0, deployed: 0, benched: 0 },
};
let monitoredOk = 0, monitoredPending = 0, monitoredMissing = 0;
const unknownIds = new Set(Object.keys(data.unknown || {}));
// ===== Device type counts (always unfiltered) =====
let seismoCount = 0;
let slmCount = 0;
let modemCount = 0;
Object.values(data.units || {}).forEach(unit => {
if (unit.retired) return; // Don't count retired units
const deviceType = unit.device_type || 'seismograph';
if (deviceType === 'seismograph') {
seismoCount++;
} else if (deviceType === 'sound_level_meter') {
slmCount++;
} else if (deviceType === 'modem') {
modemCount++;
Object.entries(data.units || {}).forEach(([uid, unit]) => {
if (unit.retired || unit.out_for_calibration) return;
if (unknownIds.has(uid)) return;
const dt = unit.device_type || 'seismograph';
const bucket = counts[dt];
if (!bucket) return; // skip modems and anything else
bucket.total++;
if (unit.location_id) {
bucket.deployed++;
} else {
bucket.benched++;
}
// Status tally only for seismographs + SLMs that are actually
// deployed (assigned). Mirrors the per-device buckets so the
// sum matches.
if (unit.location_id) {
if (unit.status === 'OK') monitoredOk++;
else if (unit.status === 'Pending') monitoredPending++;
else if (unit.status === 'Missing') monitoredMissing++;
}
});
document.getElementById('seismo-count').textContent = seismoCount;
document.getElementById('slm-count').textContent = slmCount;
document.getElementById('seismo-count').textContent = counts.seismograph.total;
document.getElementById('seismo-deployed').textContent = counts.seismograph.deployed;
document.getElementById('seismo-benched').textContent = counts.seismograph.benched;
document.getElementById('slm-count').textContent = counts.sound_level_meter.total;
document.getElementById('slm-deployed').textContent = counts.sound_level_meter.deployed;
document.getElementById('slm-benched').textContent = counts.sound_level_meter.benched;
document.getElementById('status-ok').textContent = monitoredOk;
document.getElementById('status-pending').textContent = monitoredPending;
document.getElementById('status-missing').textContent = monitoredMissing;
// ===== Apply filters and render map + alerts =====
renderFilteredDashboard(data);
+6 -2
View File
@@ -10,8 +10,8 @@ Usage:
#}
<div id="event-detail-modal" class="fixed inset-0 z-50 hidden">
<div class="absolute inset-0 bg-black/60" onclick="closeEventDetailModal()"></div>
<div class="absolute inset-x-4 top-1/2 -translate-y-1/2 max-w-3xl mx-auto bg-white dark:bg-slate-800 rounded-xl shadow-2xl p-6 max-h-[88vh] overflow-y-auto">
<div class="flex items-center justify-between mb-4 sticky top-0 bg-white dark:bg-slate-800 -mx-6 px-6 pb-3 border-b border-gray-200 dark:border-gray-700">
<div class="absolute inset-x-4 top-1/2 -translate-y-1/2 max-w-5xl mx-auto bg-white dark:bg-slate-800 rounded-xl shadow-2xl p-6 max-h-[92vh] overflow-y-auto">
<div class="flex items-center justify-between mb-4 sticky top-0 bg-white dark:bg-slate-800 -mx-6 px-6 pb-3 border-b border-gray-200 dark:border-gray-700 z-10">
<h3 class="text-lg font-bold text-gray-900 dark:text-white" id="event-detail-modal-title">Event Detail</h3>
<button onclick="closeEventDetailModal()"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors">
@@ -23,3 +23,7 @@ Usage:
<div id="event-detail-modal-content"></div>
</div>
</div>
{# Chart.js — pinned to v4.4.1 to match the SFM webapp's reference impl
(v4 chart API; differs from v3). Loaded once globally; safe if other
pages on the same template tree also load it. #}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
+21 -1
View File
@@ -122,6 +122,21 @@
How often the dashboard should refresh automatically
</p>
</div>
<!-- Event-Report Mic Units -->
<div>
<label for="mic-unit-pref" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Event Report — Mic Channel Units
</label>
<select id="mic-unit-pref"
class="w-full max-w-md px-4 py-2 text-gray-900 dark:text-gray-100 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-seismo-orange">
<option value="psi" selected>psi — raw pressure (matches PDF report)</option>
<option value="dBL">dB(L) — sound pressure level</option>
</select>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Applies only to the waveform chart inside the event detail modal. Peak values everywhere else (tables, KPIs, modal summary) stay in dB(L) regardless.
</p>
</div>
</div>
<button onclick="saveGeneralSettings()" class="mt-6 px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
@@ -771,6 +786,9 @@ async function loadPreferences() {
// Load auto-refresh interval
document.getElementById('refresh-interval').value = prefs.auto_refresh_interval || 10;
// Load event-report mic units
document.getElementById('mic-unit-pref').value = prefs.mic_unit_pref || 'psi';
// Load status thresholds
document.getElementById('ok-threshold').value = prefs.status_ok_threshold_hours || 12;
document.getElementById('pending-threshold').value = prefs.status_pending_threshold_hours || 24;
@@ -788,6 +806,7 @@ async function saveGeneralSettings() {
const timezone = document.getElementById('timezone-select').value;
const theme = document.querySelector('input[name="theme"]:checked').value;
const autoRefreshInterval = parseInt(document.getElementById('refresh-interval').value);
const micUnitPref = document.getElementById('mic-unit-pref').value;
try {
const response = await fetch('/api/settings/preferences', {
@@ -796,7 +815,8 @@ async function saveGeneralSettings() {
body: JSON.stringify({
timezone,
theme,
auto_refresh_interval: autoRefreshInterval
auto_refresh_interval: autoRefreshInterval,
mic_unit_pref: micUnitPref
})
});
+7
View File
@@ -118,6 +118,13 @@
{# Shared event-detail modal — rendered by /static/event-modal.js #}
{% include 'partials/event_detail_modal.html' %}
<script src="/static/event-modal.js"></script>
<script>
// Refresh the events table when the modal's review form saves —
// keeps the FT badge in sync without a full page reload.
window.addEventListener('sfm-event-review-saved', () => {
if (typeof loadEvents === 'function') loadEvents();
});
</script>
<style>
.sfm-tab {
+18
View File
@@ -141,6 +141,24 @@
</div>
</a>
<!-- Unit Swap -->
<a href="/tools/unit-swap"
class="block bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 hover:shadow-xl transition-shadow border border-transparent hover:border-seismo-orange">
<div class="flex items-start gap-3">
<div class="w-10 h-10 rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400 flex items-center justify-center shrink-0">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-gray-900 dark:text-white">Unit Swap</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Field-swap a unit (and modem) at a vibration location. Pick project → location → incoming unit → confirm. Optional photo of the new install.
</p>
</div>
</div>
</a>
<!-- Swap Detection (Phase 5c — coming soon) -->
<div class="bg-gray-50 dark:bg-slate-800/50 rounded-xl shadow p-5 border border-dashed border-gray-300 dark:border-gray-700 cursor-not-allowed">
<div class="flex items-start gap-3">
+441 -39
View File
@@ -129,6 +129,15 @@
<span id="viewProjectNoLink" class="text-gray-900 dark:text-white font-medium">Not assigned</span>
</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Deployment Location</label>
<p id="viewLocationContainer" class="mt-1">
<a id="viewLocationLink" href="#" class="text-seismo-orange hover:text-orange-600 font-medium hover:underline hidden">
<span id="viewLocationText">--</span>
</a>
<span id="viewLocationNoLink" class="text-gray-500 dark:text-gray-400 italic">Not deployed</span>
</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Address</label>
<p id="viewAddress" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
@@ -281,11 +290,16 @@
<!-- Deployment Timeline (Phase 4 unified view — derived from
unit_assignments + unit_history + SFM event overlay) -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<div class="flex justify-between items-center mb-4">
<div class="flex justify-between items-center mb-4 flex-wrap gap-2">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Deployment Timeline</h3>
<button onclick="loadDeploymentTimeline()" class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">
↻ Refresh
</button>
<div class="flex items-center gap-2">
<button onclick="openAddAssignmentModal()" class="px-3 py-1.5 text-sm rounded-lg bg-seismo-orange hover:bg-orange-600 text-white">
+ Add deployment record
</button>
<button onclick="loadDeploymentTimeline()" class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">
↻ Refresh
</button>
</div>
</div>
<!-- Gantt chart — visual timeline of all deployments. Click
@@ -302,6 +316,119 @@
</div>
</div>
<!-- Edit-assignment modal -->
<div id="editAssignmentModal" class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-y-auto">
<div class="sticky top-0 bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-gray-700 px-5 py-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Edit deployment record</h3>
<button onclick="closeEditAssignmentModal()" class="text-2xl text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">&times;</button>
</div>
<div class="p-5 space-y-4">
<div class="text-sm text-gray-500 dark:text-gray-400">
<span id="editAssignmentLocation"></span>
<span class="text-xs">·</span>
<span id="editAssignmentProject" class="text-xs"></span>
</div>
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Assigned at</span>
<input id="editAssignedAt" type="datetime-local" step="60"
class="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
</label>
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Assigned until</span>
<div class="mt-1 flex items-center gap-2">
<input id="editAssignedUntil" type="datetime-local" step="60"
class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
<label class="inline-flex items-center gap-1 text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
<input id="editAssignedUntilOpen" type="checkbox" onchange="_toggleEditOpenEnded()">
open-ended
</label>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Check "open-ended" to mark this assignment active (no end date).</p>
</label>
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Notes</span>
<textarea id="editAssignmentNotes" rows="2"
class="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white"></textarea>
</label>
<div id="editAssignmentError" class="hidden text-sm text-red-600"></div>
</div>
<div class="sticky bottom-0 bg-white dark:bg-slate-800 border-t border-gray-200 dark:border-gray-700 px-5 py-3 flex items-center justify-between gap-2">
<button onclick="deleteAssignmentFromModal()" class="px-3 py-2 text-sm rounded-lg border border-red-300 text-red-700 dark:border-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20">
Delete
</button>
<div class="flex gap-2">
<button onclick="closeEditAssignmentModal()" class="px-4 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel
</button>
<button id="editAssignmentSaveBtn" onclick="saveEditAssignment()" class="px-4 py-2 text-sm rounded-lg bg-seismo-orange hover:bg-orange-600 text-white">
Save
</button>
</div>
</div>
</div>
</div>
<!-- Add-historical-assignment modal -->
<div id="addAssignmentModal" class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-y-auto">
<div class="sticky top-0 bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-gray-700 px-5 py-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Add deployment record</h3>
<button onclick="closeAddAssignmentModal()" class="text-2xl text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">&times;</button>
</div>
<div class="p-5 space-y-4">
<p class="text-xs text-gray-500 dark:text-gray-400">
Create a deployment record for this unit — usually to backfill a historical window so orphan events get attributed.
</p>
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Project</span>
<select id="addAssignmentProject" onchange="_addAssignmentProjectChanged()"
class="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
<option value="">Loading…</option>
</select>
</label>
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Location</span>
<select id="addAssignmentLocation"
class="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white" disabled>
<option value="">Pick a project first</option>
</select>
</label>
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Assigned at</span>
<input id="addAssignedAt" type="datetime-local" step="60"
class="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
</label>
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Assigned until</span>
<div class="mt-1 flex items-center gap-2">
<input id="addAssignedUntil" type="datetime-local" step="60"
class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
<label class="inline-flex items-center gap-1 text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
<input id="addAssignedUntilOpen" type="checkbox" onchange="_toggleAddOpenEnded()">
open-ended
</label>
</div>
</label>
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Notes</span>
<textarea id="addAssignmentNotes" rows="2"
placeholder="e.g. Backfilled to attribute orphan events 2026-03-16 — 2026-03-25"
class="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white"></textarea>
</label>
<div id="addAssignmentError" class="hidden text-sm text-red-600"></div>
</div>
<div class="sticky bottom-0 bg-white dark:bg-slate-800 border-t border-gray-200 dark:border-gray-700 px-5 py-3 flex justify-end gap-2">
<button onclick="closeAddAssignmentModal()" class="px-4 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel
</button>
<button id="addAssignmentSaveBtn" onclick="saveAddAssignment()" class="px-4 py-2 text-sm rounded-lg bg-seismo-orange hover:bg-orange-600 text-white">
Create
</button>
</div>
</div>
</div>
<!-- SFM Events (seismographs only) -->
<div id="sfmEventsSection" class="border-t border-gray-200 dark:border-gray-700 pt-6 hidden">
<div class="flex justify-between items-center mb-4">
@@ -521,18 +648,12 @@
{% include "partials/project_picker.html" with context %}
</div>
<!-- Address -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
<input type="text" name="address" id="address" placeholder="123 Main St, City, State"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
</div>
<!-- Coordinates -->
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Coordinates</label>
<input type="text" name="coordinates" id="coordinates" placeholder="34.0522,-118.2437"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange font-mono">
<!-- Address / coordinates are managed on the project's
MonitoringLocation, not the unit itself. Edit them on
the project page. -->
<div class="md:col-span-2 rounded-lg bg-gray-50 dark:bg-slate-700/50 border border-gray-200 dark:border-gray-700 p-3 text-sm text-gray-600 dark:text-gray-400">
Address &amp; coordinates are set on the deployment location.
Open the project to edit them.
</div>
</div>
@@ -730,16 +851,6 @@
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Project</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_location" id="detailCascadeLocation" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Address</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_coordinates" id="detailCascadeCoordinates" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Coordinates</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_note" id="detailCascadeNote" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
@@ -1050,8 +1161,28 @@ function populateViewMode() {
if (projectLink) projectLink.classList.add('hidden');
}
document.getElementById('viewAddress').textContent = currentUnit.address || '--';
document.getElementById('viewCoordinates').textContent = currentUnit.coordinates || '--';
// Deployment Location — comes from the active UnitAssignment →
// MonitoringLocation. Show project link if present, otherwise
// "Not deployed" placeholder.
const locLink = document.getElementById('viewLocationLink');
const locText = document.getElementById('viewLocationText');
const locNoLink = document.getElementById('viewLocationNoLink');
const activeLoc = currentUnit.active_location;
if (activeLoc && activeLoc.location_id) {
if (locText) locText.textContent = activeLoc.name || activeLoc.address || 'Active location';
if (locLink) {
locLink.href = `/projects/${activeLoc.project_id}`;
locLink.classList.remove('hidden');
}
if (locNoLink) locNoLink.classList.add('hidden');
} else {
if (locLink) locLink.classList.add('hidden');
if (locNoLink) locNoLink.classList.remove('hidden');
}
// Address / coordinates also come from the active assignment.
document.getElementById('viewAddress').textContent = (activeLoc && activeLoc.address) || '--';
document.getElementById('viewCoordinates').textContent = (activeLoc && activeLoc.coordinates) || '--';
// Seismograph fields
document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--';
@@ -1209,8 +1340,6 @@ function populateEditForm() {
if (projectPickerClear) projectPickerClear.classList.add('hidden');
}
document.getElementById('address').value = currentUnit.address || '';
document.getElementById('coordinates').value = currentUnit.coordinates || '';
document.getElementById('deployed').checked = currentUnit.deployed;
document.getElementById('outForCalibration').checked = currentUnit.out_for_calibration || false;
document.getElementById('retired').value = currentUnit.retired ? 'true' : '';
@@ -1491,8 +1620,13 @@ function initUnitMap() {
// Update marker (can be called multiple times)
updateMapMarker(lat, lon);
// Update location text
// Update location text — prefer the assignment's location name, fall
// back to address, then coordinates.
const locationParts = [];
const loc = currentUnit.active_location;
if (loc && loc.name) {
locationParts.push(loc.name);
}
if (currentUnit.address) {
locationParts.push(currentUnit.address);
}
@@ -1606,13 +1740,12 @@ async function uploadPhoto(file) {
const result = await response.json();
// Show success message with metadata info
// Show success message with metadata info. Location is on the
// assignment's MonitoringLocation now, so we just surface what GPS
// came in — the backend no longer mutates the unit row.
let message = 'Photo uploaded successfully!';
if (result.metadata && result.metadata.coordinates) {
message += ` GPS location detected: ${result.metadata.coordinates}`;
if (result.coordinates_updated) {
message += ' (Unit coordinates updated automatically)';
}
} else {
message += ' No GPS data found in photo.';
}
@@ -1620,11 +1753,8 @@ async function uploadPhoto(file) {
statusDiv.className = 'mt-4 p-4 rounded-lg bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200';
statusDiv.textContent = message;
// Reload photos and unit data
// Reload photos
await loadPhotos();
if (result.coordinates_updated) {
await loadUnitData();
}
// Hide status after 5 seconds
setTimeout(() => {
@@ -2141,6 +2271,16 @@ function _dtRenderAssignment(e) {
? `<div class="mt-2 text-xs text-gray-600 dark:text-gray-400 italic">${_dtEsc(e.notes)}</div>`
: '';
// Edit/delete actions live on the right of the date row. Only shown
// for assignment entries with a real assignment_id (synthesized legacy
// entries without one are read-only).
const actionButtons = e.assignment_id
? `<button type="button" onclick='openEditAssignmentModal(${JSON.stringify(e.assignment_id)})'
class="text-xs text-gray-500 hover:text-seismo-orange p-1 rounded" title="Edit dates / notes">
✏️
</button>`
: '';
return `<div class="flex gap-3 transition-shadow rounded-lg" data-assignment-row="${_dtEsc(e.assignment_id)}">
<div class="flex flex-col items-center pt-1">
<span class="w-3 h-3 rounded-full ${e.is_active ? 'bg-green-500' : 'bg-seismo-orange'}"></span>
@@ -2153,6 +2293,7 @@ function _dtRenderAssignment(e) {
<div class="flex items-center gap-2">
${mergeableBadge}
${activeBadge}
${actionButtons}
</div>
</div>
<div class="mt-1">${locLink}</div>
@@ -2455,6 +2596,261 @@ function renderDeploymentTimeline(entries, container, mergeGroups) {
container.innerHTML = bannerHtml + '<div class="space-y-3">' + html + '</div>';
}
// ── Deployment-timeline editor ──────────────────────────────────────────────
// Edit / delete an existing UnitAssignment row, or create a historical one
// (backfill orphan-event windows). All three operations hit endpoints that
// already exist on the project-locations router; after a save we just
// reload the timeline + events.
let _editAssignmentCtx = null; // { assignment_id, project_id, location_name, project_name }
function _iso_to_local_input(iso) {
// The PATCH/POST endpoints accept "YYYY-MM-DDTHH:MM[:SS]" strings
// (datetime.fromisoformat). datetime-local inputs emit the same shape
// without timezone. We just strip the trailing "Z" if present and
// truncate to minutes.
if (!iso) return '';
let s = String(iso).replace('Z', '');
// Slice down to YYYY-MM-DDTHH:MM (16 chars).
return s.slice(0, 16);
}
function openEditAssignmentModal(assignmentId) {
const entry = (_dtCurrentTimeline.entries || []).find(
e => e.kind === 'assignment' && e.assignment_id === assignmentId
);
if (!entry) {
alert('Could not find this assignment in the loaded timeline.');
return;
}
_editAssignmentCtx = {
assignment_id: entry.assignment_id,
project_id: entry.project_id,
location_name: entry.location_name || 'unnamed location',
project_name: entry.project_name || '',
};
document.getElementById('editAssignmentLocation').textContent = _editAssignmentCtx.location_name;
document.getElementById('editAssignmentProject').textContent = _editAssignmentCtx.project_name;
document.getElementById('editAssignedAt').value = _iso_to_local_input(entry.starts_at);
const endsAtInput = document.getElementById('editAssignedUntil');
const openCheckbox = document.getElementById('editAssignedUntilOpen');
if (entry.is_active) {
endsAtInput.value = '';
endsAtInput.disabled = true;
openCheckbox.checked = true;
} else {
endsAtInput.value = _iso_to_local_input(entry.ends_at);
endsAtInput.disabled = false;
openCheckbox.checked = false;
}
document.getElementById('editAssignmentNotes').value = entry.notes || '';
document.getElementById('editAssignmentError').classList.add('hidden');
document.getElementById('editAssignmentModal').classList.remove('hidden');
}
function closeEditAssignmentModal() {
document.getElementById('editAssignmentModal').classList.add('hidden');
_editAssignmentCtx = null;
}
function _toggleEditOpenEnded() {
const open = document.getElementById('editAssignedUntilOpen').checked;
const input = document.getElementById('editAssignedUntil');
input.disabled = open;
if (open) input.value = '';
}
async function saveEditAssignment() {
if (!_editAssignmentCtx) return;
const err = document.getElementById('editAssignmentError');
err.classList.add('hidden');
const assignedAt = document.getElementById('editAssignedAt').value;
if (!assignedAt) {
err.textContent = 'Assigned-at is required.';
err.classList.remove('hidden');
return;
}
const open = document.getElementById('editAssignedUntilOpen').checked;
const assignedUntil = open ? null : (document.getElementById('editAssignedUntil').value || null);
const notes = document.getElementById('editAssignmentNotes').value;
const btn = document.getElementById('editAssignmentSaveBtn');
btn.disabled = true; btn.textContent = 'Saving…';
try {
const url = `/api/projects/${encodeURIComponent(_editAssignmentCtx.project_id)}/assignments/${encodeURIComponent(_editAssignmentCtx.assignment_id)}`;
const r = await fetch(url, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
assigned_at: assignedAt,
assigned_until: assignedUntil,
notes: notes,
}),
});
if (!r.ok) {
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
throw new Error(e.detail || 'HTTP ' + r.status);
}
closeEditAssignmentModal();
await loadDeploymentTimeline();
if (typeof loadUnitEvents === 'function' && currentUnit && currentUnit.device_type === 'seismograph') {
loadUnitEvents();
}
} catch (e) {
err.textContent = e.message;
err.classList.remove('hidden');
} finally {
btn.disabled = false; btn.textContent = 'Save';
}
}
async function deleteAssignmentFromModal() {
if (!_editAssignmentCtx) return;
if (!confirm('Hard-delete this deployment record?\n\nThe assignment row and its history audit entry are removed. Use this only for misclicks — to end a real deployment, edit the "assigned until" date instead.')) return;
const err = document.getElementById('editAssignmentError');
err.classList.add('hidden');
try {
const url = `/api/projects/${encodeURIComponent(_editAssignmentCtx.project_id)}/assignments/${encodeURIComponent(_editAssignmentCtx.assignment_id)}`;
const r = await fetch(url, { method: 'DELETE' });
if (!r.ok) {
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
throw new Error(e.detail || 'HTTP ' + r.status);
}
closeEditAssignmentModal();
await loadDeploymentTimeline();
if (typeof loadUnitEvents === 'function' && currentUnit && currentUnit.device_type === 'seismograph') {
loadUnitEvents();
}
} catch (e) {
err.textContent = e.message;
err.classList.remove('hidden');
}
}
// ── Add-historical-assignment modal ─────────────────────────────────────────
let _addAssignmentProjectsCache = null;
async function openAddAssignmentModal() {
if (!currentUnit) return;
document.getElementById('addAssignmentError').classList.add('hidden');
document.getElementById('addAssignedAt').value = '';
document.getElementById('addAssignedUntil').value = '';
document.getElementById('addAssignedUntilOpen').checked = false;
document.getElementById('addAssignedUntil').disabled = false;
document.getElementById('addAssignmentNotes').value = '';
const locSel = document.getElementById('addAssignmentLocation');
locSel.disabled = true;
locSel.innerHTML = '<option value="">Pick a project first</option>';
const projSel = document.getElementById('addAssignmentProject');
projSel.innerHTML = '<option value="">Loading…</option>';
document.getElementById('addAssignmentModal').classList.remove('hidden');
try {
if (!_addAssignmentProjectsCache) {
const r = await fetch('/api/projects/search-json?limit=50');
if (!r.ok) throw new Error('HTTP ' + r.status);
_addAssignmentProjectsCache = await r.json();
}
projSel.innerHTML = '<option value="">— pick project —</option>'
+ _addAssignmentProjectsCache.map(p =>
`<option value="${_dtEsc(p.id)}">${_dtEsc(p.name)}${p.project_number ? ' (' + _dtEsc(p.project_number) + ')' : ''}</option>`
).join('');
} catch (e) {
projSel.innerHTML = `<option value="">Load failed: ${_dtEsc(e.message)}</option>`;
}
}
function closeAddAssignmentModal() {
document.getElementById('addAssignmentModal').classList.add('hidden');
}
function _toggleAddOpenEnded() {
const open = document.getElementById('addAssignedUntilOpen').checked;
const input = document.getElementById('addAssignedUntil');
input.disabled = open;
if (open) input.value = '';
}
async function _addAssignmentProjectChanged() {
const projectId = document.getElementById('addAssignmentProject').value;
const locSel = document.getElementById('addAssignmentLocation');
if (!projectId) {
locSel.disabled = true;
locSel.innerHTML = '<option value="">Pick a project first</option>';
return;
}
locSel.disabled = true;
locSel.innerHTML = '<option value="">Loading locations…</option>';
// Match the device type to the location_type filter.
const wantType = (currentUnit && currentUnit.device_type === 'slm') ? 'sound' : 'vibration';
try {
const r = await fetch(`/api/projects/${encodeURIComponent(projectId)}/locations-json?location_type=${wantType}`);
if (!r.ok) throw new Error('HTTP ' + r.status);
const locs = await r.json();
if (!locs.length) {
locSel.innerHTML = '<option value="">No matching locations in this project</option>';
return;
}
locSel.disabled = false;
locSel.innerHTML = '<option value="">— pick location —</option>'
+ locs.map(l => `<option value="${_dtEsc(l.id)}">${_dtEsc(l.name)}</option>`).join('');
} catch (e) {
locSel.innerHTML = `<option value="">Load failed: ${_dtEsc(e.message)}</option>`;
}
}
async function saveAddAssignment() {
if (!currentUnit) return;
const err = document.getElementById('addAssignmentError');
err.classList.add('hidden');
const projectId = document.getElementById('addAssignmentProject').value;
const locationId = document.getElementById('addAssignmentLocation').value;
const assignedAt = document.getElementById('addAssignedAt').value;
const open = document.getElementById('addAssignedUntilOpen').checked;
const assignedUntil = open ? '' : document.getElementById('addAssignedUntil').value;
const notes = document.getElementById('addAssignmentNotes').value;
if (!projectId) { err.textContent = 'Pick a project.'; err.classList.remove('hidden'); return; }
if (!locationId) { err.textContent = 'Pick a location.'; err.classList.remove('hidden'); return; }
if (!assignedAt) { err.textContent = 'Assigned-at is required.'; err.classList.remove('hidden'); return; }
const btn = document.getElementById('addAssignmentSaveBtn');
btn.disabled = true; btn.textContent = 'Creating…';
try {
const fd = new FormData();
fd.append('unit_id', currentUnit.id);
fd.append('assigned_at', assignedAt);
if (assignedUntil) fd.append('assigned_until', assignedUntil);
if (notes) fd.append('notes', notes);
const url = `/api/projects/${encodeURIComponent(projectId)}/locations/${encodeURIComponent(locationId)}/assign`;
const r = await fetch(url, { method: 'POST', body: fd });
if (!r.ok) {
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
throw new Error(e.detail || 'HTTP ' + r.status);
}
closeAddAssignmentModal();
await loadDeploymentTimeline();
if (typeof loadUnitEvents === 'function' && currentUnit.device_type === 'seismograph') {
loadUnitEvents();
}
} catch (e) {
err.textContent = e.message;
err.classList.remove('hidden');
} finally {
btn.disabled = false; btn.textContent = 'Create';
}
}
// ── SFM Events section ──────────────────────────────────────────────────────
function clearUnitEventFilters() {
document.getElementById('ue-filter-bucket').value = 'all';
@@ -3336,5 +3732,11 @@ function showToast(message, type = 'info') {
{# Shared event-detail modal (clicking a row in the SFM Events table) #}
{% include 'partials/event_detail_modal.html' %}
<script src="/static/event-modal.js"></script>
<script>
// Refresh the unit's events table when the modal's review form saves.
window.addEventListener('sfm-event-review-saved', () => {
if (typeof loadUnitEvents === 'function') loadUnitEvents();
});
</script>
{% endblock %}
+6
View File
@@ -992,4 +992,10 @@ document.getElementById('swap-modal')?.addEventListener('click', function(e) {
{# Shared event-detail modal (clicking an event row in the Events tab) #}
{% include 'partials/event_detail_modal.html' %}
<script src="/static/event-modal.js"></script>
<script>
// Refresh the location's events table when the modal's review form saves.
window.addEventListener('sfm-event-review-saved', () => {
if (typeof loadLocationEvents === 'function') loadLocationEvents();
});
</script>
{% endblock %}