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;
|
||||
|
||||
|
||||
@@ -1154,11 +1154,20 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Check if any modal is currently open
|
||||
function isAnyModalOpen() {
|
||||
const modalIds = ['addUnitModal', 'editUnitModal', 'renameUnitModal', 'importModal', 'quickCreateProjectModal'];
|
||||
return modalIds.some(id => {
|
||||
const modal = document.getElementById(id);
|
||||
return modal && !modal.classList.contains('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Auto-refresh device list every 30 seconds (increased from 10s to reduce flicker)
|
||||
setInterval(() => {
|
||||
const deviceContent = document.getElementById('device-content');
|
||||
if (deviceContent && !document.querySelector('.modal:not(.hidden)')) {
|
||||
if (deviceContent && !isAnyModalOpen()) {
|
||||
// Only auto-refresh if no modal is open
|
||||
refreshDeviceList();
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button id="editButton" onclick="window.location.href='/roster?edit=' + unitId" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors flex items-center gap-2">
|
||||
<button id="editButton" onclick="enterEditMode()" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||
</svg>
|
||||
@@ -153,7 +153,12 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Deployed With Modem</label>
|
||||
<p id="viewDeployedWithModemId" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
||||
<p id="viewDeployedWithModemContainer" class="mt-1">
|
||||
<a id="viewDeployedWithModemLink" href="#" class="text-seismo-orange hover:text-orange-600 font-medium hover:underline hidden">
|
||||
<span id="viewDeployedWithModemText">--</span>
|
||||
</a>
|
||||
<span id="viewDeployedWithModemNoLink" class="text-gray-900 dark:text-white font-medium">--</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -336,8 +341,9 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
||||
<input type="text" name="deployed_with_modem_id" id="deployedWithModemId" placeholder="Modem ID"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
{% set picker_id = "-detail-seismo" %}
|
||||
{% set input_name = "deployed_with_modem_id" %}
|
||||
{% include "partials/modem_picker.html" with context %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -411,11 +417,9 @@
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
||||
<select name="deployed_with_modem_id" id="slmDeployedWithModemId"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
<option value="">No modem assigned</option>
|
||||
<!-- Options will be populated by JavaScript -->
|
||||
</select>
|
||||
{% set picker_id = "-detail-slm" %}
|
||||
{% set input_name = "deployed_with_modem_id" %}
|
||||
{% include "partials/modem_picker.html" with context %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Select the modem that provides network connectivity for this SLM</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -442,6 +446,54 @@
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Cascade to Paired Device Section -->
|
||||
<div id="detailCascadeSection" class="hidden border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<svg class="w-5 h-5 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
|
||||
</svg>
|
||||
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Also update paired device: <span id="detailPairedDeviceName" class="text-seismo-orange"></span>
|
||||
</span>
|
||||
</div>
|
||||
<input type="hidden" name="cascade_to_unit_id" id="detailCascadeToUnitId" value="">
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-2 bg-gray-50 dark:bg-slate-700/50 rounded-lg p-3">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="cascade_deployed" id="detailCascadeDeployed" value="true"
|
||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed status</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="cascade_retired" id="detailCascadeRetired" value="true"
|
||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Retired status</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="cascade_project" id="detailCascadeProject" value="true"
|
||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Project</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="cascade_location" id="detailCascadeLocation" value="true"
|
||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Address</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="cascade_coordinates" id="detailCascadeCoordinates" value="true"
|
||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Coordinates</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="cascade_note" id="detailCascadeNote" value="true"
|
||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Notes</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
Check the fields you want to sync to the paired device
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Save/Cancel Buttons -->
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" class="flex-1 px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium transition-colors">
|
||||
@@ -482,6 +534,44 @@ async function fetchProjectDisplay(projectId) {
|
||||
return projectId;
|
||||
}
|
||||
|
||||
// Fetch modem display name (combines id, ip_address, hardware_model)
|
||||
// Also returns the actual modem ID if found (for updating picker value)
|
||||
async function fetchModemDisplay(modemIdOrIp) {
|
||||
if (!modemIdOrIp) return { display: '', modemId: '' };
|
||||
try {
|
||||
// First try direct lookup by ID
|
||||
let response = await fetch(`/api/roster/${encodeURIComponent(modemIdOrIp)}`);
|
||||
if (response.ok) {
|
||||
const modem = await response.json();
|
||||
const parts = [modem.id];
|
||||
if (modem.ip_address) parts.push(`(${modem.ip_address})`);
|
||||
if (modem.hardware_model) parts.push(`- ${modem.hardware_model}`);
|
||||
return { display: parts.join(' ') || modemIdOrIp, modemId: modem.id };
|
||||
}
|
||||
|
||||
// If not found, maybe it's an IP address - search for it
|
||||
response = await fetch(`/api/roster/search/modems?q=${encodeURIComponent(modemIdOrIp)}`);
|
||||
if (response.ok) {
|
||||
// The search returns HTML, so we need to look up differently
|
||||
// Try fetching all modems and find by IP
|
||||
const modemsResponse = await fetch('/api/roster/modems');
|
||||
if (modemsResponse.ok) {
|
||||
const modems = await modemsResponse.json();
|
||||
const modem = modems.find(m => m.ip_address === modemIdOrIp);
|
||||
if (modem) {
|
||||
const parts = [modem.id];
|
||||
if (modem.ip_address) parts.push(`(${modem.ip_address})`);
|
||||
if (modem.hardware_model) parts.push(`- ${modem.hardware_model}`);
|
||||
return { display: parts.join(' ') || modemIdOrIp, modemId: modem.id };
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch modem:', e);
|
||||
}
|
||||
return { display: modemIdOrIp, modemId: modemIdOrIp };
|
||||
}
|
||||
|
||||
// Load unit data on page load
|
||||
async function loadUnitData() {
|
||||
try {
|
||||
@@ -634,7 +724,29 @@ function populateViewMode() {
|
||||
// Seismograph fields
|
||||
document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--';
|
||||
document.getElementById('viewNextCalibrationDue').textContent = currentUnit.next_calibration_due || '--';
|
||||
document.getElementById('viewDeployedWithModemId').textContent = currentUnit.deployed_with_modem_id || '--';
|
||||
|
||||
// Deployed with modem - show as clickable link
|
||||
const modemLink = document.getElementById('viewDeployedWithModemLink');
|
||||
const modemNoLink = document.getElementById('viewDeployedWithModemNoLink');
|
||||
const modemText = document.getElementById('viewDeployedWithModemText');
|
||||
|
||||
if (currentUnit.deployed_with_modem_id) {
|
||||
// Fetch modem info to get the actual ID (in case stored as IP) and display text
|
||||
fetchModemDisplay(currentUnit.deployed_with_modem_id).then(result => {
|
||||
if (modemText) modemText.textContent = result.display;
|
||||
if (modemLink) {
|
||||
modemLink.href = `/unit/${encodeURIComponent(result.modemId)}`;
|
||||
modemLink.classList.remove('hidden');
|
||||
}
|
||||
if (modemNoLink) modemNoLink.classList.add('hidden');
|
||||
});
|
||||
} else {
|
||||
if (modemNoLink) {
|
||||
modemNoLink.textContent = '--';
|
||||
modemNoLink.classList.remove('hidden');
|
||||
}
|
||||
if (modemLink) modemLink.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Modem fields
|
||||
document.getElementById('viewIpAddress').textContent = currentUnit.ip_address || '--';
|
||||
@@ -689,7 +801,24 @@ function populateEditForm() {
|
||||
// Seismograph fields
|
||||
document.getElementById('lastCalibrated').value = currentUnit.last_calibrated || '';
|
||||
document.getElementById('nextCalibrationDue').value = currentUnit.next_calibration_due || '';
|
||||
document.getElementById('deployedWithModemId').value = currentUnit.deployed_with_modem_id || '';
|
||||
|
||||
// Populate modem picker for seismograph (uses -detail-seismo suffix)
|
||||
const modemPickerValue = document.getElementById('modem-picker-value-detail-seismo');
|
||||
const modemPickerSearch = document.getElementById('modem-picker-search-detail-seismo');
|
||||
const modemPickerClear = document.getElementById('modem-picker-clear-detail-seismo');
|
||||
if (currentUnit.deployed_with_modem_id) {
|
||||
// Fetch modem display info (handles both modem ID and IP address lookups)
|
||||
fetchModemDisplay(currentUnit.deployed_with_modem_id).then(result => {
|
||||
// Update the hidden value with the actual modem ID (in case it was stored as IP)
|
||||
if (modemPickerValue) modemPickerValue.value = result.modemId || currentUnit.deployed_with_modem_id;
|
||||
if (modemPickerSearch) modemPickerSearch.value = result.display;
|
||||
if (modemPickerClear) modemPickerClear.classList.remove('hidden');
|
||||
});
|
||||
} else {
|
||||
if (modemPickerValue) modemPickerValue.value = '';
|
||||
if (modemPickerSearch) modemPickerSearch.value = '';
|
||||
if (modemPickerClear) modemPickerClear.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Modem fields
|
||||
document.getElementById('ipAddress').value = currentUnit.ip_address || '';
|
||||
@@ -703,10 +832,69 @@ function populateEditForm() {
|
||||
document.getElementById('slmFrequencyWeighting').value = currentUnit.slm_frequency_weighting || '';
|
||||
document.getElementById('slmTimeWeighting').value = currentUnit.slm_time_weighting || '';
|
||||
document.getElementById('slmMeasurementRange').value = currentUnit.slm_measurement_range || '';
|
||||
document.getElementById('slmDeployedWithModemId').value = currentUnit.deployed_with_modem_id || '';
|
||||
|
||||
// Populate modem picker for SLM (uses -detail-slm suffix)
|
||||
const slmModemPickerValue = document.getElementById('modem-picker-value-detail-slm');
|
||||
const slmModemPickerSearch = document.getElementById('modem-picker-search-detail-slm');
|
||||
const slmModemPickerClear = document.getElementById('modem-picker-clear-detail-slm');
|
||||
if (currentUnit.deployed_with_modem_id) {
|
||||
// Fetch modem display info (handles both modem ID and IP address lookups)
|
||||
fetchModemDisplay(currentUnit.deployed_with_modem_id).then(result => {
|
||||
// Update the hidden value with the actual modem ID (in case it was stored as IP)
|
||||
if (slmModemPickerValue) slmModemPickerValue.value = result.modemId || currentUnit.deployed_with_modem_id;
|
||||
if (slmModemPickerSearch) slmModemPickerSearch.value = result.display;
|
||||
if (slmModemPickerClear) slmModemPickerClear.classList.remove('hidden');
|
||||
});
|
||||
} else {
|
||||
if (slmModemPickerValue) slmModemPickerValue.value = '';
|
||||
if (slmModemPickerSearch) slmModemPickerSearch.value = '';
|
||||
if (slmModemPickerClear) slmModemPickerClear.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Show/hide fields based on device type
|
||||
toggleDetailFields();
|
||||
|
||||
// Check for paired device and show cascade section if applicable
|
||||
checkAndShowCascadeSection();
|
||||
}
|
||||
|
||||
// Check for paired device and show/hide cascade section
|
||||
async function checkAndShowCascadeSection() {
|
||||
const cascadeSection = document.getElementById('detailCascadeSection');
|
||||
const cascadeToUnitId = document.getElementById('detailCascadeToUnitId');
|
||||
const pairedDeviceName = document.getElementById('detailPairedDeviceName');
|
||||
|
||||
if (!cascadeSection) return;
|
||||
|
||||
// Reset cascade section
|
||||
cascadeSection.classList.add('hidden');
|
||||
if (cascadeToUnitId) cascadeToUnitId.value = '';
|
||||
if (pairedDeviceName) pairedDeviceName.textContent = '';
|
||||
|
||||
// Reset checkboxes
|
||||
['detailCascadeDeployed', 'detailCascadeRetired', 'detailCascadeProject',
|
||||
'detailCascadeLocation', 'detailCascadeCoordinates', 'detailCascadeNote'].forEach(id => {
|
||||
const checkbox = document.getElementById(id);
|
||||
if (checkbox) checkbox.checked = false;
|
||||
});
|
||||
|
||||
let pairedUnitId = null;
|
||||
|
||||
// Check based on device type
|
||||
if (currentUnit.device_type === 'modem' && currentUnit.deployed_with_unit_id) {
|
||||
// Modem is paired with a seismograph or SLM
|
||||
pairedUnitId = currentUnit.deployed_with_unit_id;
|
||||
} else if ((currentUnit.device_type === 'seismograph' || currentUnit.device_type === 'sound_level_meter') && currentUnit.deployed_with_modem_id) {
|
||||
// Seismograph or SLM is paired with a modem
|
||||
pairedUnitId = currentUnit.deployed_with_modem_id;
|
||||
}
|
||||
|
||||
if (pairedUnitId) {
|
||||
// Show cascade section
|
||||
cascadeSection.classList.remove('hidden');
|
||||
if (cascadeToUnitId) cascadeToUnitId.value = pairedUnitId;
|
||||
if (pairedDeviceName) pairedDeviceName.textContent = pairedUnitId;
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle device-specific fields
|
||||
|
||||
Reference in New Issue
Block a user