From f84d0818d2fc63ab8f535b23d0cae869c8a3f562 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Fri, 10 Apr 2026 22:22:25 +0000 Subject: [PATCH] fix: improve roster behavior with in-place rerfresh. docs: update for 0.9.4 --- CHANGELOG.md | 20 +++++++ README.md | 2 +- backend/main.py | 2 +- templates/roster.html | 125 +++++++++++++++++++++++++++++++++++++++--- 4 files changed, 140 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97335e5..3973d36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ 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.9.4] - 2026-04-06 + +### Added +- **Modular Project Types**: Projects now support optional modules (Sound Monitoring, Vibration Monitoring) selectable at creation time. The project header and dashboard dynamically show/hide tabs and actions based on which modules are enabled, and modules can be added or removed after creation. +- **Deleted Project Management**: Settings page now includes a section for soft-deleted projects with options to restore or permanently delete each one. Deleted projects load automatically when the Data tab is opened. + +### Changed +- **Swap Modal Search**: The unit/modem swap modal on vibration location detail pages now includes live search filtering for both seismographs and modems, making it easier to find the right unit in large fleets. + +### Fixed +- **Roster Auto-Refresh No Longer Disrupts Scroll/Sort**: The roster page's 30-second background refresh now updates status, age, and last-seen values in-place via a lightweight JSON poll instead of replacing the entire table HTML. Sort order, scroll position, and active filters are all preserved across refreshes. + +### Migration Notes +Run on each database before deploying: +```bash +docker compose exec terra-view python3 backend/migrate_add_project_modules.py +``` + +--- + ## [0.9.3] - 2026-03-28 ### Added diff --git a/README.md b/README.md index d5c7557..011f764 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Terra-View v0.9.3 +# Terra-View v0.9.4 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 diff --git a/backend/main.py b/backend/main.py index 89cca81..ca35337 100644 --- a/backend/main.py +++ b/backend/main.py @@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine) ENVIRONMENT = os.getenv("ENVIRONMENT", "production") # Initialize FastAPI app -VERSION = "0.9.3" +VERSION = "0.9.4" if ENVIRONMENT == "development": _build = os.getenv("BUILD_NUMBER", "0") if _build and _build != "0": diff --git a/templates/roster.html b/templates/roster.html index 6380962..fed2856 100644 --- a/templates/roster.html +++ b/templates/roster.html @@ -1231,12 +1231,22 @@ // Refresh device list (applies current client-side filters after load) function refreshDeviceList() { + const scrollY = window.scrollY; htmx.ajax('GET', '/partials/devices-all', { target: '#device-content', swap: 'innerHTML' }).then(() => { - // Re-apply filters after content loads - setTimeout(filterDevices, 100); + setTimeout(() => { + filterDevices(); + // Re-apply sort if one was active + if (window.currentSort && window.currentSort.column) { + // sortTable toggles direction, so pre-flip so the toggle lands on the correct value + window.currentSort.direction = window.currentSort.direction === 'asc' ? 'desc' : 'asc'; + sortTable(window.currentSort.column); + } + // Restore scroll position + window.scrollTo(0, scrollY); + }, 100); }); } @@ -1249,13 +1259,114 @@ }); } + // Silently refresh only the status/age/last-seen fields in the existing rows + // without touching the DOM structure, so sort/scroll/filters are undisturbed. + async function refreshStatusInPlace() { + let snapshot; + try { + const resp = await fetch('/api/roster/status-snapshot'); + if (!resp.ok) return; + snapshot = await resp.json(); + } catch (e) { + return; + } + + const units = snapshot.units || {}; + + // Helper: map status string → dot color class + function statusDotClass(status) { + if (status === 'OK') return 'bg-green-500'; + if (status === 'Pending') return 'bg-yellow-500'; + if (status === 'Missing') return 'bg-red-500'; + return 'bg-gray-400'; + } + + // --- Desktop table rows --- + document.querySelectorAll('#roster-tbody tr[data-id]').forEach(row => { + const uid = row.dataset.id; + const u = units[uid]; + if (!u) return; + + const newStatus = u.status || 'Missing'; + const newAge = u.age || 'N/A'; + const newLast = u.last || ''; + + // Update data attributes used by sort/filter + row.dataset.health = newStatus; + row.dataset.age = newAge; + row.dataset.lastSeen = newLast; + + // Status dot (first span inside first td) + const dot = row.querySelector('td:first-child span:first-child'); + if (dot && row.dataset.status === 'deployed') { + dot.className = `w-3 h-3 rounded-full ${statusDotClass(newStatus)}`; + dot.title = newStatus; + } + + // Age cell (6th td — index 5) + const cells = row.querySelectorAll('td'); + if (cells[5]) { + const ageDiv = cells[5].querySelector('div'); + if (ageDiv) { + ageDiv.textContent = newAge; + ageDiv.className = `text-sm ${ + newStatus === 'Missing' ? 'text-red-600 dark:text-red-400 font-semibold' : + newStatus === 'Pending' ? 'text-yellow-600 dark:text-yellow-400' : + 'text-gray-500 dark:text-gray-400' + }`; + } + } + + // Last-seen cell (5th td — index 4) + if (cells[4]) { + const lsDiv = cells[4].querySelector('.last-seen-cell'); + if (lsDiv) { + lsDiv.dataset.iso = newLast; + lsDiv.textContent = newLast; + } + } + }); + + // --- Mobile cards --- + document.querySelectorAll('.device-card[data-unit-id]').forEach(card => { + const uid = card.dataset.unitId; + const u = units[uid]; + if (!u) return; + + const newStatus = u.status || 'Missing'; + const newAge = u.age || 'N/A'; + + card.dataset.health = newStatus; + card.dataset.age = newAge; + + // Status dot (first span in header div) + const dot = card.querySelector('span.rounded-full:first-child'); + if (dot && card.dataset.status === 'deployed') { + dot.className = `w-4 h-4 rounded-full ${statusDotClass(newStatus)}`; + dot.title = newStatus; + } + + // Age text — the div containing the clock emoji + card.querySelectorAll('.text-sm').forEach(div => { + if (div.textContent.includes('🕐')) { + div.textContent = `🕐 ${newAge}`; + } + }); + }); + + // Update the "last updated" timestamp + const ts = document.getElementById('last-updated'); + if (ts) ts.textContent = new Date().toLocaleTimeString(); + + // Re-apply active filters (sort order is untouched since DOM rows weren't moved) + filterDevices(); + } + document.addEventListener('DOMContentLoaded', function() { - // Auto-refresh device list every 30 seconds (increased from 10s to reduce flicker) + // Poll status every 30 seconds — updates cells in-place, no DOM restructuring setInterval(() => { - const deviceContent = document.getElementById('device-content'); - if (deviceContent && !isAnyModalOpen()) { - // Only auto-refresh if no modal is open - refreshDeviceList(); + if (!isAnyModalOpen()) { + refreshStatusInPlace(); } }, 30000); });