update main to v0.10.0 #48
Reference in New Issue
Block a user
Delete Branch "feature/sfm-integration"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
[0.10.0] - 2026-05-14
This release brings terra-view onto the SFM (Seismograph Field Module) event pipeline. Triggered events forwarded by series3-watcher now land in SFM, and terra-view reads from that store as the authoritative source for vibration data. The watcher heartbeat is preserved as a transparent fallback signal.
Added
/sfmlisting every event ingested by SFM, with filters for serial, date range, false-trigger flag, and limit. Unit detail pages and project-location pages show their own attributed subsets of the same event stream./sfm, unit detail, and project-location pages — clicking any event opens a rich modal showing peaks per channel (PVS color-coded by magnitude), microphone dB(L) + ZC frequency + time of peak, sensor self-check table with pass/fail per channel, device/recording metadata (firmware, battery, calibration date, geo range), and download buttons for the original Blastware binary and the sidecar JSON. Includes an inline pretty-printed JSON viewer with copy-to-clipboard.backend/services/sfm_events.py): Per-event attribution againstUnitAssignmenttime windows. Events outside any assignment window surface in an "Unattributed" bucket with the nearest-assignment diagnostic (which location, signed delta in days)./tools→ Backfill from event metadata): Scans operator-typedprojectandsensor_locationstrings in event sidecars, fuzzy-clusters them viarapidfuzz.WRatio, and proposes retroactiveUnitAssignmentrecords to attribute orphan events. Tracks operator decisions per cluster across re-scans./tools→ Project Tidy): Fuzzy-detect duplicate projects and bulk-merge them with a single click. Source projects soft-deleted with full audit trail.emit_status_snapshot()now consults SFM's/db/units(cached 15s) before falling back toEmitter.last_seenfor each seismograph. The fresher signal wins; the choice is recorded in a new per-unitlast_seen_sourcefield. A smallSFM(orange) orHB(gray) badge on each unit's active-table row shows which path is currently driving the status./api/recent-event-callinsendpoint backed by SFM event forwards instead of the watcher-heartbeat endpoint./sfmand unit-detail SFM Events tables now have clickable column headers with ↕/↓/↑ indicators. Default sort is Timestamp DESC. Click same column to toggle direction; click different column to switch and reset to DESC. Pure client-side over cached rows — no re-fetches./admin/sfm): Health banner with reachability indicator, terra-view↔SFM connection panel, 4 KPI tiles (known units, total events, stalemonitor_logrows, staleach_sessionsrows), per-unit roll-up table, recent-events table with color-coded forwarding latency (so stale watcher forwards stand out), and a raw API tester for any/api/sfm/*path./admin/slmm): Stripped-down companion page — health, connection info, raw API tester./tools): New top-level sidebar entry consolidating Pair Devices, Project Tidy, Metadata Backfill, Reports (info card), and Swap Detection (placeholder).docs/SYNOLOGY_DEPLOYMENT.md): End-to-end playbook for migrating the stack to an always-on office NAS — phased rollout (pre-stage, data rsync, watcher repoint, external access, decommission), Tailscale vs reverse-proxy options, rollback plan, and gotchas.Changed
_compute_stats()function insfm_events.py) now skip events flagged as false triggers when computing the highest PVS, so operators see the highest real event rather than the biggest sensor glitch.false_trigger_countstill includes flagged events so operators can see how many were filtered out.RosterUnit.noteEditing: Inline edit on seismograph cards is more forgiving and now auto-saves on blur.Fixed
H=Histogram,W=Waveform,M=Manual,E=Event,C=Combo). The proper fix lives in SFM's sidecar parser; tracked separately.Migration Notes
Run on each database before deploying. Every migration is idempotent.
Migrations new in this release:
migrate_add_metadata_backfill.py— addsunit_assignments.sourcecolumn andmetadata_backfill_decisionstable for the Metadata Backfill toolDeployment Notes
SFM_BASE_URL: Confirm prod'sdocker-compose.ymlsets this for the terra-view service (typicallyhttp://sfm:8200for the in-stack SFM container, or an external URL if SFM lives elsewhere).sfm_forward_urlshould point athttps://<your-terra-view-host>/api/sfm(proxy-based — no second port forward needed). Watcher composes the full path/db/import/blastware_fileitself.- backend/routers/sfm.py: HTTP proxy to SFM backend (localhost:8200), mirrors the SLMM proxy pattern. SFM_BASE_URL env var for docker-compose. Catch-all /{path} forwards to SFM root (no /api/ prefix). 60s timeout. - templates/sfm.html: full SFM dashboard with 5 tabs: Events (DB listing, filters by serial/date/false-trigger, flag/unflag FT), Units (known serials + stats, filter events by unit), Monitor Log (continuous monitoring intervals), ACH Sessions (call-home history), Live Device (TCP connect, device info cards, start/stop monitoring, push project config, download events from device, operation log). - backend/main.py: import sfm router, include router, add GET /sfm route - templates/base.html: add SFM Live Data nav link under Seismographs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>Phase 1 of the SFM project/location integration. When viewing a vibration monitoring location, operators now see the events that were actually recorded there — fanned out across every seismograph that was ever assigned to that location (handles mid-project unit swaps). Backend: - backend/services/sfm_events.py: new events_for_location() async helper. Walks UnitAssignment rows for the location (active + closed), intersects each assignment's [assigned_at, assigned_until] window with the requested filter, and concurrently queries SFM /db/events for each (serial, window) pair via httpx.AsyncClient. Unions, sorts newest-first, computes summary stats (event count, peak PVS + when/who, last event, false-trigger count) over the full set, and trims to the user's display limit. Over-fetches per-window (up to 5000) so stats stay accurate even with a small display limit. - backend/routers/project_locations.py: new GET endpoint /api/projects/{project_id}/locations/{location_id}/events. Validates project/location pairing (404 on mismatch). SLM locations return an empty payload rather than 404 so the frontend can render gracefully. Frontend: - templates/vibration_location_detail.html: new "Events" tab on the location detail page. KPI tiles (total / peak PVS / last event / false triggers), "Seismographs deployed at this location" assignment list (transparency: shows each assignment's date range and contributed event count), date / false-trigger / limit filters, and the paginated event table. Lazy-loaded on first tab visit; manual refresh button. Architectural notes: - SFM remains the single source of truth for events. No event sync; live HTTP per page load. - UnitAssignment is the join key (not MonitoringSession). - Events whose timestamp falls outside every assignment window are NOT surfaced here. Those orphan events get a dedicated "Unattributed events" view on the per-unit detail page in Phase 2. Out of scope (this commit): - Phase 2 (per-unit history view) and Phase 3 (project-level roll-up) reuse this helper but ship separately. - Phase 4 (deprecating deployment_records) is independent. - Extracting the event-table JS to a shared file is a follow-up. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>Operators couldn't change a unit's assigned_at / assigned_until after creating the assignment, so a unit physically deployed in December 2025 but only recorded in terra-view today would show "deployed today" and all its real events would be invisible on the project's location page. Backend: - PATCH /api/projects/{project_id}/assignments/{assignment_id} Accepts JSON body with optional assigned_at, assigned_until, notes. - assigned_at is required (cannot be cleared) - assigned_until can be null to mark active / indefinite - assigned_until must be after assigned_at - rejects overlaps with other assignments of the same unit at the same location (different units overlapping is fine — that's a legitimate swap window) - assignment.status flips to "active" when assigned_until is cleared, "completed" when set - 404 if the assignment doesn't belong to {project_id} (security) Frontend (vibration_location_detail.html): - Pencil icon next to each row in the "Seismographs deployed at this location" card. Click to open a modal with datetime-local inputs for From + Until (blank = active) and a Notes textarea. Save reloads the Events tab so KPI tiles and the event table reflect the new window. - Helper line under the assignment list explains the workflow: "Click the pencil to backdate a deployment so historical events get attributed to this location." Verified end-to-end against real data: backdating BE11529's assignment on a vibration location from 2026-04-14 to 2025-12-01 surfaced 10 additional events (24 -> 34) that were previously invisible. Validation suite (all returning correct HTTP codes): - assigned_until < assigned_at -> 400 - cross-project assignment_id -> 404 - assigned_at cleared -> 400 - notes-only update -> 200 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>Phase 2 of the SFM integration. Adds a "SFM Events" section to the seismograph unit detail page (/unit/{id}). Every event SFM has for the serial is shown, with each event annotated by which project/location assignment window it falls into. Events outside every assignment window get the "⚠ Unattributed" badge plus a "<N>d before/after <nearest location>" hint — that's the operator's signal that backdating an assignment (Phase 1 edit-pencil) will absorb the orphan events. Backend: - backend/services/sfm_events.py: new events_for_unit() helper. Fetches all events for the serial via SFM /db/events (one call, ceiling 5000), loads every UnitAssignment for the unit + resolves MonitoringLocation + Project names, then annotates each event with attribution or nearest_assignment (signed delta_days). Bucket filter: all / attributed / unattributed. Stats always reflect the full event set so the "Unattributed" KPI tile is meaningful regardless of which bucket is being viewed. - backend/routers/units.py: new GET /api/units/{unit_id}/events with bucket / date-range / false_trigger / limit query params. 404s on unknown unit_id; returns an empty payload for non-seismograph device_types so the page can render the section conditionally. Frontend (templates/unit_detail.html): - New "SFM Events" section between "Deployment History" and "Timeline", styled to match the existing card pattern (border-t divider, same heading weight). - Hidden by default; revealed only when currentUnit.device_type === 'seismograph' after the unit data loads. - Four KPI tiles: Total Events / Unattributed (highlighted amber when > 0) / Peak PVS / Last Event. - Filters: Bucket (all|attributed|unattributed), From/To, False Triggers, Limit, + Refresh. - Event table with Attribution column. Attributed rows link to the project/location detail page; unattributed rows are tinted amber and show "<N>d before/after <nearest location>" with a link to the nearest location. - Empty-state copy varies by bucket: e.g. unattributed-with-zero shows "✅ All events for this unit are attributed to a project/location". Verified end-to-end against BE11529 (81 events total, 24 attributed, 57 unattributed — all 57 unattributed events emitted within hours of the assignment start, which means backdating the assignment by a day would attribute every one of them). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>Phase 3 of the SFM integration. Adds a "Project-wide vibration events" KPI card to the Vibration tab of every project detail page, summarising event activity across all of that project's vibration MonitoringLocations. Backend: - backend/services/sfm_events.py: vibration_summary_for_project() helper. Concurrently fans out events_for_location() across every vibration location in the project; aggregates total events, peak PVS (with the location it occurred at), last-event timestamp, false-trigger count; and produces a per-location breakdown sorted by event count. - backend/routers/project_locations.py: new GET /api/projects/{p}/ vibration_summary endpoint returning an HTML partial (HTMX-friendly, matches the locations-list HTMX pattern already used on this page). Frontend: - templates/partials/projects/vibration_summary.html: new partial with four KPI tiles (total, peak PVS + linked location + date, last event, false triggers) and a "Top locations by activity" mini-list showing the top 5 by event count. Empty-state copy when the project has no vibration locations yet. - templates/projects/detail.html: HTMX-load the new summary above the locations list inside the Vibration tab. Verified against terra-view-alpha: 24 events across "Loc 1 - 78 poop street", peak PVS 14.1351 in/s. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>Phase 4. Rebuilds the seismograph "Deployment History" + "Timeline" sections on the unit detail page as a single derived view computed from three sources: unit_assignments (authoritative project/location windows), unit_history (calibration/retirement/deployed state changes), and SFM events overlaid per assignment window (count + peak PVS + last event). Fixes the wonky-timeline symptoms: missing entries, duplicate/contradictory rows, and no visibility into what the unit was actually doing during each deployment window. Backend: - backend/services/deployment_timeline.py: new deployment_timeline_for_unit() helper. Merges UnitAssignment rows (with SFM event overlay fetched concurrently via httpx), UnitHistory state-change rows (filtered to meaningful change_types and de-noised by dropping rows where old_value == new_value — there's noise in legacy audit log from record_history() being called on every save), and synthetic "gap" entries between assignments >= 1 day apart. Sorts newest first. - backend/routers/units.py: new GET /api/units/{unit_id}/deployment_timeline endpoint with optional include_events=false flag. - backend/routers/project_locations.py: assign / unassign / swap / update endpoints now write UnitHistory rows on every assignment lifecycle event. New change_types: assignment_created, assignment_ended, assignment_swapped, assignment_updated. These surface in the unified timeline (where the assignment row itself shows the structural data; the audit row is filtered out to avoid double-rendering). Closes a real gap — assignment changes were previously invisible to any audit consumer. - backend/migrate_deprecate_deployment_records.py: non-destructive migration. Adds deployment_records.deprecated_at column. For each legacy row without a matching UnitAssignment, best-effort synthesizes one (with the free-text location_name preserved in notes). Marks every processed row. Idempotent. DROP TABLE deferred to a follow-up release. Frontend (templates/unit_detail.html): - Removed legacy "Deployment History" card (with Log Deployment button) and the separate "Timeline" card. Replaced with a single "Deployment Timeline" section. - Three entry visual styles: assignment rows (orange dot, location + project link, event-overlay summary), gap rows (dashed outline, idle day count), and state_change rows (navy dot, friendly label, old → new value). Active assignments get a green dot + "active" badge. - Existing loadUnitHistory() and loadDeploymentHistory() functions kept as shims that delegate to loadDeploymentTimeline(), so modal-save callbacks that referenced them still trigger a refresh of the visible section. Legacy function bodies preserved under _legacy_*_unused names for archeology; not called by anything. Verified end-to-end: - BE11529 timeline now shows 2 entries (active assignment with 24-event overlay + the deployed→benched state change), compared to the previous noisy mix that included 6 no-op state-change rows. - Migration ran against real DB: 1 legacy row processed (had no project_id, marked deprecated without backfill). - Assign / unassign / swap / edit now leave a paper trail in unit_history. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>Clicking any event row in any of the three event tables (/sfm Events, project-location Events tab, unit detail SFM Events) now opens a modal populated from the SFM .sfm.json sidecar. Previously the /sfm page had a basic inline modal showing only the columns already in the table; this rebuilds it as a shared component and exposes the rich fields that the BW ASCII report unlocks. Shared component: - backend/static/event-modal.js — single ~250-line module. Public API: showEventDetail(eventId) fetches /api/sfm/db/events/{id}/sidecar live (no extra terra-view caching) and renders sections for: • Event (serial, timestamp, record type, sample rate, rec time, waveform key) • Project Info (operator-typed user notes — project / client / operator / sensor_location — flagged in the UI as "as typed into the seismograph at session start", not the terra-view assignment) • Peak Particle Velocity (per-channel + vector sum, with the time-of-vector-sum-peak when bw_report is available) • Microphone (Peak dB(L) + psi, ZC frequency, time of peak) • Sensor Self-Check table (per-channel freq + ratio/amplitude + pass/fail) • Device & Recording Metadata (firmware, battery, calibration date + by-whom, geo range, stop mode, units) • Source File (Blastware filename, size, SHA-256, capture time) closeEventDetailModal() closes; Escape key also closes. - templates/partials/event_detail_modal.html — modal shell partial (sticky title bar, scrollable body, click-outside-to-close). Wired into three pages: - templates/sfm.html: removed the old inline modal + showEventDetail / ppvCard / closeEventModal functions (replaced by the shared module). Row onclick now passes just the event id instead of the full JSON. - templates/vibration_location_detail.html: row click on the Events tab opens the modal. The /unit/{serial} link inside the row has event.stopPropagation() so the link navigates instead of opening the modal. - templates/unit_detail.html: row click on the SFM Events table opens the modal. The attribution-cell project/location links also got stopPropagation. Graceful degradation: older events forwarded before the watcher's _ASCII.TXT pairing fix don't have a bw_report block in their sidecar. The modal renders an amber banner explaining that and shows just the event + project_info + peak_values + source-file sections. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>Two new action buttons at the top of the Source File section of the event-detail modal: 1. Download Blastware file — primary orange button. Pulls the raw .AB0 /.G10/.6R0/etc. binary from SFM (/db/events/{id}/blastware_file) via terra-view's /api/sfm proxy. The browser saves it with the original on-disk filename (using the HTML5 `download` attribute pointed at sidecar.blastware.filename). Operator can then open the file directly in Blastware on a Windows box for full waveform analysis, archive it, or attach it to a compliance report. Greyed-out "Blastware file unavailable" placeholder shown when sidecar.blastware.available is false (rare — would mean SFM stored the metadata but lost the binary). 2. Download sidecar JSON — secondary outlined button. Pulls the same .sfm.json the modal renders from. Saved as <binary>.sfm.json. Useful for ops/diagnostics and for the future metadata-driven project parser (Phase 5) which can chew on these directly. End-to-end verified through the proxy: 8882-byte Blastware binary intact with "Instantel" magic header preserved. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>Operator clicks one button. Parser reads SFM's events table (operator-typed project / client / sensor_location strings), clusters by serial + time + metadata, fuzzy-matches against existing projects, and proposes Project / MonitoringLocation / UnitAssignment chains to create. Auto-applies high-confidence non-conflicting clusters in bulk; queues medium/low confidence for individual review. Verified against real data: 10,052 events → 59 clusters → 37 high- confidence + 14 medium + 8 low. Test-applied one cluster end-to-end; Project + Module + Location + Assignment + UnitHistory + Decision rows all created correctly, and Phase 2's attribution walk picked up the events automatically on the new location's detail page. Pipeline (backend/services/metadata_backfill.py, ~700 lines): 1. Pull all SFM events via /db/events per serial. 2. Pre-filter: drop events already covered by an existing UnitAssignment window (Phase 2 handles those automatically). 3. Time-cluster what's left: serial + 7-day gap is the cluster identity. 4. Metadata-split each time-cluster on persistent metadata transitions (≥ 2 consecutive events) so a single typo doesn't fork the cluster. 5. Match against existing graph (rapidfuzz.WRatio multi-signal scoring, normalisation that handles abbreviations / reorders / separator variations). Thresholds: 0.95 exact, 0.80 fuzzy, min-shorter-input 5 chars to guardrail false positives on single common words. 6. Score confidence (high/medium/low) using event count, span, blank-meta, conflict, ambiguity rules. 7. Detect conflicts: overlap with existing UnitAssignment at a different location for the same serial → blocking. Operator must reconcile. 8. Apply: ensure auto_imported ProjectType exists, ensure vibration_monitoring ProjectModule on the project, write Project / MonitoringLocation / UnitAssignment / UnitHistory all in one transaction. Migration (backend/migrate_add_metadata_backfill.py): adds unit_assignments.source column (default 'manual') and metadata_backfill_decisions table. Idempotent, non-destructive. API (backend/routers/metadata_backfill.py): GET /api/admin/metadata_backfill/scan — clusters + suggestions POST /api/admin/metadata_backfill/apply — bulk apply by cluster_ids w/ optional per-cluster project/location overrides POST /api/admin/metadata_backfill/skip — mark skipped (persistent) UI (templates/admin/metadata_backfill.html, accessible at /settings/developer/metadata-backfill via the Developer tab of Settings): - One-button "Run scan" entry. - Summary KPI tiles (scanned / already attributed / pending / conflicts). - "Apply all high-confidence" bulk button at the top — primary path. - Per-cluster cards below with Apply / Skip / Preview event actions. - Blank-meta clusters get inline input fields for operator-typed project + location names before applying. - Blocking-conflict clusters render with the conflicting assignment information and a disabled Apply button. - Live progress toast during apply. - Reuses the Phase 1+2+4 event-detail modal for "Preview event" — operator can sanity-check the BW report data against the cluster's sample event. Dependencies: rapidfuzz==3.10.1 added to requirements.txt. Pre-built C wheels for all platforms, ~5s docker build hit. Phase 5b (deferred to next session): swap-detection daily background job, notification inbox for auto-applied swaps, recently-applied audit view, "Tidy" page for renaming/merging auto-created projects. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>Operator no longer has to accept the parser's suggested project / location verbatim. Each cluster card now has editable typeahead inputs that search existing projects (and existing locations within the chosen project), with a "Create new: <typed>" fallback always available. Solves the I-80-North-Fork case: of the 20+ cluster variants ("I-80-North Fork Bridges-I80 E. Abutment", "I-80- North Fork Bridges-543 Plank Rd", etc.), operator types "I-80" in the Project input, picks the existing project from the dropdown, and the cluster attaches to it. Repeat for the other variants. No need to pre-create the canonical project — though pre-creation still works fine if you'd rather. Backend (backend/routers/metadata_backfill.py): - GET /api/admin/metadata_backfill/projects_search?q=&limit= Returns existing projects matching by case-insensitive substring OR rapidfuzz WRatio score >= 0.50. Substring matches sort to the top (treated as exact for ordering). Includes location_count and project_number/client_name in each result for disambiguation. Always emits a "Create new: <q>" suggestion alongside the matches. - GET /api/admin/metadata_backfill/locations_search?project_id=&q=&limit= Same shape, scoped to a single project's vibration locations. - POST /api/admin/metadata_backfill/apply now accepts four override keys per cluster (was previously two): project_id → attach to existing Project (operator picked from typeahead) project_name → create new with this name (operator typed a custom name; existing project_name behaviour) location_id → attach to existing MonitoringLocation; validated against the chosen project_id so a stale location FK can't sneak in location_name → create new location with this name Frontend (templates/admin/metadata_backfill.html): - Each non-blank-meta cluster card now has two editable typeahead inputs (Project + Location) pre-populated with the parser's suggested values. Old static "Project: + Create new: X" / "≈ Fuzzy match" pills replaced with compact hint lines under the inputs showing what the current value will do. - Typeahead dropdown opens on focus, debounced 150ms on type. Shows matched existing entities with score badges (exact / NN%) plus a "Create new: <typed>" option at the bottom. Click-to-pick fills the text input and writes the entity id into a hidden field. - Picking a new project clears the location id (forces re-pick under the new project, avoids cross-project location FKs). - _gatherOverrides re-wired to emit the new project_id / location_id keys when the operator picked from the dropdown, falling back to *_name when they typed free-form. Backward-compatible: blank-meta clusters keep their existing "project_name / location_name" plain inputs and the override path still honours them. Verified end-to-end: - /projects_search?q=I-80 returns the existing "I-80 - North Fork Bridge" project (score 1.0, has 4 locations) plus a "Create new" option. - /locations_search requires project_id (400 without it). - Wizard page renders with typeahead wiring confirmed in HTML. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>Operator-facing tool for cleaning up duplicate projects. Common after the metadata-backfill parser auto-creates near-duplicates from operator name variations ("SR81" vs "SR 81", "Swank-Karns Crossing" vs "Swank-Karns Crossings", "Trumbull-Bryman Mont.Dam" vs "Trumbull-Brayman-Mont Dam", etc.). Workflow: visit the duplicate project's detail page, click "Merge into…" in the header, search for the canonical target project from a typeahead, review the preview (what assignments / locations / sessions will move, any conflicts), confirm. Source is soft-deleted; everything else re-points to the target. Smart consolidation: same-named locations in both projects merge into one (source's assignments move to target's existing location with the same name; source's empty location is then deleted). Different-named locations move as-is. Backend: - backend/services/project_merge.py (new): preview() and execute() functions. Transaction-safe. Per-assignment UnitHistory audit row with change_type='assignment_merged' so the deployment timeline shows the merge. Source modules disabled; missing modules added to target. Handles edge cases: same project_id rejected, deleted projects rejected, orphan project-direct assignments (no location) re-pointed defensively. - backend/routers/projects.py: new endpoints GET /api/projects/{source_id}/merge_preview?target_id=... POST /api/projects/{source_id}/merge_into?target_id=... Frontend (templates/partials/projects/project_header.html): - "Merge into…" button in Project Actions area. - Modal with typeahead (reuses /api/admin/metadata_backfill/projects_search) scoped to existing projects only (no create-new option). Filters out the source project from candidates so operator can't accidentally pick it as target. - Preview pane shows totals + per-location plan (consolidate vs move) + warnings (mismatched client names, location consolidation note). - Red "Merge (permanent)" confirm button only enables after a target is picked and preview loads. - On success, browser redirects to target project page. Smoke verified: "Swank-Karns Crossing" (1 assignment) merged into "Swank-Karns Crossings"; target now has 2 locations + 2 assignments, source has 0 dangling rows, 1 project_merge audit entry written. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>The sidebar had 10 entries with 5 of them (Devices, Seismographs, Sound Level Meters, Modems, Pair Devices) all about the physical fleet plus SFM Events as a debug surface. Operators kept asking "where do I find BE11529?" without knowing whether it was a seismograph / SLM / modem. This collapses those 5+1 into a single "Fleet" sidebar entry that opens into a unified tab strip across the top of the four device pages. Each page keeps its existing custom layout (seismograph-specific calibration/deployment columns, SLM live-status panel, modem pairing view, all-devices roster). The strip just provides the navigation + the "Pair Devices" button as an action. Sidebar before (10 items): Dashboard · Devices · Seismographs · SFM Events · Sound Level Meters Modems · Pair Devices · Projects · Job Planner · Settings Sidebar after (5 items): Dashboard · Fleet · Projects · Job Planner · Settings Changes: - templates/partials/fleet_tab_strip.html (new): the shared tab strip. Auto-detects the active tab from request.url.path. 4 tabs (Seismographs / Sound Level Meters / Modems / All Devices) plus a "Pair Devices" button on the right. - templates/{seismographs,sound_level_meters,modems,roster}.html: added {% include 'partials/fleet_tab_strip.html' %} as the first thing inside the content block. No other changes to those templates' existing layouts. - templates/base.html: replaced the 6 device-related sidebar links with one "Fleet" link to /seismographs. The Fleet entry is highlighted when the current URL is any of /seismographs, /sound-level-meters, /modems, /roster, /pair-devices, /unit/*, or /slm/*. - templates/settings.html: SFM Events moved out of the main nav into a new "SFM Admin" card under Settings → Developer. Daily event browsing already lives on project / location / unit pages (Phases 1+2+3); the standalone /sfm page is now admin / cross-project debug surface only. URLs unchanged — all bookmarks / deep links still work. /sfm still serves the standalone page, it's just no longer in the main nav. Mobile bottom-nav unaffected. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>Sidebar evolved from "Fleet defaults to seismograph dashboard" to "Devices defaults to unified roster" + a new "Tools" entry housing the active operator workflows. Sidebar (6 items): Dashboard · Devices · Projects · Tools · Job Planner · Settings Changes: - templates/base.html: renamed Fleet → Devices. Default route changed from /seismographs to /roster — clicking Devices now lands on the unified all-devices view, then operators drill into type-specific layouts via the tab strip. Tools entry added between Projects and Job Planner; highlights when on /tools or any of its linked workflow pages. - templates/partials/fleet_tab_strip.html: reordered tabs so "All Devices" comes first (matches the new default landing). Seismographs → SLMs → Modems follow. - templates/tools.html (new) + /tools route in main.py: card grid hub for active workflows. • Pair Devices — links to /pair-devices • Project Tidy — links to /settings/developer/project-tidy • Backfill from event metadata — /settings/developer/metadata-backfill • Reports — info card pointing to project detail pages where Excel report generation actually lives (per-project context) • Swap Detection — greyed-out placeholder for Phase 5c - templates/settings.html: removed Project Tidy + Metadata Backfill cards from Settings → Developer. They now live in Tools. Settings → Developer retains the truly admin/dev surfaces (Watcher Manager, SFM Admin). The workflow page URLs (/settings/developer/project-tidy, /settings/developer/metadata-backfill) stay where they are — only the nav entry point changes. Bookmarks still work. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>emit_status_snapshot() now consults SFM /db/units (cached 15s) before falling back to Emitter.last_seen for each seismograph. The fresher of the two wins and the choice is recorded in a new per-unit last_seen_source field ("sfm" | "heartbeat" | "none"). sfm_reachable is exposed alongside so the UI can show degraded state. Fallback is transparent: if SFM is unreachable or has no record for a serial, the watcher heartbeat path takes over and the unit just shows the HB badge instead of SFM. No schema changes; SLMs are untouched (they don't go through SFM); modems inherit source from their pair. active_table.html grows a small "SFM" / "HB" badge next to the age column so operators can see at a glance which path is currently driving each unit's status. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>New /admin/sfm page (linked from Settings → Developer): - Health banner — green/red with version + last-checked timestamp - Connection panel — shows SFM_BASE_URL terra-view is configured with - 4 KPI tiles — known units, total events, stale monitor_log rows, stale ach_sessions rows (the deprecated tables from the paused Python-ACH experiment, useful for confirming nothing's growing them) - Per-unit roll-up table — serial, last_seen, event count, stale per-unit counts, sourced from SFM's /db/units - Recent events with forwarding latency — color-coded gap between the event's recorded timestamp and SFM ingest time, so operators can spot watchers that are forwarding stale files (e.g. after a jobsite outage) - Raw API tester — text input + GET button against any /api/sfm/* path, response rendered as prettified JSON New /admin/slmm page — same layout, stripped down to health + connection + raw API tester. For per-device SLM control the existing /sound-level-meters dashboard remains the right entry point. Backend (backend/routers/admin_modules.py): - GET /admin/sfm, GET /admin/slmm — HTML pages - GET /api/admin/sfm/overview — single aggregated probe that returns health, units, last 25 events with computed latency, stale-table counts, cache stats. Tolerant of partial failures: any sub-fetch error is captured into errors{} so a flaky SFM endpoint doesn't break the whole page - GET /api/admin/slmm/overview — health + connection info only for now Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>