fix: improve roster behavior with in-place rerfresh.

docs: update for 0.9.4
This commit is contained in:
serversdwn
2026-04-10 22:22:25 +00:00
parent 3e0d20d62d
commit f84d0818d2
4 changed files with 140 additions and 9 deletions

View File

@@ -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/), 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). 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 ## [0.9.3] - 2026-03-28
### Added ### Added

View File

@@ -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. 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 ## Features

View File

@@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine)
ENVIRONMENT = os.getenv("ENVIRONMENT", "production") ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
# Initialize FastAPI app # Initialize FastAPI app
VERSION = "0.9.3" VERSION = "0.9.4"
if ENVIRONMENT == "development": if ENVIRONMENT == "development":
_build = os.getenv("BUILD_NUMBER", "0") _build = os.getenv("BUILD_NUMBER", "0")
if _build and _build != "0": if _build and _build != "0":

View File

@@ -1231,12 +1231,22 @@
// Refresh device list (applies current client-side filters after load) // Refresh device list (applies current client-side filters after load)
function refreshDeviceList() { function refreshDeviceList() {
const scrollY = window.scrollY;
htmx.ajax('GET', '/partials/devices-all', { htmx.ajax('GET', '/partials/devices-all', {
target: '#device-content', target: '#device-content',
swap: 'innerHTML' swap: 'innerHTML'
}).then(() => { }).then(() => {
// Re-apply filters after content loads setTimeout(() => {
setTimeout(filterDevices, 100); 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() { 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(() => { setInterval(() => {
const deviceContent = document.getElementById('device-content'); if (!isAnyModalOpen()) {
if (deviceContent && !isAnyModalOpen()) { refreshStatusInPlace();
// Only auto-refresh if no modal is open
refreshDeviceList();
} }
}, 30000); }, 30000);
}); });