feat: Enhance dashboard with filtering options and sync SLM status
- Added a new filtering system to the dashboard for device types and statuses. - Implemented asynchronous SLM status synchronization to update the Emitter table. - Updated the status snapshot endpoint to sync SLM status before generating the snapshot. - Refactored the dashboard HTML to include filter controls and JavaScript for managing filter state. - Improved the unit detail page to handle modem associations and cascade updates to paired devices. - Removed redundant code related to syncing start time for measuring devices.
This commit is contained in:
@@ -187,6 +187,68 @@
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Filters -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-4 mb-4" id="dashboard-filters-card">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Filter Dashboard</h3>
|
||||
<button onclick="resetFilters()" class="text-xs text-gray-500 hover:text-seismo-orange dark:hover:text-seismo-orange transition-colors">
|
||||
Reset Filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-6">
|
||||
<!-- Device Type Filters -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 font-medium uppercase tracking-wide">Device Type</span>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="checkbox" id="filter-seismograph" checked
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-slate-800"
|
||||
onchange="applyFilters()">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Seismographs</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="checkbox" id="filter-slm" checked
|
||||
class="rounded border-gray-300 text-purple-600 focus:ring-purple-500 dark:border-gray-600 dark:bg-slate-800"
|
||||
onchange="applyFilters()">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">SLMs</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="checkbox" id="filter-modem" checked
|
||||
class="rounded border-gray-300 text-cyan-600 focus:ring-cyan-500 dark:border-gray-600 dark:bg-slate-800"
|
||||
onchange="applyFilters()">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Modems</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Filters -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 font-medium uppercase tracking-wide">Status</span>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="checkbox" id="filter-ok" checked
|
||||
class="rounded border-gray-300 text-green-600 focus:ring-green-500 dark:border-gray-600 dark:bg-slate-800"
|
||||
onchange="applyFilters()">
|
||||
<span class="text-sm text-green-600 dark:text-green-400">OK</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="checkbox" id="filter-pending" checked
|
||||
class="rounded border-gray-300 text-yellow-600 focus:ring-yellow-500 dark:border-gray-600 dark:bg-slate-800"
|
||||
onchange="applyFilters()">
|
||||
<span class="text-sm text-yellow-600 dark:text-yellow-400">Pending</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="checkbox" id="filter-missing" checked
|
||||
class="rounded border-gray-300 text-red-600 focus:ring-red-500 dark:border-gray-600 dark:bg-slate-800"
|
||||
onchange="applyFilters()">
|
||||
<span class="text-sm text-red-600 dark:text-red-400">Missing</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fleet Map -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6 mb-8" id="fleet-map-card">
|
||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-map')">
|
||||
@@ -302,6 +364,254 @@
|
||||
|
||||
|
||||
<script>
|
||||
// ===== Dashboard Filtering System =====
|
||||
let currentSnapshotData = null; // Store latest snapshot data for re-filtering
|
||||
|
||||
// Filter state - tracks which device types and statuses to show
|
||||
const filters = {
|
||||
deviceTypes: {
|
||||
seismograph: true,
|
||||
sound_level_meter: true,
|
||||
modem: true
|
||||
},
|
||||
statuses: {
|
||||
OK: true,
|
||||
Pending: true,
|
||||
Missing: true
|
||||
}
|
||||
};
|
||||
|
||||
// Load saved filter preferences from localStorage
|
||||
function loadFilterPreferences() {
|
||||
const saved = localStorage.getItem('dashboardFilters');
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.deviceTypes) Object.assign(filters.deviceTypes, parsed.deviceTypes);
|
||||
if (parsed.statuses) Object.assign(filters.statuses, parsed.statuses);
|
||||
} catch (e) {
|
||||
console.error('Error loading filter preferences:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Sync checkboxes with loaded state
|
||||
const seismoCheck = document.getElementById('filter-seismograph');
|
||||
const slmCheck = document.getElementById('filter-slm');
|
||||
const modemCheck = document.getElementById('filter-modem');
|
||||
const okCheck = document.getElementById('filter-ok');
|
||||
const pendingCheck = document.getElementById('filter-pending');
|
||||
const missingCheck = document.getElementById('filter-missing');
|
||||
|
||||
if (seismoCheck) seismoCheck.checked = filters.deviceTypes.seismograph;
|
||||
if (slmCheck) slmCheck.checked = filters.deviceTypes.sound_level_meter;
|
||||
if (modemCheck) modemCheck.checked = filters.deviceTypes.modem;
|
||||
if (okCheck) okCheck.checked = filters.statuses.OK;
|
||||
if (pendingCheck) pendingCheck.checked = filters.statuses.Pending;
|
||||
if (missingCheck) missingCheck.checked = filters.statuses.Missing;
|
||||
}
|
||||
|
||||
// Save filter preferences to localStorage
|
||||
function saveFilterPreferences() {
|
||||
localStorage.setItem('dashboardFilters', JSON.stringify(filters));
|
||||
}
|
||||
|
||||
// Apply filters - called when any checkbox changes
|
||||
function applyFilters() {
|
||||
// Update filter state from checkboxes
|
||||
const seismoCheck = document.getElementById('filter-seismograph');
|
||||
const slmCheck = document.getElementById('filter-slm');
|
||||
const modemCheck = document.getElementById('filter-modem');
|
||||
const okCheck = document.getElementById('filter-ok');
|
||||
const pendingCheck = document.getElementById('filter-pending');
|
||||
const missingCheck = document.getElementById('filter-missing');
|
||||
|
||||
if (seismoCheck) filters.deviceTypes.seismograph = seismoCheck.checked;
|
||||
if (slmCheck) filters.deviceTypes.sound_level_meter = slmCheck.checked;
|
||||
if (modemCheck) filters.deviceTypes.modem = modemCheck.checked;
|
||||
if (okCheck) filters.statuses.OK = okCheck.checked;
|
||||
if (pendingCheck) filters.statuses.Pending = pendingCheck.checked;
|
||||
if (missingCheck) filters.statuses.Missing = missingCheck.checked;
|
||||
|
||||
saveFilterPreferences();
|
||||
|
||||
// Re-render with current data and filters
|
||||
if (currentSnapshotData) {
|
||||
renderFilteredDashboard(currentSnapshotData);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset all filters to show everything
|
||||
function resetFilters() {
|
||||
filters.deviceTypes = { seismograph: true, sound_level_meter: true, modem: true };
|
||||
filters.statuses = { OK: true, Pending: true, Missing: true };
|
||||
|
||||
// Update all checkboxes
|
||||
const checkboxes = [
|
||||
'filter-seismograph', 'filter-slm', 'filter-modem',
|
||||
'filter-ok', 'filter-pending', 'filter-missing'
|
||||
];
|
||||
checkboxes.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.checked = true;
|
||||
});
|
||||
|
||||
saveFilterPreferences();
|
||||
|
||||
if (currentSnapshotData) {
|
||||
renderFilteredDashboard(currentSnapshotData);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a unit passes the current filters
|
||||
function unitPassesFilter(unit) {
|
||||
const deviceType = unit.device_type || 'seismograph';
|
||||
const status = unit.status || 'Missing';
|
||||
|
||||
// Check device type filter
|
||||
if (!filters.deviceTypes[deviceType]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check status filter
|
||||
if (!filters.statuses[status]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get display label for device type
|
||||
function getDeviceTypeLabel(deviceType) {
|
||||
switch(deviceType) {
|
||||
case 'sound_level_meter': return 'SLM';
|
||||
case 'modem': return 'Modem';
|
||||
default: return 'Seismograph';
|
||||
}
|
||||
}
|
||||
|
||||
// Render dashboard with filtered data
|
||||
function renderFilteredDashboard(data) {
|
||||
// Filter active units for alerts
|
||||
const filteredActive = {};
|
||||
Object.entries(data.active || {}).forEach(([id, unit]) => {
|
||||
if (unitPassesFilter(unit)) {
|
||||
filteredActive[id] = unit;
|
||||
}
|
||||
});
|
||||
|
||||
// Update alerts with filtered data
|
||||
updateAlertsFiltered(filteredActive);
|
||||
|
||||
// Update map with filtered data
|
||||
updateFleetMapFiltered(data.units);
|
||||
}
|
||||
|
||||
// Update the Recent Alerts section with filtering
|
||||
function updateAlertsFiltered(filteredActive) {
|
||||
const alertsList = document.getElementById('alerts-list');
|
||||
const missingUnits = Object.entries(filteredActive).filter(([_, u]) => u.status === 'Missing');
|
||||
|
||||
if (!missingUnits.length) {
|
||||
// Check if this is because of filters or genuinely no alerts
|
||||
const anyMissing = currentSnapshotData && Object.values(currentSnapshotData.active || {}).some(u => u.status === 'Missing');
|
||||
if (anyMissing) {
|
||||
alertsList.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No alerts match current filters</p>';
|
||||
} else {
|
||||
alertsList.innerHTML = '<p class="text-sm text-green-600 dark:text-green-400">All units reporting normally</p>';
|
||||
}
|
||||
} else {
|
||||
let alertsHtml = '';
|
||||
missingUnits.forEach(([id, unit]) => {
|
||||
const deviceLabel = getDeviceTypeLabel(unit.device_type);
|
||||
alertsHtml += `
|
||||
<div class="flex items-start space-x-2 text-sm">
|
||||
<span class="w-2 h-2 rounded-full bg-red-500 mt-1.5"></span>
|
||||
<div>
|
||||
<a href="/unit/${id}" class="font-medium text-red-600 dark:text-red-400 hover:underline">${id}</a>
|
||||
<span class="text-xs text-gray-500 ml-1">(${deviceLabel})</span>
|
||||
<p class="text-gray-600 dark:text-gray-400">Missing for ${unit.age}</p>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
alertsList.innerHTML = alertsHtml;
|
||||
}
|
||||
}
|
||||
|
||||
// Update map with filtered data
|
||||
function updateFleetMapFiltered(allUnits) {
|
||||
if (!fleetMap) return;
|
||||
|
||||
// Clear existing markers
|
||||
fleetMarkers.forEach(marker => fleetMap.removeLayer(marker));
|
||||
fleetMarkers = [];
|
||||
|
||||
// Get deployed units with coordinates that pass the filter
|
||||
const deployedUnits = Object.entries(allUnits || {})
|
||||
.filter(([_, u]) => u.deployed && u.coordinates && unitPassesFilter(u));
|
||||
|
||||
if (deployedUnits.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = [];
|
||||
|
||||
deployedUnits.forEach(([id, unit]) => {
|
||||
const coords = parseLocation(unit.coordinates);
|
||||
if (coords) {
|
||||
const [lat, lon] = coords;
|
||||
|
||||
// Color based on status
|
||||
const markerColor = unit.status === 'OK' ? 'green' :
|
||||
unit.status === 'Pending' ? 'orange' : 'red';
|
||||
|
||||
// Different marker style per device type
|
||||
const deviceType = unit.device_type || 'seismograph';
|
||||
let radius = 8;
|
||||
let weight = 2;
|
||||
|
||||
if (deviceType === 'modem') {
|
||||
radius = 6;
|
||||
weight = 2;
|
||||
} else if (deviceType === 'sound_level_meter') {
|
||||
radius = 8;
|
||||
weight = 3;
|
||||
}
|
||||
|
||||
const marker = L.circleMarker([lat, lon], {
|
||||
radius: radius,
|
||||
fillColor: markerColor,
|
||||
color: '#fff',
|
||||
weight: weight,
|
||||
opacity: 1,
|
||||
fillOpacity: 0.8
|
||||
}).addTo(fleetMap);
|
||||
|
||||
// Popup with device type
|
||||
const deviceLabel = getDeviceTypeLabel(deviceType);
|
||||
|
||||
marker.bindPopup(`
|
||||
<div class="p-2">
|
||||
<h3 class="font-bold text-lg">${id}</h3>
|
||||
<p class="text-sm text-gray-600">${deviceLabel}</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>
|
||||
</div>
|
||||
`);
|
||||
|
||||
fleetMarkers.push(marker);
|
||||
bounds.push([lat, lon]);
|
||||
}
|
||||
});
|
||||
|
||||
// Fit bounds if we have markers
|
||||
if (bounds.length > 0) {
|
||||
const padding = window.innerWidth < 768 ? [20, 20] : [50, 50];
|
||||
fleetMap.fitBounds(bounds, { padding: padding });
|
||||
fleetMapInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle card collapse/expand (mobile only)
|
||||
function toggleCard(cardName) {
|
||||
// Only work on mobile
|
||||
@@ -366,8 +676,17 @@ if (document.readyState === 'loading') {
|
||||
|
||||
function updateDashboard(event) {
|
||||
try {
|
||||
// Only process responses from /api/status-snapshot
|
||||
const requestUrl = event.detail.xhr.responseURL || event.detail.pathInfo?.requestPath;
|
||||
if (!requestUrl || !requestUrl.includes('/api/status-snapshot')) {
|
||||
return; // Ignore responses from other endpoints (like /dashboard/todays-actions)
|
||||
}
|
||||
|
||||
const data = JSON.parse(event.detail.xhr.response);
|
||||
|
||||
// Store data for filter re-application
|
||||
currentSnapshotData = data;
|
||||
|
||||
// Update "Last updated" timestamp with timezone
|
||||
const now = new Date();
|
||||
const timezone = localStorage.getItem('timezone') || 'America/New_York';
|
||||
@@ -379,7 +698,7 @@ function updateDashboard(event) {
|
||||
timeZoneName: 'short'
|
||||
});
|
||||
|
||||
// ===== Fleet summary numbers =====
|
||||
// ===== 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;
|
||||
@@ -387,9 +706,10 @@ function updateDashboard(event) {
|
||||
document.getElementById('status-pending').textContent = data.summary?.pending ?? 0;
|
||||
document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;
|
||||
|
||||
// ===== Device type counts =====
|
||||
// ===== 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';
|
||||
@@ -397,46 +717,26 @@ function updateDashboard(event) {
|
||||
seismoCount++;
|
||||
} else if (deviceType === 'sound_level_meter') {
|
||||
slmCount++;
|
||||
} else if (deviceType === 'modem') {
|
||||
modemCount++;
|
||||
}
|
||||
});
|
||||
document.getElementById('seismo-count').textContent = seismoCount;
|
||||
document.getElementById('slm-count').textContent = slmCount;
|
||||
|
||||
// ===== Alerts =====
|
||||
const alertsList = document.getElementById('alerts-list');
|
||||
// Only show alerts for deployed units that are MISSING (not pending)
|
||||
const missingUnits = Object.entries(data.active).filter(([_, u]) => u.status === 'Missing');
|
||||
|
||||
if (!missingUnits.length) {
|
||||
alertsList.innerHTML =
|
||||
'<p class="text-sm text-green-600 dark:text-green-400">✓ All units reporting normally</p>';
|
||||
} else {
|
||||
let alertsHtml = '';
|
||||
|
||||
missingUnits.forEach(([id, unit]) => {
|
||||
alertsHtml += `
|
||||
<div class="flex items-start space-x-2 text-sm">
|
||||
<span class="w-2 h-2 rounded-full bg-red-500 mt-1.5"></span>
|
||||
<div>
|
||||
<a href="/unit/${id}" class="font-medium text-red-600 dark:text-red-400 hover:underline">${id}</a>
|
||||
<p class="text-gray-600 dark:text-gray-400">Missing for ${unit.age}</p>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
alertsList.innerHTML = alertsHtml;
|
||||
}
|
||||
|
||||
// ===== Update Fleet Map =====
|
||||
updateFleetMap(data);
|
||||
// ===== Apply filters and render map + alerts =====
|
||||
renderFilteredDashboard(data);
|
||||
|
||||
} catch (err) {
|
||||
console.error("Dashboard update error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tab switching
|
||||
// Handle tab switching and initialize components
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Load filter preferences
|
||||
loadFilterPreferences();
|
||||
|
||||
const tabButtons = document.querySelectorAll('.tab-button');
|
||||
|
||||
tabButtons.forEach(button => {
|
||||
@@ -476,64 +776,6 @@ function initFleetMap() {
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function updateFleetMap(data) {
|
||||
if (!fleetMap) return;
|
||||
|
||||
// Clear existing markers
|
||||
fleetMarkers.forEach(marker => fleetMap.removeLayer(marker));
|
||||
fleetMarkers = [];
|
||||
|
||||
// Get deployed units with coordinates data
|
||||
const deployedUnits = Object.entries(data.units).filter(([_, u]) => u.deployed && u.coordinates);
|
||||
|
||||
if (deployedUnits.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = [];
|
||||
|
||||
deployedUnits.forEach(([id, unit]) => {
|
||||
const coords = parseLocation(unit.coordinates);
|
||||
if (coords) {
|
||||
const [lat, lon] = coords;
|
||||
|
||||
// Create marker with custom color based on status
|
||||
const markerColor = unit.status === 'OK' ? 'green' : unit.status === 'Pending' ? 'orange' : 'red';
|
||||
|
||||
const marker = L.circleMarker([lat, lon], {
|
||||
radius: 8,
|
||||
fillColor: markerColor,
|
||||
color: '#fff',
|
||||
weight: 2,
|
||||
opacity: 1,
|
||||
fillOpacity: 0.8
|
||||
}).addTo(fleetMap);
|
||||
|
||||
// Add popup with unit info
|
||||
marker.bindPopup(`
|
||||
<div class="p-2">
|
||||
<h3 class="font-bold text-lg">${id}</h3>
|
||||
<p class="text-sm">Status: <span style="color: ${markerColor}">${unit.status}</span></p>
|
||||
<p class="text-sm">Type: ${unit.device_type}</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>
|
||||
</div>
|
||||
`);
|
||||
|
||||
fleetMarkers.push(marker);
|
||||
bounds.push([lat, lon]);
|
||||
}
|
||||
});
|
||||
|
||||
// Fit map to show all markers
|
||||
if (bounds.length > 0) {
|
||||
// Use different padding for mobile vs desktop
|
||||
const padding = window.innerWidth < 768 ? [20, 20] : [50, 50];
|
||||
fleetMap.fitBounds(bounds, { padding: padding });
|
||||
fleetMapInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
function parseLocation(location) {
|
||||
if (!location) return null;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user