Fix: - Polling intervals for SLMM. -modem view now list - device pairing much improved. -various other tweaks through out UI.
1641 lines
81 KiB
HTML
1641 lines
81 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Unit Detail - Seismo Fleet Manager{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="mb-6">
|
|
<!-- Breadcrumb Navigation -->
|
|
<nav class="flex mb-4 text-sm" aria-label="Breadcrumb">
|
|
<ol class="inline-flex items-center space-x-1 md:space-x-3">
|
|
<li class="inline-flex items-center">
|
|
<a href="/" class="text-gray-500 hover:text-seismo-orange dark:text-gray-400">
|
|
Dashboard
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<div class="flex items-center">
|
|
<svg class="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path>
|
|
</svg>
|
|
<a href="/roster" class="ml-1 text-gray-500 hover:text-seismo-orange dark:text-gray-400">
|
|
Fleet Roster
|
|
</a>
|
|
</div>
|
|
</li>
|
|
<li aria-current="page">
|
|
<div class="flex items-center">
|
|
<svg class="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path>
|
|
</svg>
|
|
<span class="ml-1 text-gray-700 dark:text-gray-300 font-medium" id="breadcrumb-unit">Unit</span>
|
|
</div>
|
|
</li>
|
|
</ol>
|
|
</nav>
|
|
|
|
<div class="flex justify-between items-center">
|
|
<div class="flex items-center gap-2">
|
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white" id="pageTitle">Loading...</h1>
|
|
<button onclick="copyToClipboard(window.currentUnitId, this)" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors p-1" title="Copy Unit ID">
|
|
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="flex gap-3">
|
|
<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>
|
|
Edit Unit
|
|
</button>
|
|
<button onclick="deleteUnit()" class="px-4 py-2 bg-red-600 hover:bg-red-700 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
|
</svg>
|
|
Delete Unit
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading state -->
|
|
<div id="loadingState" class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-12 text-center">
|
|
<div class="animate-pulse">
|
|
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4 mx-auto mb-4"></div>
|
|
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2 mx-auto"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main content (hidden until loaded) -->
|
|
<div id="mainContent" class="hidden space-y-6">
|
|
<!-- Status Card -->
|
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
|
<div class="flex justify-between items-start mb-6">
|
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Status</h2>
|
|
<div class="flex items-center space-x-2">
|
|
<span id="statusIndicator" class="w-3 h-3 rounded-full"></span>
|
|
<span id="statusText" class="font-semibold"></span>
|
|
</div>
|
|
</div>
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<div>
|
|
<span class="text-sm text-gray-500 dark:text-gray-400">Last Seen</span>
|
|
<p id="lastSeen" class="font-medium text-gray-900 dark:text-white">--</p>
|
|
</div>
|
|
<div>
|
|
<span class="text-sm text-gray-500 dark:text-gray-400">Age</span>
|
|
<p id="age" class="font-medium text-gray-900 dark:text-white">--</p>
|
|
</div>
|
|
<div>
|
|
<span class="text-sm text-gray-500 dark:text-gray-400">Deployed</span>
|
|
<p id="deployedStatus" class="font-medium text-gray-900 dark:text-white">--</p>
|
|
</div>
|
|
<div>
|
|
<span class="text-sm text-gray-500 dark:text-gray-400">Retired</span>
|
|
<p id="retiredStatus" class="font-medium text-gray-900 dark:text-white">--</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Location Map -->
|
|
<div id="mapCard" class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6 hidden">
|
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Location</h2>
|
|
<div id="unit-map" style="height: 400px; width: 100%;" class="rounded-lg mb-4"></div>
|
|
<p id="locationText" class="text-sm text-gray-500 dark:text-gray-400"></p>
|
|
</div>
|
|
|
|
<!-- View Mode: Unit Information (Default) -->
|
|
<div id="viewMode" class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Unit Information</h2>
|
|
|
|
<div class="space-y-6">
|
|
<!-- Basic Info Grid -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div>
|
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Device Type</label>
|
|
<p id="viewDeviceType" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
|
</div>
|
|
<div>
|
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Unit Type</label>
|
|
<p id="viewUnitType" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
|
</div>
|
|
<div>
|
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Project</label>
|
|
<p id="viewProjectContainer" class="mt-1">
|
|
<a id="viewProjectLink" href="#" class="text-seismo-orange hover:text-orange-600 font-medium hover:underline hidden">
|
|
<span id="viewProjectText">--</span>
|
|
</a>
|
|
<span id="viewProjectNoLink" class="text-gray-900 dark:text-white font-medium">Not assigned</span>
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Address</label>
|
|
<p id="viewAddress" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
|
</div>
|
|
<div class="md:col-span-2">
|
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Coordinates</label>
|
|
<p id="viewCoordinates" class="mt-1 text-gray-900 dark:text-white font-medium font-mono text-sm">--</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Seismograph Info -->
|
|
<div id="viewSeismographFields" class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Seismograph Information</h3>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div>
|
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Last Calibrated</label>
|
|
<p id="viewLastCalibrated" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
|
</div>
|
|
<div>
|
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Next Calibration Due</label>
|
|
<p id="viewNextCalibrationDue" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
|
</div>
|
|
<div>
|
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Deployed With Modem</label>
|
|
<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>
|
|
|
|
<!-- Modem Info -->
|
|
<div id="viewModemFields" class="hidden border-t border-gray-200 dark:border-gray-700 pt-6">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Modem Information</h3>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div>
|
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">IP Address</label>
|
|
<p id="viewIpAddress" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
|
</div>
|
|
<div>
|
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Phone Number</label>
|
|
<p id="viewPhoneNumber" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
|
</div>
|
|
<div>
|
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Hardware Model</label>
|
|
<p id="viewHardwareModel" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Paired Device (for modems only) -->
|
|
<div id="viewPairedDeviceSection" class="hidden border-t border-gray-200 dark:border-gray-700 pt-6">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Paired Device</h3>
|
|
<div id="pairedDeviceInfo">
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Connectivity (for modems only) -->
|
|
<div id="viewConnectivitySection" class="hidden border-t border-gray-200 dark:border-gray-700 pt-6">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Connectivity</h3>
|
|
<div class="flex items-center gap-4 mb-4">
|
|
<button onclick="pingModem()" id="modemPingBtn"
|
|
class="px-4 py-2 bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 dark:text-blue-300 rounded-lg flex items-center gap-2 transition-colors">
|
|
<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="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
|
</svg>
|
|
Ping Test
|
|
</button>
|
|
<span id="modemPingResult" class="text-sm text-gray-500 dark:text-gray-400">--</span>
|
|
</div>
|
|
|
|
<!-- Future Diagnostics Placeholders -->
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 opacity-60">
|
|
<div class="bg-gray-100 dark:bg-gray-700 rounded-lg p-4">
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">Signal Strength</p>
|
|
<p class="text-xl font-semibold text-gray-400 dark:text-gray-500">-- dBm</p>
|
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">ModemManager pending</p>
|
|
</div>
|
|
<div class="bg-gray-100 dark:bg-gray-700 rounded-lg p-4">
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">Data Usage</p>
|
|
<p class="text-xl font-semibold text-gray-400 dark:text-gray-500">-- MB</p>
|
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">ModemManager pending</p>
|
|
</div>
|
|
<div class="bg-gray-100 dark:bg-gray-700 rounded-lg p-4">
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">Uptime</p>
|
|
<p class="text-xl font-semibold text-gray-400 dark:text-gray-500">--</p>
|
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">ModemManager pending</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Notes -->
|
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Notes</label>
|
|
<p id="viewNote" class="mt-1 text-gray-900 dark:text-white whitespace-pre-wrap">--</p>
|
|
</div>
|
|
|
|
<!-- Unit History Timeline -->
|
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Timeline</h3>
|
|
<div id="historyTimeline" class="space-y-3">
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">Loading history...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Photos -->
|
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
|
<div class="flex justify-between items-start mb-4">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Photos</h3>
|
|
<div class="flex flex-col sm:flex-row gap-2">
|
|
<!-- Take Photo Button (Camera) -->
|
|
<label for="photoCameraUpload" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors cursor-pointer flex items-center gap-2 text-sm sm:text-base">
|
|
<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="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"></path>
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
|
</svg>
|
|
<span class="hidden sm:inline">Take Photo</span>
|
|
<span class="sm:hidden">Camera</span>
|
|
</label>
|
|
<!-- Choose from Library Button -->
|
|
<label for="photoLibraryUpload" class="px-4 py-2 bg-seismo-navy hover:bg-blue-800 text-white rounded-lg transition-colors cursor-pointer flex items-center gap-2 text-sm sm:text-base">
|
|
<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="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
|
</svg>
|
|
<span class="hidden sm:inline">Choose Photo</span>
|
|
<span class="sm:hidden">Library</span>
|
|
</label>
|
|
<input type="file" id="photoCameraUpload" accept="image/*" capture="environment" class="hidden" onchange="uploadPhoto(this.files[0])">
|
|
<input type="file" id="photoLibraryUpload" accept="image/*" class="hidden" onchange="uploadPhoto(this.files[0])">
|
|
</div>
|
|
</div>
|
|
<div id="photoGallery" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
|
<p class="text-sm text-gray-500 dark:text-gray-400 col-span-full">Loading photos...</p>
|
|
</div>
|
|
<div id="uploadStatus" class="hidden mt-4 p-4 rounded-lg"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Edit Mode: Unit Information Form (Hidden by default) -->
|
|
<div id="editMode" class="hidden rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
|
<div class="flex justify-between items-center mb-6">
|
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Edit Unit Information</h2>
|
|
<button onclick="cancelEdit()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<form id="editForm" class="space-y-6">
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<!-- Device Type -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Device Type</label>
|
|
<select name="device_type" id="deviceType" onchange="toggleDetailFields()"
|
|
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="seismograph">Seismograph</option>
|
|
<option value="modem">Modem</option>
|
|
<option value="sound_level_meter">Sound Level Meter</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Unit Type -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Unit Type</label>
|
|
<input type="text" name="unit_type" id="unitType"
|
|
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">
|
|
</div>
|
|
|
|
<!-- Project -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project</label>
|
|
{% set picker_id = "-detail" %}
|
|
{% include "partials/project_picker.html" with context %}
|
|
</div>
|
|
|
|
<!-- Address -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
|
|
<input type="text" name="address" id="address" placeholder="123 Main St, City, State"
|
|
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">
|
|
</div>
|
|
|
|
<!-- Coordinates -->
|
|
<div class="md:col-span-2">
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Coordinates</label>
|
|
<input type="text" name="coordinates" id="coordinates" placeholder="34.0522,-118.2437"
|
|
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 font-mono">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Seismograph Fields -->
|
|
<div id="seismographFields" class="space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Seismograph Information</h3>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Last Calibrated</label>
|
|
<input type="date" name="last_calibrated" id="lastCalibrated"
|
|
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">
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Next Calibration Due</label>
|
|
<input type="date" name="next_calibration_due" id="nextCalibrationDue"
|
|
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">
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
|
<div class="flex gap-2">
|
|
<div class="flex-1">
|
|
{% set picker_id = "-detail-seismo" %}
|
|
{% set input_name = "deployed_with_modem_id" %}
|
|
{% include "partials/modem_picker.html" with context %}
|
|
</div>
|
|
<button type="button" onclick="openPairDeviceModal('seismograph')"
|
|
class="px-3 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors flex items-center gap-1"
|
|
title="Pair with modem">
|
|
<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="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>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modem Fields -->
|
|
<div id="modemFields" class="hidden space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Modem Information</h3>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">IP Address</label>
|
|
<input type="text" name="ip_address" id="ipAddress" placeholder="192.168.1.100"
|
|
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">
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Phone Number</label>
|
|
<input type="text" name="phone_number" id="phoneNumber" placeholder="+1-555-0123"
|
|
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">
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Hardware Model</label>
|
|
<input type="text" name="hardware_model" id="hardwareModel" placeholder="e.g., Raven XTV"
|
|
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">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sound Level Meter Fields -->
|
|
<div id="slmFields" class="hidden space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Sound Level Meter Information</h3>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Model</label>
|
|
<input type="text" name="slm_model" id="slmModel" placeholder="NL-43, NL-53, etc."
|
|
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">
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Serial Number</label>
|
|
<input type="text" name="slm_serial_number" id="slmSerialNumber" placeholder="123456"
|
|
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">
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Frequency Weighting</label>
|
|
<select name="slm_frequency_weighting" id="slmFrequencyWeighting"
|
|
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="">Not set</option>
|
|
<option value="A">A-weighting</option>
|
|
<option value="C">C-weighting</option>
|
|
<option value="Z">Z-weighting (Flat)</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Time Weighting</label>
|
|
<select name="slm_time_weighting" id="slmTimeWeighting"
|
|
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="">Not set</option>
|
|
<option value="F">Fast (125ms)</option>
|
|
<option value="S">Slow (1s)</option>
|
|
<option value="I">Impulse (35ms)</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Measurement Range</label>
|
|
<input type="text" name="slm_measurement_range" id="slmMeasurementRange" placeholder="30-130 dB"
|
|
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">
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">TCP Port (on modem)</label>
|
|
<input type="number" name="slm_tcp_port" id="slmTcpPort" placeholder="2255"
|
|
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">
|
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Default: 2255</p>
|
|
</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>
|
|
<div class="flex gap-2">
|
|
<div class="flex-1">
|
|
{% set picker_id = "-detail-slm" %}
|
|
{% set input_name = "deployed_with_modem_id" %}
|
|
{% include "partials/modem_picker.html" with context %}
|
|
</div>
|
|
<button type="button" onclick="openPairDeviceModal('sound_level_meter')"
|
|
class="px-3 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors flex items-center gap-1"
|
|
title="Pair with modem">
|
|
<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="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>
|
|
</button>
|
|
</div>
|
|
<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>
|
|
|
|
<!-- Checkboxes -->
|
|
<div class="flex items-center gap-6 border-t border-gray-200 dark:border-gray-700 pt-4">
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" name="deployed" id="deployed" 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</span>
|
|
</label>
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" name="retired" id="retired" 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</span>
|
|
</label>
|
|
</div>
|
|
|
|
<!-- Notes -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
|
|
<textarea name="note" id="note" rows="4"
|
|
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">
|
|
Save Changes
|
|
</button>
|
|
<button type="button" onclick="cancelEdit()" class="px-6 py-3 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 text-gray-700 dark:text-white rounded-lg font-medium transition-colors">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const unitId = "{{ unit_id }}";
|
|
let currentUnit = null;
|
|
let currentSnapshot = null;
|
|
let unitMap = null;
|
|
let mapMarker = null;
|
|
|
|
// Fetch project display name (combines project_number, client_name, name)
|
|
async function fetchProjectDisplay(projectId) {
|
|
if (!projectId) return '';
|
|
try {
|
|
const response = await fetch(`/api/projects/${projectId}`);
|
|
if (response.ok) {
|
|
const project = await response.json();
|
|
const parts = [
|
|
project.project_number,
|
|
project.client_name,
|
|
project.name
|
|
].filter(Boolean);
|
|
return parts.join(' - ') || projectId;
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to fetch project:', e);
|
|
}
|
|
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 {
|
|
// Fetch unit roster data
|
|
const rosterResponse = await fetch(`/api/roster/${unitId}`);
|
|
if (!rosterResponse.ok) {
|
|
throw new Error('Unit not found');
|
|
}
|
|
currentUnit = await rosterResponse.json();
|
|
|
|
// Fetch snapshot data for status info
|
|
const snapshotResponse = await fetch('/api/status-snapshot');
|
|
if (snapshotResponse.ok) {
|
|
currentSnapshot = await snapshotResponse.json();
|
|
}
|
|
|
|
// Load modems list for dropdown
|
|
await loadModemsList();
|
|
|
|
// Populate views
|
|
populateViewMode();
|
|
populateEditForm();
|
|
|
|
// Hide loading, show content
|
|
document.getElementById('loadingState').classList.add('hidden');
|
|
document.getElementById('mainContent').classList.remove('hidden');
|
|
|
|
// Initialize map after content is visible
|
|
setTimeout(() => {
|
|
initUnitMap();
|
|
}, 100);
|
|
} catch (error) {
|
|
alert(`Error loading unit: ${error.message}`);
|
|
window.location.href = '/roster';
|
|
}
|
|
}
|
|
|
|
// Load list of modems for dropdown
|
|
async function loadModemsList() {
|
|
try {
|
|
const response = await fetch('/api/roster/modems');
|
|
if (response.ok) {
|
|
const modems = await response.json();
|
|
|
|
// Populate both seismograph and SLM modem dropdowns
|
|
const seismoDropdown = document.getElementById('deployedWithModemId');
|
|
const slmDropdown = document.getElementById('slmDeployedWithModemId');
|
|
|
|
// Clear existing options (except the first "No modem" option)
|
|
[seismoDropdown, slmDropdown].forEach(dropdown => {
|
|
if (!dropdown) return;
|
|
while (dropdown.options.length > 1) {
|
|
dropdown.remove(1);
|
|
}
|
|
|
|
// Add modem options
|
|
modems.forEach(modem => {
|
|
const option = document.createElement('option');
|
|
option.value = modem.id;
|
|
option.textContent = `${modem.id}${modem.ip_address ? ' (' + modem.ip_address + ')' : ''}${modem.hardware_model ? ' - ' + modem.hardware_model : ''}`;
|
|
dropdown.appendChild(option);
|
|
});
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load modems list:', error);
|
|
}
|
|
}
|
|
|
|
// Populate view mode (read-only display)
|
|
function populateViewMode() {
|
|
// Update page title and store unit ID for copy function
|
|
window.currentUnitId = currentUnit.id;
|
|
document.getElementById('pageTitle').textContent = `Unit ${currentUnit.id}`;
|
|
document.getElementById('breadcrumb-unit').textContent = currentUnit.id;
|
|
|
|
// Get status info from snapshot
|
|
let unitStatus = null;
|
|
if (currentSnapshot && currentSnapshot.units) {
|
|
unitStatus = currentSnapshot.units[unitId];
|
|
}
|
|
|
|
// Status card
|
|
if (unitStatus) {
|
|
const statusColors = {
|
|
'OK': 'bg-green-500',
|
|
'Pending': 'bg-yellow-500',
|
|
'Missing': 'bg-red-500'
|
|
};
|
|
const statusTextColors = {
|
|
'OK': 'text-green-600 dark:text-green-400',
|
|
'Pending': 'text-yellow-600 dark:text-yellow-400',
|
|
'Missing': 'text-red-600 dark:text-red-400'
|
|
};
|
|
|
|
document.getElementById('statusIndicator').className = `w-3 h-3 rounded-full ${statusColors[unitStatus.status] || 'bg-gray-400'}`;
|
|
document.getElementById('statusText').className = `font-semibold ${statusTextColors[unitStatus.status] || 'text-gray-600'}`;
|
|
document.getElementById('statusText').textContent = unitStatus.status || 'Unknown';
|
|
|
|
// Format "Last Seen" with timezone-aware formatting
|
|
if (unitStatus.last && typeof formatFullTimestamp === 'function') {
|
|
document.getElementById('lastSeen').textContent = formatFullTimestamp(unitStatus.last);
|
|
} else {
|
|
document.getElementById('lastSeen').textContent = unitStatus.last || '--';
|
|
}
|
|
|
|
document.getElementById('age').textContent = unitStatus.age || '--';
|
|
} else {
|
|
document.getElementById('statusIndicator').className = 'w-3 h-3 rounded-full bg-gray-400';
|
|
document.getElementById('statusText').className = 'font-semibold text-gray-600 dark:text-gray-400';
|
|
document.getElementById('statusText').textContent = 'No status data';
|
|
document.getElementById('lastSeen').textContent = '--';
|
|
document.getElementById('age').textContent = '--';
|
|
}
|
|
|
|
document.getElementById('deployedStatus').textContent = currentUnit.deployed ? 'Yes' : 'No';
|
|
document.getElementById('retiredStatus').textContent = currentUnit.retired ? 'Yes' : 'No';
|
|
|
|
// Basic info
|
|
document.getElementById('viewDeviceType').textContent = currentUnit.device_type || '--';
|
|
document.getElementById('viewUnitType').textContent = currentUnit.unit_type || '--';
|
|
|
|
// Project display with clickable link
|
|
const projectId = currentUnit.project_id;
|
|
const projectLink = document.getElementById('viewProjectLink');
|
|
const projectNoLink = document.getElementById('viewProjectNoLink');
|
|
const projectText = document.getElementById('viewProjectText');
|
|
|
|
if (projectId) {
|
|
// Fetch project display name and show link
|
|
fetchProjectDisplay(projectId).then(displayText => {
|
|
if (projectText) projectText.textContent = displayText;
|
|
if (projectLink) {
|
|
projectLink.href = `/projects/${projectId}`;
|
|
projectLink.classList.remove('hidden');
|
|
}
|
|
if (projectNoLink) projectNoLink.classList.add('hidden');
|
|
});
|
|
} else {
|
|
if (projectNoLink) {
|
|
projectNoLink.textContent = 'Not assigned';
|
|
projectNoLink.classList.remove('hidden');
|
|
}
|
|
if (projectLink) projectLink.classList.add('hidden');
|
|
}
|
|
|
|
document.getElementById('viewAddress').textContent = currentUnit.address || '--';
|
|
document.getElementById('viewCoordinates').textContent = currentUnit.coordinates || '--';
|
|
|
|
// Seismograph fields
|
|
document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--';
|
|
document.getElementById('viewNextCalibrationDue').textContent = currentUnit.next_calibration_due || '--';
|
|
|
|
// 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 || '--';
|
|
document.getElementById('viewPhoneNumber').textContent = currentUnit.phone_number || '--';
|
|
document.getElementById('viewHardwareModel').textContent = currentUnit.hardware_model || '--';
|
|
|
|
// Notes
|
|
document.getElementById('viewNote').textContent = currentUnit.note || '--';
|
|
|
|
// Show/hide fields based on device type
|
|
if (currentUnit.device_type === 'modem') {
|
|
document.getElementById('viewSeismographFields').classList.add('hidden');
|
|
document.getElementById('viewModemFields').classList.remove('hidden');
|
|
document.getElementById('viewPairedDeviceSection').classList.remove('hidden');
|
|
document.getElementById('viewConnectivitySection').classList.remove('hidden');
|
|
// Load paired device info
|
|
loadPairedDevice();
|
|
} else {
|
|
document.getElementById('viewSeismographFields').classList.remove('hidden');
|
|
document.getElementById('viewModemFields').classList.add('hidden');
|
|
document.getElementById('viewPairedDeviceSection').classList.add('hidden');
|
|
document.getElementById('viewConnectivitySection').classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
// Populate edit form
|
|
function populateEditForm() {
|
|
document.getElementById('deviceType').value = currentUnit.device_type || 'seismograph';
|
|
document.getElementById('unitType').value = currentUnit.unit_type || '';
|
|
|
|
// Populate project picker (uses -detail suffix)
|
|
const projectPickerValue = document.getElementById('project-picker-value-detail');
|
|
const projectPickerSearch = document.getElementById('project-picker-search-detail');
|
|
const projectPickerClear = document.getElementById('project-picker-clear-detail');
|
|
if (projectPickerValue) projectPickerValue.value = currentUnit.project_id || '';
|
|
if (currentUnit.project_id) {
|
|
fetchProjectDisplay(currentUnit.project_id).then(displayText => {
|
|
if (projectPickerSearch) projectPickerSearch.value = displayText;
|
|
if (projectPickerClear) projectPickerClear.classList.remove('hidden');
|
|
});
|
|
} else {
|
|
if (projectPickerSearch) projectPickerSearch.value = '';
|
|
if (projectPickerClear) projectPickerClear.classList.add('hidden');
|
|
}
|
|
|
|
document.getElementById('address').value = currentUnit.address || '';
|
|
document.getElementById('coordinates').value = currentUnit.coordinates || '';
|
|
document.getElementById('deployed').checked = currentUnit.deployed;
|
|
document.getElementById('retired').checked = currentUnit.retired;
|
|
document.getElementById('note').value = currentUnit.note || '';
|
|
|
|
// Seismograph fields
|
|
document.getElementById('lastCalibrated').value = currentUnit.last_calibrated || '';
|
|
document.getElementById('nextCalibrationDue').value = currentUnit.next_calibration_due || '';
|
|
|
|
// 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 || '';
|
|
document.getElementById('phoneNumber').value = currentUnit.phone_number || '';
|
|
document.getElementById('hardwareModel').value = currentUnit.hardware_model || '';
|
|
|
|
// Sound Level Meter fields
|
|
document.getElementById('slmTcpPort').value = currentUnit.slm_tcp_port || '';
|
|
document.getElementById('slmModel').value = currentUnit.slm_model || '';
|
|
document.getElementById('slmSerialNumber').value = currentUnit.slm_serial_number || '';
|
|
document.getElementById('slmFrequencyWeighting').value = currentUnit.slm_frequency_weighting || '';
|
|
document.getElementById('slmTimeWeighting').value = currentUnit.slm_time_weighting || '';
|
|
document.getElementById('slmMeasurementRange').value = currentUnit.slm_measurement_range || '';
|
|
|
|
// 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
|
|
function toggleDetailFields() {
|
|
const deviceType = document.getElementById('deviceType').value;
|
|
const seismoFields = document.getElementById('seismographFields');
|
|
const modemFields = document.getElementById('modemFields');
|
|
const slmFields = document.getElementById('slmFields');
|
|
|
|
// Hide all device-specific fields first
|
|
seismoFields.classList.add('hidden');
|
|
modemFields.classList.add('hidden');
|
|
slmFields.classList.add('hidden');
|
|
|
|
// Show the relevant fields
|
|
if (deviceType === 'seismograph') {
|
|
seismoFields.classList.remove('hidden');
|
|
} else if (deviceType === 'modem') {
|
|
modemFields.classList.remove('hidden');
|
|
} else if (deviceType === 'sound_level_meter') {
|
|
slmFields.classList.remove('hidden');
|
|
}
|
|
}
|
|
|
|
// Enter edit mode
|
|
function enterEditMode() {
|
|
document.getElementById('viewMode').classList.add('hidden');
|
|
document.getElementById('editMode').classList.remove('hidden');
|
|
document.getElementById('editButton').classList.add('hidden');
|
|
}
|
|
|
|
// Cancel edit mode
|
|
function cancelEdit() {
|
|
document.getElementById('editMode').classList.add('hidden');
|
|
document.getElementById('viewMode').classList.remove('hidden');
|
|
document.getElementById('editButton').classList.remove('hidden');
|
|
|
|
// Reset form to current values
|
|
populateEditForm();
|
|
}
|
|
|
|
// Handle form submission
|
|
document.getElementById('editForm').addEventListener('submit', async function(e) {
|
|
e.preventDefault();
|
|
|
|
const formData = new FormData(this);
|
|
const deviceType = formData.get('device_type');
|
|
|
|
// Fix: FormData contains both modem picker hidden inputs (seismo and slm).
|
|
// We need to ensure only the correct one is submitted based on device type.
|
|
// Delete all deployed_with_modem_id entries and re-add the correct one.
|
|
const modemId = getCorrectModemPickerValue(deviceType);
|
|
formData.delete('deployed_with_modem_id');
|
|
if (modemId) {
|
|
formData.append('deployed_with_modem_id', modemId);
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/roster/edit/${unitId}`, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
if (response.ok) {
|
|
alert('Unit updated successfully!');
|
|
// Reload data and return to view mode
|
|
await loadUnitData();
|
|
cancelEdit();
|
|
} else {
|
|
const result = await response.json();
|
|
alert(`Error: ${result.detail || 'Unknown error'}`);
|
|
}
|
|
} catch (error) {
|
|
alert(`Error: ${error.message}`);
|
|
}
|
|
});
|
|
|
|
// Get the correct modem picker value based on device type
|
|
function getCorrectModemPickerValue(deviceType) {
|
|
if (deviceType === 'seismograph') {
|
|
const picker = document.getElementById('modem-picker-value-detail-seismo');
|
|
return picker ? picker.value : '';
|
|
} else if (deviceType === 'sound_level_meter') {
|
|
const picker = document.getElementById('modem-picker-value-detail-slm');
|
|
return picker ? picker.value : '';
|
|
}
|
|
// Modems don't have a deployed_with_modem_id
|
|
return '';
|
|
}
|
|
|
|
// Delete unit
|
|
async function deleteUnit() {
|
|
if (!confirm(`Are you sure you want to PERMANENTLY delete unit ${unitId}?\n\nThis action cannot be undone!`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/roster/${unitId}`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (response.ok) {
|
|
alert('Unit deleted successfully');
|
|
window.location.href = '/roster';
|
|
} else {
|
|
const result = await response.json();
|
|
alert(`Error: ${result.detail || 'Unknown error'}`);
|
|
}
|
|
} catch (error) {
|
|
alert(`Error: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
// Initialize unit location map (only called once)
|
|
function initUnitMap() {
|
|
if (!currentUnit.coordinates) {
|
|
document.getElementById('mapCard').classList.add('hidden');
|
|
return;
|
|
}
|
|
|
|
const coords = parseLocation(currentUnit.coordinates);
|
|
if (!coords) {
|
|
document.getElementById('mapCard').classList.add('hidden');
|
|
return;
|
|
}
|
|
|
|
const [lat, lon] = coords;
|
|
|
|
// Show the map card
|
|
document.getElementById('mapCard').classList.remove('hidden');
|
|
|
|
// Only initialize map if it doesn't exist
|
|
if (!unitMap) {
|
|
// Initialize map
|
|
unitMap = L.map('unit-map').setView([lat, lon], 13);
|
|
|
|
// Add OpenStreetMap tiles
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
maxZoom: 18
|
|
}).addTo(unitMap);
|
|
|
|
// Force map to update its size
|
|
setTimeout(() => {
|
|
unitMap.invalidateSize();
|
|
}, 100);
|
|
}
|
|
|
|
// Update marker (can be called multiple times)
|
|
updateMapMarker(lat, lon);
|
|
|
|
// Update location text
|
|
const locationParts = [];
|
|
if (currentUnit.address) {
|
|
locationParts.push(currentUnit.address);
|
|
}
|
|
locationParts.push(`Coordinates: ${lat.toFixed(6)}, ${lon.toFixed(6)}`);
|
|
document.getElementById('locationText').textContent = locationParts.join(' • ');
|
|
}
|
|
|
|
// Update map marker with current status
|
|
function updateMapMarker(lat, lon) {
|
|
// Remove old marker if it exists
|
|
if (mapMarker) {
|
|
mapMarker.remove();
|
|
}
|
|
|
|
// Get status color
|
|
let statusColor = 'gray';
|
|
let status = 'Unknown';
|
|
if (currentSnapshot && currentSnapshot.units && currentSnapshot.units[unitId]) {
|
|
const unitStatus = currentSnapshot.units[unitId];
|
|
status = unitStatus.status || 'Unknown';
|
|
statusColor = status === 'OK' ? 'green' : status === 'Pending' ? 'orange' : status === 'Missing' ? 'red' : 'gray';
|
|
}
|
|
|
|
// Add new marker
|
|
mapMarker = L.circleMarker([lat, lon], {
|
|
radius: 10,
|
|
fillColor: statusColor,
|
|
color: '#fff',
|
|
weight: 3,
|
|
opacity: 1,
|
|
fillOpacity: 0.8
|
|
}).addTo(unitMap).bindPopup(`
|
|
<div class="p-2">
|
|
<h3 class="font-bold text-lg">${currentUnit.id}</h3>
|
|
<p class="text-sm">Status: <span style="color: ${statusColor}">${status}</span></p>
|
|
<p class="text-sm">Type: ${currentUnit.device_type}</p>
|
|
</div>
|
|
`).openPopup();
|
|
}
|
|
|
|
function parseLocation(location) {
|
|
if (!location) return null;
|
|
|
|
// Try to parse as "lat,lon" format
|
|
const parts = location.split(',').map(s => s.trim());
|
|
if (parts.length === 2) {
|
|
const lat = parseFloat(parts[0]);
|
|
const lon = parseFloat(parts[1]);
|
|
if (!isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
|
|
return [lat, lon];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// Load and display photos
|
|
async function loadPhotos() {
|
|
try {
|
|
const response = await fetch(`/api/unit/${unitId}/photos`);
|
|
if (!response.ok) {
|
|
throw new Error('Failed to load photos');
|
|
}
|
|
|
|
const data = await response.json();
|
|
const gallery = document.getElementById('photoGallery');
|
|
|
|
if (data.photos && data.photos.length > 0) {
|
|
gallery.innerHTML = '';
|
|
data.photo_urls.forEach((url, index) => {
|
|
const photoDiv = document.createElement('div');
|
|
photoDiv.className = 'relative group';
|
|
photoDiv.innerHTML = `
|
|
<img src="${url}" alt="Unit photo ${index + 1}"
|
|
class="w-full h-48 object-cover rounded-lg shadow cursor-pointer hover:shadow-lg transition-shadow"
|
|
onclick="window.open('${url}', '_blank')">
|
|
${index === 0 ? '<span class="absolute top-2 left-2 bg-seismo-orange text-white text-xs px-2 py-1 rounded">Primary</span>' : ''}
|
|
`;
|
|
gallery.appendChild(photoDiv);
|
|
});
|
|
} else {
|
|
gallery.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 col-span-full">No photos yet. Add a photo to get started.</p>';
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading photos:', error);
|
|
document.getElementById('photoGallery').innerHTML = '<p class="text-sm text-red-500 col-span-full">Failed to load photos</p>';
|
|
}
|
|
}
|
|
|
|
// Upload photo with EXIF metadata extraction
|
|
async function uploadPhoto(file) {
|
|
if (!file) return;
|
|
|
|
const statusDiv = document.getElementById('uploadStatus');
|
|
statusDiv.className = 'mt-4 p-4 rounded-lg bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200';
|
|
statusDiv.textContent = 'Uploading photo and extracting metadata...';
|
|
statusDiv.classList.remove('hidden');
|
|
|
|
const formData = new FormData();
|
|
formData.append('photo', file);
|
|
|
|
try {
|
|
const response = await fetch(`/api/unit/${unitId}/upload-photo`, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Upload failed');
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
// Show success message with metadata info
|
|
let message = 'Photo uploaded successfully!';
|
|
if (result.metadata && result.metadata.coordinates) {
|
|
message += ` GPS location detected: ${result.metadata.coordinates}`;
|
|
if (result.coordinates_updated) {
|
|
message += ' (Unit coordinates updated automatically)';
|
|
}
|
|
} else {
|
|
message += ' No GPS data found in photo.';
|
|
}
|
|
|
|
statusDiv.className = 'mt-4 p-4 rounded-lg bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200';
|
|
statusDiv.textContent = message;
|
|
|
|
// Reload photos and unit data
|
|
await loadPhotos();
|
|
if (result.coordinates_updated) {
|
|
await loadUnitData();
|
|
}
|
|
|
|
// Hide status after 5 seconds
|
|
setTimeout(() => {
|
|
statusDiv.classList.add('hidden');
|
|
}, 5000);
|
|
|
|
// Reset both file inputs
|
|
document.getElementById('photoCameraUpload').value = '';
|
|
document.getElementById('photoLibraryUpload').value = '';
|
|
|
|
} catch (error) {
|
|
console.error('Error uploading photo:', error);
|
|
statusDiv.className = 'mt-4 p-4 rounded-lg bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200';
|
|
statusDiv.textContent = `Error uploading photo: ${error.message}`;
|
|
|
|
// Hide error after 5 seconds
|
|
setTimeout(() => {
|
|
statusDiv.classList.add('hidden');
|
|
}, 5000);
|
|
}
|
|
}
|
|
|
|
// Load and display unit history timeline
|
|
async function loadUnitHistory() {
|
|
try {
|
|
const response = await fetch(`/api/roster/history/${unitId}`);
|
|
if (!response.ok) {
|
|
throw new Error('Failed to load history');
|
|
}
|
|
|
|
const data = await response.json();
|
|
const timeline = document.getElementById('historyTimeline');
|
|
|
|
if (data.history && data.history.length > 0) {
|
|
timeline.innerHTML = '';
|
|
data.history.forEach(entry => {
|
|
const timelineEntry = createTimelineEntry(entry);
|
|
timeline.appendChild(timelineEntry);
|
|
});
|
|
} else {
|
|
timeline.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No history yet. Changes will appear here.</p>';
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading history:', error);
|
|
document.getElementById('historyTimeline').innerHTML = '<p class="text-sm text-red-500">Failed to load history</p>';
|
|
}
|
|
}
|
|
|
|
// Create a timeline entry element
|
|
function createTimelineEntry(entry) {
|
|
const div = document.createElement('div');
|
|
div.className = 'flex gap-3 p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50';
|
|
|
|
// Icon based on change type
|
|
const icons = {
|
|
'note_change': `<svg class="w-5 h-5 text-blue-500" 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>`,
|
|
'deployed_change': `<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
|
</svg>`,
|
|
'retired_change': `<svg class="w-5 h-5 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
|
</svg>`
|
|
};
|
|
|
|
const icon = icons[entry.change_type] || `<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
|
</svg>`;
|
|
|
|
// Format change description
|
|
let description = '';
|
|
if (entry.change_type === 'note_change') {
|
|
description = `<strong>Note changed</strong>`;
|
|
if (entry.old_value) {
|
|
description += `<br><span class="text-xs text-gray-500 dark:text-gray-400">From: "${entry.old_value}"</span>`;
|
|
}
|
|
if (entry.new_value) {
|
|
description += `<br><span class="text-xs text-gray-600 dark:text-gray-300">To: "${entry.new_value}"</span>`;
|
|
}
|
|
} else if (entry.change_type === 'deployed_change') {
|
|
description = `<strong>Status changed to ${entry.new_value}</strong>`;
|
|
} else if (entry.change_type === 'retired_change') {
|
|
description = `<strong>Marked as ${entry.new_value}</strong>`;
|
|
} else {
|
|
description = `<strong>${entry.field_name} changed</strong>`;
|
|
if (entry.old_value && entry.new_value) {
|
|
description += `<br><span class="text-xs text-gray-500 dark:text-gray-400">${entry.old_value} → ${entry.new_value}</span>`;
|
|
}
|
|
}
|
|
|
|
// Format timestamp
|
|
const timestamp = new Date(entry.changed_at).toLocaleString();
|
|
|
|
div.innerHTML = `
|
|
<div class="flex-shrink-0">
|
|
${icon}
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="text-sm text-gray-900 dark:text-white">
|
|
${description}
|
|
</div>
|
|
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
${timestamp}
|
|
${entry.source !== 'manual' ? `<span class="ml-2 px-2 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">${entry.source}</span>` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="flex-shrink-0">
|
|
<button onclick="deleteHistoryEntry(${entry.id})" class="text-gray-400 hover:text-red-500 transition-colors" title="Delete this history entry">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
return div;
|
|
}
|
|
|
|
// Delete a history entry
|
|
async function deleteHistoryEntry(historyId) {
|
|
if (!confirm('Are you sure you want to delete this history entry?')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/roster/history/${historyId}`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (response.ok) {
|
|
// Reload history
|
|
await loadUnitHistory();
|
|
} else {
|
|
const result = await response.json();
|
|
alert(`Error: ${result.detail || 'Unknown error'}`);
|
|
}
|
|
} catch (error) {
|
|
alert(`Error: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
// Load paired device info for modems
|
|
async function loadPairedDevice() {
|
|
try {
|
|
const response = await fetch(`/api/modem-dashboard/${unitId}/paired-device-html`);
|
|
if (response.ok) {
|
|
const html = await response.text();
|
|
document.getElementById('pairedDeviceInfo').innerHTML = html;
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading paired device:', error);
|
|
document.getElementById('pairedDeviceInfo').innerHTML = '<p class="text-red-500 text-sm">Failed to load paired device info</p>';
|
|
}
|
|
}
|
|
|
|
// Ping modem and show result
|
|
async function pingModem() {
|
|
const btn = document.getElementById('modemPingBtn');
|
|
const resultSpan = document.getElementById('modemPingResult');
|
|
|
|
// Show loading state
|
|
const originalText = btn.innerHTML;
|
|
btn.innerHTML = `
|
|
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
|
</svg>
|
|
Pinging...
|
|
`;
|
|
btn.disabled = true;
|
|
resultSpan.textContent = 'Testing connection...';
|
|
resultSpan.className = 'text-sm text-gray-500 dark:text-gray-400';
|
|
|
|
try {
|
|
const response = await fetch(`/api/modem-dashboard/${unitId}/ping`);
|
|
const data = await response.json();
|
|
|
|
if (data.status === 'success') {
|
|
resultSpan.innerHTML = `<span class="inline-block w-2 h-2 bg-green-500 rounded-full mr-1"></span>Online (${data.response_time_ms}ms)`;
|
|
resultSpan.className = 'text-sm text-green-600 dark:text-green-400';
|
|
} else {
|
|
resultSpan.innerHTML = `<span class="inline-block w-2 h-2 bg-red-500 rounded-full mr-1"></span>${data.detail || 'Offline'}`;
|
|
resultSpan.className = 'text-sm text-red-600 dark:text-red-400';
|
|
}
|
|
} catch (error) {
|
|
resultSpan.textContent = 'Error: ' + error.message;
|
|
resultSpan.className = 'text-sm text-red-600 dark:text-red-400';
|
|
}
|
|
|
|
// Restore button
|
|
btn.innerHTML = originalText;
|
|
btn.disabled = false;
|
|
}
|
|
|
|
// Load data when page loads
|
|
loadUnitData().then(() => {
|
|
loadPhotos();
|
|
loadUnitHistory();
|
|
});
|
|
|
|
// ===== Pair Device Modal Functions =====
|
|
let pairModalModems = []; // Cache loaded modems
|
|
let pairModalDeviceType = ''; // Current device type
|
|
|
|
function openPairDeviceModal(deviceType) {
|
|
const modal = document.getElementById('pairDeviceModal');
|
|
const searchInput = document.getElementById('pairModemSearch');
|
|
const hideBenchedCheckbox = document.getElementById('pairHideBenched');
|
|
|
|
if (!modal) return;
|
|
|
|
pairModalDeviceType = deviceType;
|
|
|
|
// Reset search and filter
|
|
if (searchInput) searchInput.value = '';
|
|
if (hideBenchedCheckbox) hideBenchedCheckbox.checked = false;
|
|
|
|
// Show modal
|
|
modal.classList.remove('hidden');
|
|
|
|
// Focus search input
|
|
setTimeout(() => {
|
|
if (searchInput) searchInput.focus();
|
|
}, 100);
|
|
|
|
// Load available modems
|
|
loadAvailableModems();
|
|
}
|
|
|
|
function closePairDeviceModal() {
|
|
const modal = document.getElementById('pairDeviceModal');
|
|
if (modal) modal.classList.add('hidden');
|
|
pairModalModems = [];
|
|
}
|
|
|
|
async function loadAvailableModems() {
|
|
const listContainer = document.getElementById('pairModemList');
|
|
listContainer.innerHTML = '<div class="text-center py-4"><div class="animate-spin inline-block w-6 h-6 border-2 border-seismo-orange border-t-transparent rounded-full"></div><p class="mt-2 text-sm text-gray-500">Loading modems...</p></div>';
|
|
|
|
try {
|
|
const response = await fetch('/api/roster/modems');
|
|
if (!response.ok) throw new Error('Failed to load modems');
|
|
|
|
pairModalModems = await response.json();
|
|
|
|
if (pairModalModems.length === 0) {
|
|
listContainer.innerHTML = '<p class="text-center py-4 text-gray-500">No modems found in roster</p>';
|
|
return;
|
|
}
|
|
|
|
// Render the list
|
|
renderModemList();
|
|
} catch (error) {
|
|
listContainer.innerHTML = `<p class="text-center py-4 text-red-500">Error: ${error.message}</p>`;
|
|
}
|
|
}
|
|
|
|
function filterPairModemList() {
|
|
renderModemList();
|
|
}
|
|
|
|
function renderModemList() {
|
|
const listContainer = document.getElementById('pairModemList');
|
|
const searchInput = document.getElementById('pairModemSearch');
|
|
const hideBenchedCheckbox = document.getElementById('pairHideBenched');
|
|
|
|
const searchTerm = (searchInput?.value || '').toLowerCase().trim();
|
|
const hideBenched = hideBenchedCheckbox?.checked || false;
|
|
|
|
// Filter modems
|
|
let filteredModems = pairModalModems.filter(modem => {
|
|
// Filter by benched status
|
|
if (hideBenched && !modem.deployed) return false;
|
|
|
|
// Filter by search term
|
|
if (searchTerm) {
|
|
const searchFields = [
|
|
modem.id,
|
|
modem.ip_address || '',
|
|
modem.phone_number || '',
|
|
modem.note || ''
|
|
].join(' ').toLowerCase();
|
|
if (!searchFields.includes(searchTerm)) return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
if (filteredModems.length === 0) {
|
|
listContainer.innerHTML = '<p class="text-center py-4 text-gray-500">No modems match your criteria</p>';
|
|
return;
|
|
}
|
|
|
|
// Build modem list
|
|
let html = '';
|
|
for (const modem of filteredModems) {
|
|
const displayParts = [modem.id];
|
|
if (modem.ip_address) displayParts.push(`(${modem.ip_address})`);
|
|
if (modem.note) displayParts.push(`- ${modem.note.substring(0, 30)}${modem.note.length > 30 ? '...' : ''}`);
|
|
|
|
const deployedBadge = modem.deployed
|
|
? '<span class="px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 text-xs rounded">Deployed</span>'
|
|
: '<span class="px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 text-xs rounded">Benched</span>';
|
|
|
|
const pairedBadge = modem.deployed_with_unit_id
|
|
? `<span class="px-2 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs rounded">Paired: ${modem.deployed_with_unit_id}</span>`
|
|
: '';
|
|
|
|
html += `
|
|
<div class="flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer border-b border-gray-100 dark:border-gray-700 last:border-0"
|
|
onclick="selectModemForPairing('${modem.id}', '${displayParts.join(' ').replace(/'/g, "\\'")}')">
|
|
<div class="flex-1">
|
|
<div class="font-medium text-gray-900 dark:text-white">
|
|
<span class="text-seismo-orange">${modem.id}</span>
|
|
${modem.ip_address ? `<span class="text-gray-400 ml-2 font-mono text-sm">${modem.ip_address}</span>` : ''}
|
|
</div>
|
|
${modem.note ? `<div class="text-sm text-gray-500 dark:text-gray-400 truncate">${modem.note}</div>` : ''}
|
|
</div>
|
|
<div class="flex gap-2 ml-3">
|
|
${deployedBadge}
|
|
${pairedBadge}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
listContainer.innerHTML = html;
|
|
}
|
|
|
|
function selectModemForPairing(modemId, displayText) {
|
|
// Update the correct picker based on device type
|
|
let pickerId = '';
|
|
if (pairModalDeviceType === 'seismograph') {
|
|
pickerId = '-detail-seismo';
|
|
} else if (pairModalDeviceType === 'sound_level_meter') {
|
|
pickerId = '-detail-slm';
|
|
}
|
|
|
|
const valueInput = document.getElementById('modem-picker-value' + pickerId);
|
|
const searchInput = document.getElementById('modem-picker-search' + pickerId);
|
|
const clearBtn = document.getElementById('modem-picker-clear' + pickerId);
|
|
|
|
if (valueInput) valueInput.value = modemId;
|
|
if (searchInput) searchInput.value = displayText;
|
|
if (clearBtn) clearBtn.classList.remove('hidden');
|
|
|
|
// Close modal
|
|
closePairDeviceModal();
|
|
}
|
|
|
|
// Clear pairing (unpair device from modem)
|
|
function clearPairing(deviceType) {
|
|
let pickerId = '';
|
|
if (deviceType === 'seismograph') {
|
|
pickerId = '-detail-seismo';
|
|
} else if (deviceType === 'sound_level_meter') {
|
|
pickerId = '-detail-slm';
|
|
}
|
|
|
|
const valueInput = document.getElementById('modem-picker-value' + pickerId);
|
|
const searchInput = document.getElementById('modem-picker-search' + pickerId);
|
|
const clearBtn = document.getElementById('modem-picker-clear' + pickerId);
|
|
|
|
if (valueInput) valueInput.value = '';
|
|
if (searchInput) searchInput.value = '';
|
|
if (clearBtn) clearBtn.classList.add('hidden');
|
|
|
|
closePairDeviceModal();
|
|
}
|
|
</script>
|
|
|
|
<!-- Pair Device Modal -->
|
|
<div id="pairDeviceModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
|
<div class="flex items-center justify-center min-h-screen px-4">
|
|
<!-- Backdrop -->
|
|
<div class="fixed inset-0 bg-black/50" onclick="closePairDeviceModal()"></div>
|
|
|
|
<!-- Modal Content -->
|
|
<div class="relative bg-white dark:bg-slate-800 rounded-xl shadow-xl max-w-lg w-full max-h-[80vh] overflow-hidden">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Pair with Modem</h3>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">Select a modem to pair with this device</p>
|
|
</div>
|
|
<button onclick="closePairDeviceModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Search and Filter -->
|
|
<div class="px-6 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-slate-900/50">
|
|
<div class="flex gap-3 items-center">
|
|
<!-- Search Input -->
|
|
<div class="flex-1 relative">
|
|
<input type="text"
|
|
id="pairModemSearch"
|
|
placeholder="Search by ID, IP, or note..."
|
|
oninput="filterPairModemList()"
|
|
class="w-full px-4 py-2 pl-10 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 focus:border-seismo-orange text-sm">
|
|
<svg class="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
|
</svg>
|
|
</div>
|
|
<!-- Hide Benched Toggle -->
|
|
<label class="flex items-center gap-2 cursor-pointer whitespace-nowrap">
|
|
<input type="checkbox"
|
|
id="pairHideBenched"
|
|
onchange="filterPairModemList()"
|
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
|
<span class="text-sm text-gray-600 dark:text-gray-400">Deployed only</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modem List -->
|
|
<div id="pairModemList" class="max-h-80 overflow-y-auto">
|
|
<!-- Populated by JavaScript -->
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-slate-900">
|
|
<button onclick="closePairDeviceModal()" class="w-full px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Include Project Create Modal for inline project creation -->
|
|
{% include "partials/project_create_modal.html" %}
|
|
|
|
{% endblock %}
|