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:
@@ -2,20 +2,32 @@ from fastapi import APIRouter, Depends
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
import random
|
import random
|
||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.services.snapshot import emit_status_snapshot
|
from backend.services.snapshot import emit_status_snapshot
|
||||||
|
from backend.services.slm_status_sync import sync_slm_status_to_emitters
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["roster"])
|
router = APIRouter(prefix="/api", tags=["roster"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status-snapshot")
|
@router.get("/status-snapshot")
|
||||||
def get_status_snapshot(db: Session = Depends(get_db)):
|
async def get_status_snapshot(db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
Calls emit_status_snapshot() to get current fleet status.
|
Calls emit_status_snapshot() to get current fleet status.
|
||||||
This will be replaced with real Series3 emitter logic later.
|
Syncs SLM status from SLMM before generating snapshot.
|
||||||
"""
|
"""
|
||||||
|
# Sync SLM status from SLMM (with timeout to prevent blocking)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(sync_slm_status_to_emitters(), timeout=2.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning("SLM status sync timed out, using cached data")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"SLM status sync failed: {e}")
|
||||||
|
|
||||||
return emit_status_snapshot()
|
return emit_status_snapshot()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -167,23 +167,7 @@ async def get_live_view(request: Request, unit_id: str, db: Session = Depends(ge
|
|||||||
measurement_state = state_data.get("measurement_state", "Unknown")
|
measurement_state = state_data.get("measurement_state", "Unknown")
|
||||||
is_measuring = state_data.get("is_measuring", False)
|
is_measuring = state_data.get("is_measuring", False)
|
||||||
|
|
||||||
# If measuring, sync start time from FTP to database (fixes wrong timestamps)
|
# Get live status (measurement_start_time is already stored in SLMM database)
|
||||||
if is_measuring:
|
|
||||||
try:
|
|
||||||
sync_response = await client.post(
|
|
||||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/sync-start-time",
|
|
||||||
timeout=10.0
|
|
||||||
)
|
|
||||||
if sync_response.status_code == 200:
|
|
||||||
sync_data = sync_response.json()
|
|
||||||
logger.info(f"Synced start time for {unit_id}: {sync_data.get('message')}")
|
|
||||||
else:
|
|
||||||
logger.warning(f"Failed to sync start time for {unit_id}: {sync_response.status_code}")
|
|
||||||
except Exception as e:
|
|
||||||
# Don't fail the whole request if sync fails
|
|
||||||
logger.warning(f"Could not sync start time for {unit_id}: {e}")
|
|
||||||
|
|
||||||
# Get live status (now with corrected start time)
|
|
||||||
status_response = await client.get(
|
status_response = await client.get(
|
||||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live"
|
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live"
|
||||||
)
|
)
|
||||||
|
|||||||
125
backend/services/slm_status_sync.py
Normal file
125
backend/services/slm_status_sync.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"""
|
||||||
|
SLM Status Synchronization Service
|
||||||
|
|
||||||
|
Syncs SLM device status from SLMM backend to Terra-View's Emitter table.
|
||||||
|
This bridges SLMM's polling data with Terra-View's status snapshot system.
|
||||||
|
|
||||||
|
SLMM tracks device reachability via background polling. This service
|
||||||
|
fetches that data and creates/updates Emitter records so SLMs appear
|
||||||
|
correctly in the dashboard status snapshot.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from backend.database import get_db_session
|
||||||
|
from backend.models import Emitter
|
||||||
|
from backend.services.slmm_client import get_slmm_client, SLMMClientError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_slm_status_to_emitters() -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Fetch SLM status from SLMM and sync to Terra-View's Emitter table.
|
||||||
|
|
||||||
|
For each device in SLMM's polling status:
|
||||||
|
- If last_success exists, create/update Emitter with that timestamp
|
||||||
|
- If not reachable, update Emitter with last known timestamp (or None)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with synced_count, error_count, errors list
|
||||||
|
"""
|
||||||
|
client = get_slmm_client()
|
||||||
|
synced = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get polling status from SLMM
|
||||||
|
status_response = await client.get_polling_status()
|
||||||
|
|
||||||
|
# Handle nested response structure
|
||||||
|
data = status_response.get("data", status_response)
|
||||||
|
devices = data.get("devices", [])
|
||||||
|
|
||||||
|
if not devices:
|
||||||
|
logger.debug("No SLM devices in SLMM polling status")
|
||||||
|
return {"synced_count": 0, "error_count": 0, "errors": []}
|
||||||
|
|
||||||
|
db = get_db_session()
|
||||||
|
try:
|
||||||
|
for device in devices:
|
||||||
|
unit_id = device.get("unit_id")
|
||||||
|
if not unit_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get or create Emitter record
|
||||||
|
emitter = db.query(Emitter).filter(Emitter.id == unit_id).first()
|
||||||
|
|
||||||
|
# Determine last_seen from SLMM data
|
||||||
|
last_success_str = device.get("last_success")
|
||||||
|
is_reachable = device.get("is_reachable", False)
|
||||||
|
|
||||||
|
if last_success_str:
|
||||||
|
# Parse ISO format timestamp
|
||||||
|
last_seen = datetime.fromisoformat(
|
||||||
|
last_success_str.replace("Z", "+00:00")
|
||||||
|
)
|
||||||
|
# Convert to naive UTC for consistency with existing code
|
||||||
|
if last_seen.tzinfo:
|
||||||
|
last_seen = last_seen.astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
else:
|
||||||
|
last_seen = None
|
||||||
|
|
||||||
|
# Status will be recalculated by snapshot.py based on time thresholds
|
||||||
|
# Just store a provisional status here
|
||||||
|
status = "OK" if is_reachable else "Missing"
|
||||||
|
|
||||||
|
# Store last error message if available
|
||||||
|
last_error = device.get("last_error") or ""
|
||||||
|
|
||||||
|
if emitter:
|
||||||
|
# Update existing record
|
||||||
|
emitter.last_seen = last_seen
|
||||||
|
emitter.status = status
|
||||||
|
emitter.unit_type = "slm"
|
||||||
|
emitter.last_file = last_error
|
||||||
|
else:
|
||||||
|
# Create new record
|
||||||
|
emitter = Emitter(
|
||||||
|
id=unit_id,
|
||||||
|
unit_type="slm",
|
||||||
|
last_seen=last_seen,
|
||||||
|
last_file=last_error,
|
||||||
|
status=status
|
||||||
|
)
|
||||||
|
db.add(emitter)
|
||||||
|
|
||||||
|
synced += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"{unit_id}: {str(e)}")
|
||||||
|
logger.error(f"Error syncing SLM {unit_id}: {e}")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if synced > 0:
|
||||||
|
logger.info(f"Synced {synced} SLM device(s) to Emitter table")
|
||||||
|
|
||||||
|
except SLMMClientError as e:
|
||||||
|
logger.warning(f"Could not reach SLMM for status sync: {e}")
|
||||||
|
errors.append(f"SLMM unreachable: {str(e)}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in SLM status sync: {e}", exc_info=True)
|
||||||
|
errors.append(str(e))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"synced_count": synced,
|
||||||
|
"error_count": len(errors),
|
||||||
|
"errors": errors
|
||||||
|
}
|
||||||
@@ -146,6 +146,22 @@ def emit_status_snapshot():
|
|||||||
"coordinates": "",
|
"coordinates": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- Derive modem status from paired devices ---
|
||||||
|
# Modems don't have their own check-in system, so we inherit status
|
||||||
|
# from whatever device they're paired with (seismograph or SLM)
|
||||||
|
for unit_id, unit_data in units.items():
|
||||||
|
if unit_data.get("device_type") == "modem" and not unit_data.get("retired"):
|
||||||
|
roster_unit = roster.get(unit_id)
|
||||||
|
if roster_unit and roster_unit.deployed_with_unit_id:
|
||||||
|
paired_unit_id = roster_unit.deployed_with_unit_id
|
||||||
|
paired_unit = units.get(paired_unit_id)
|
||||||
|
if paired_unit:
|
||||||
|
# Inherit status from paired device
|
||||||
|
unit_data["status"] = paired_unit.get("status", "Missing")
|
||||||
|
unit_data["age"] = paired_unit.get("age", "N/A")
|
||||||
|
unit_data["last"] = paired_unit.get("last")
|
||||||
|
unit_data["derived_from"] = paired_unit_id
|
||||||
|
|
||||||
# Separate buckets for UI
|
# Separate buckets for UI
|
||||||
active_units = {
|
active_units = {
|
||||||
uid: u for uid, u in units.items()
|
uid: u for uid, u in units.items()
|
||||||
|
|||||||
@@ -187,6 +187,68 @@
|
|||||||
|
|
||||||
</div>
|
</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 -->
|
<!-- Fleet Map -->
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6 mb-8" id="fleet-map-card">
|
<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')">
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-map')">
|
||||||
@@ -302,6 +364,254 @@
|
|||||||
|
|
||||||
|
|
||||||
<script>
|
<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)
|
// Toggle card collapse/expand (mobile only)
|
||||||
function toggleCard(cardName) {
|
function toggleCard(cardName) {
|
||||||
// Only work on mobile
|
// Only work on mobile
|
||||||
@@ -366,8 +676,17 @@ if (document.readyState === 'loading') {
|
|||||||
|
|
||||||
function updateDashboard(event) {
|
function updateDashboard(event) {
|
||||||
try {
|
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);
|
const data = JSON.parse(event.detail.xhr.response);
|
||||||
|
|
||||||
|
// Store data for filter re-application
|
||||||
|
currentSnapshotData = data;
|
||||||
|
|
||||||
// Update "Last updated" timestamp with timezone
|
// Update "Last updated" timestamp with timezone
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const timezone = localStorage.getItem('timezone') || 'America/New_York';
|
const timezone = localStorage.getItem('timezone') || 'America/New_York';
|
||||||
@@ -379,7 +698,7 @@ function updateDashboard(event) {
|
|||||||
timeZoneName: 'short'
|
timeZoneName: 'short'
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== Fleet summary numbers =====
|
// ===== Fleet summary numbers (always unfiltered) =====
|
||||||
document.getElementById('total-units').textContent = data.summary?.total ?? 0;
|
document.getElementById('total-units').textContent = data.summary?.total ?? 0;
|
||||||
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;
|
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;
|
||||||
document.getElementById('benched-units').textContent = data.summary?.benched ?? 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-pending').textContent = data.summary?.pending ?? 0;
|
||||||
document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;
|
document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;
|
||||||
|
|
||||||
// ===== Device type counts =====
|
// ===== Device type counts (always unfiltered) =====
|
||||||
let seismoCount = 0;
|
let seismoCount = 0;
|
||||||
let slmCount = 0;
|
let slmCount = 0;
|
||||||
|
let modemCount = 0;
|
||||||
Object.values(data.units || {}).forEach(unit => {
|
Object.values(data.units || {}).forEach(unit => {
|
||||||
if (unit.retired) return; // Don't count retired units
|
if (unit.retired) return; // Don't count retired units
|
||||||
const deviceType = unit.device_type || 'seismograph';
|
const deviceType = unit.device_type || 'seismograph';
|
||||||
@@ -397,46 +717,26 @@ function updateDashboard(event) {
|
|||||||
seismoCount++;
|
seismoCount++;
|
||||||
} else if (deviceType === 'sound_level_meter') {
|
} else if (deviceType === 'sound_level_meter') {
|
||||||
slmCount++;
|
slmCount++;
|
||||||
|
} else if (deviceType === 'modem') {
|
||||||
|
modemCount++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
document.getElementById('seismo-count').textContent = seismoCount;
|
document.getElementById('seismo-count').textContent = seismoCount;
|
||||||
document.getElementById('slm-count').textContent = slmCount;
|
document.getElementById('slm-count').textContent = slmCount;
|
||||||
|
|
||||||
// ===== Alerts =====
|
// ===== Apply filters and render map + alerts =====
|
||||||
const alertsList = document.getElementById('alerts-list');
|
renderFilteredDashboard(data);
|
||||||
// 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);
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Dashboard update error:", err);
|
console.error("Dashboard update error:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle tab switching
|
// Handle tab switching and initialize components
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Load filter preferences
|
||||||
|
loadFilterPreferences();
|
||||||
|
|
||||||
const tabButtons = document.querySelectorAll('.tab-button');
|
const tabButtons = document.querySelectorAll('.tab-button');
|
||||||
|
|
||||||
tabButtons.forEach(button => {
|
tabButtons.forEach(button => {
|
||||||
@@ -476,64 +776,6 @@ function initFleetMap() {
|
|||||||
}, 100);
|
}, 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) {
|
function parseLocation(location) {
|
||||||
if (!location) return null;
|
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() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Auto-refresh device list every 30 seconds (increased from 10s to reduce flicker)
|
// Auto-refresh device list every 30 seconds (increased from 10s to reduce flicker)
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const deviceContent = document.getElementById('device-content');
|
const deviceContent = document.getElementById('device-content');
|
||||||
if (deviceContent && !document.querySelector('.modal:not(.hidden)')) {
|
if (deviceContent && !isAnyModalOpen()) {
|
||||||
// Only auto-refresh if no modal is open
|
// Only auto-refresh if no modal is open
|
||||||
refreshDeviceList();
|
refreshDeviceList();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3">
|
<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">
|
<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>
|
<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>
|
</svg>
|
||||||
@@ -153,7 +153,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Deployed With Modem</label>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -336,8 +341,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
<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"
|
{% set picker_id = "-detail-seismo" %}
|
||||||
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 input_name = "deployed_with_modem_id" %}
|
||||||
|
{% include "partials/modem_picker.html" with context %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -411,11 +417,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="md:col-span-2">
|
<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>
|
<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"
|
{% set picker_id = "-detail-slm" %}
|
||||||
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 input_name = "deployed_with_modem_id" %}
|
||||||
<option value="">No modem assigned</option>
|
{% include "partials/modem_picker.html" with context %}
|
||||||
<!-- Options will be populated by JavaScript -->
|
|
||||||
</select>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Select the modem that provides network connectivity for this SLM</p>
|
<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>
|
||||||
</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>
|
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>
|
</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 -->
|
<!-- Save/Cancel Buttons -->
|
||||||
<div class="flex gap-3">
|
<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">
|
<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;
|
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
|
// Load unit data on page load
|
||||||
async function loadUnitData() {
|
async function loadUnitData() {
|
||||||
try {
|
try {
|
||||||
@@ -634,7 +724,29 @@ function populateViewMode() {
|
|||||||
// Seismograph fields
|
// Seismograph fields
|
||||||
document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--';
|
document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--';
|
||||||
document.getElementById('viewNextCalibrationDue').textContent = currentUnit.next_calibration_due || '--';
|
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
|
// Modem fields
|
||||||
document.getElementById('viewIpAddress').textContent = currentUnit.ip_address || '--';
|
document.getElementById('viewIpAddress').textContent = currentUnit.ip_address || '--';
|
||||||
@@ -689,7 +801,24 @@ function populateEditForm() {
|
|||||||
// Seismograph fields
|
// Seismograph fields
|
||||||
document.getElementById('lastCalibrated').value = currentUnit.last_calibrated || '';
|
document.getElementById('lastCalibrated').value = currentUnit.last_calibrated || '';
|
||||||
document.getElementById('nextCalibrationDue').value = currentUnit.next_calibration_due || '';
|
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
|
// Modem fields
|
||||||
document.getElementById('ipAddress').value = currentUnit.ip_address || '';
|
document.getElementById('ipAddress').value = currentUnit.ip_address || '';
|
||||||
@@ -703,10 +832,69 @@ function populateEditForm() {
|
|||||||
document.getElementById('slmFrequencyWeighting').value = currentUnit.slm_frequency_weighting || '';
|
document.getElementById('slmFrequencyWeighting').value = currentUnit.slm_frequency_weighting || '';
|
||||||
document.getElementById('slmTimeWeighting').value = currentUnit.slm_time_weighting || '';
|
document.getElementById('slmTimeWeighting').value = currentUnit.slm_time_weighting || '';
|
||||||
document.getElementById('slmMeasurementRange').value = currentUnit.slm_measurement_range || '';
|
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
|
// Show/hide fields based on device type
|
||||||
toggleDetailFields();
|
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
|
// Toggle device-specific fields
|
||||||
|
|||||||
Reference in New Issue
Block a user