fix: improve roster behavior with in-place rerfresh.
docs: update for 0.9.4
This commit is contained in:
20
CHANGELOG.md
20
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/),
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user