56bd3041cf
feat: Location no longer assigned directly to unit, locations and coords are assigned to location only, unit only is deployed or benched.
1093 lines
51 KiB
HTML
1093 lines
51 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Dashboard - Seismo Fleet Manager{% endblock %}
|
|
|
|
{% block content %}
|
|
{% if environment == 'development' %}
|
|
<div class="mb-4 p-4 bg-yellow-100 dark:bg-yellow-900 border-l-4 border-yellow-500 text-yellow-700 dark:text-yellow-200 rounded">
|
|
<p class="font-bold">Development Environment</p>
|
|
<p class="text-sm">You are currently viewing the development version of Seismo Fleet Manager.</p>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="mb-8 flex justify-between items-center gap-4 flex-wrap">
|
|
<div>
|
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
|
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Fleet overview and recent activity</p>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<a href="/deploy"
|
|
class="hidden md:inline-flex items-center gap-2 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium text-sm shadow"
|
|
title="Capture a field install — pick unit, snap photo, leave">
|
|
<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="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 stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/>
|
|
</svg>
|
|
Field Deploy
|
|
</a>
|
|
<div class="text-right">
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">Last updated</p>
|
|
<p id="last-refresh" class="text-sm text-gray-700 dark:text-gray-300 font-mono">--</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Pending-deployments banner — auto-shows when there are field captures
|
|
awaiting classification. Hides itself when count is 0. Polled
|
|
alongside the rest of the dashboard's 10-second refresh. -->
|
|
<a id="pending-deploy-banner" href="/tools/pending-deployments"
|
|
class="hidden mb-6 flex items-center justify-between gap-3 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-colors">
|
|
<div class="flex items-center gap-3">
|
|
<div class="w-9 h-9 rounded-lg bg-amber-100 dark:bg-amber-900/40 text-amber-600 dark:text-amber-400 flex items-center justify-center shrink-0">
|
|
<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 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<div class="text-sm font-medium text-amber-900 dark:text-amber-200">
|
|
<span id="pending-deploy-count">0</span> field deployment<span id="pending-deploy-plural">s</span> awaiting classification
|
|
</div>
|
|
<div class="text-xs text-amber-700 dark:text-amber-300">Click to pick project / location for these captures</div>
|
|
</div>
|
|
</div>
|
|
<svg class="w-5 h-5 text-amber-600 dark:text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
|
</svg>
|
|
</a>
|
|
|
|
<script>
|
|
async function _refreshPendingDeployBanner() {
|
|
try {
|
|
const r = await fetch('/api/deployments/pending?status=awaiting');
|
|
if (!r.ok) return;
|
|
const data = await r.json();
|
|
const banner = document.getElementById('pending-deploy-banner');
|
|
const countEl = document.getElementById('pending-deploy-count');
|
|
const pluralEl = document.getElementById('pending-deploy-plural');
|
|
if (data.count > 0) {
|
|
countEl.textContent = data.count;
|
|
pluralEl.textContent = data.count === 1 ? '' : 's';
|
|
banner.classList.remove('hidden');
|
|
} else {
|
|
banner.classList.add('hidden');
|
|
}
|
|
} catch (e) {
|
|
/* silent — banner just stays hidden */
|
|
}
|
|
}
|
|
_refreshPendingDeployBanner();
|
|
setInterval(_refreshPendingDeployBanner, 30000);
|
|
</script>
|
|
|
|
<!-- Dashboard cards with auto-refresh -->
|
|
<div hx-get="/api/status-snapshot"
|
|
hx-trigger="load, every 10s"
|
|
hx-swap="none"
|
|
hx-on::after-request="updateDashboard(event)">
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
|
|
|
<!-- Recent Alerts Card (col 1) -->
|
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="recent-alerts-card">
|
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-alerts')">
|
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Alerts</h2>
|
|
<div class="flex items-center gap-2">
|
|
<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z">
|
|
</path>
|
|
</svg>
|
|
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-alerts-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<div id="alerts-list" class="space-y-3 card-content" id-content="recent-alerts-content">
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">Loading alerts...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recent Call-Ins Card (cols 2-3, double-wide) -->
|
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6 md:col-span-2 lg:col-span-2" id="recent-callins-card">
|
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-callins')">
|
|
<div class="flex items-center gap-2">
|
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Call-Ins</h2>
|
|
<span class="text-xs text-gray-500 dark:text-gray-400 hidden sm:inline">from SFM event forwards</span>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<svg class="w-6 h-6 text-seismo-burgundy" 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>
|
|
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-callins-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<div class="card-content" id="recent-callins-content">
|
|
<div id="recent-callins-list" class="space-y-2">
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">Loading recent call-ins...</p>
|
|
</div>
|
|
<a href="/sfm" class="block mt-3 text-center text-sm text-seismo-orange hover:text-seismo-burgundy font-medium">
|
|
View all events →
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Fleet Summary Card (col 4) -->
|
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="fleet-summary-card">
|
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-summary')">
|
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
|
|
<div class="flex items-center gap-2">
|
|
<svg class="w-6 h-6 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z">
|
|
</path>
|
|
</svg>
|
|
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="fleet-summary-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<div class="space-y-4 card-content" id="fleet-summary-content">
|
|
<!-- Seismographs -->
|
|
<div>
|
|
<div class="flex justify-between items-center mb-1.5">
|
|
<div class="flex items-center">
|
|
<svg class="w-4 h-4 mr-1.5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
|
</svg>
|
|
<a href="/seismographs" class="text-sm font-semibold text-gray-800 dark:text-gray-200 hover:text-blue-600 dark:hover:text-blue-400">Seismographs</a>
|
|
</div>
|
|
<span id="seismo-count" class="text-lg font-bold text-blue-600 dark:text-blue-400">--</span>
|
|
</div>
|
|
<div class="pl-6 flex flex-col gap-0.5 text-sm">
|
|
<div class="flex justify-between">
|
|
<span class="text-gray-500 dark:text-gray-400">Deployed</span>
|
|
<span id="seismo-deployed" class="font-medium text-gray-800 dark:text-gray-200">--</span>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span class="text-gray-500 dark:text-gray-400">Benched</span>
|
|
<span id="seismo-benched" class="font-medium text-gray-800 dark:text-gray-200">--</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sound Level Meters -->
|
|
<div>
|
|
<div class="flex justify-between items-center mb-1.5">
|
|
<div class="flex items-center">
|
|
<svg class="w-4 h-4 mr-1.5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
|
</svg>
|
|
<a href="/sound-level-meters" class="text-sm font-semibold text-gray-800 dark:text-gray-200 hover:text-purple-600 dark:hover:text-purple-400">Sound Level Meters</a>
|
|
</div>
|
|
<span id="slm-count" class="text-lg font-bold text-purple-600 dark:text-purple-400">--</span>
|
|
</div>
|
|
<div class="pl-6 flex flex-col gap-0.5 text-sm">
|
|
<div class="flex justify-between">
|
|
<span class="text-gray-500 dark:text-gray-400">Deployed</span>
|
|
<span id="slm-deployed" class="font-medium text-gray-800 dark:text-gray-200">--</span>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span class="text-gray-500 dark:text-gray-400">Benched</span>
|
|
<span id="slm-benched" class="font-medium text-gray-800 dark:text-gray-200">--</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
|
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">Call-in Status:</p>
|
|
<div class="flex justify-between items-center mb-2" title="Units reporting normally (last seen < 12 hours)">
|
|
<div class="flex items-center">
|
|
<span class="w-3 h-3 rounded-full bg-green-500 mr-2 flex items-center justify-center">
|
|
<svg class="w-2 h-2 text-white" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
|
</svg>
|
|
</span>
|
|
<span class="text-sm text-gray-600 dark:text-gray-400">OK</span>
|
|
</div>
|
|
<span id="status-ok" class="font-semibold text-green-600 dark:text-green-400">--</span>
|
|
</div>
|
|
<div class="flex justify-between items-center mb-2" title="Units with delayed reports (12-24 hours)">
|
|
<div class="flex items-center">
|
|
<span class="w-3 h-3 rounded-full bg-yellow-500 mr-2 flex items-center justify-center">
|
|
<svg class="w-2 h-2 text-white" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/>
|
|
</svg>
|
|
</span>
|
|
<span class="text-sm text-gray-600 dark:text-gray-400">Pending</span>
|
|
</div>
|
|
<span id="status-pending" class="font-semibold text-yellow-600 dark:text-yellow-400">--</span>
|
|
</div>
|
|
<div class="flex justify-between items-center" title="Units not reporting (> 24 hours)">
|
|
<div class="flex items-center">
|
|
<span class="w-3 h-3 rounded-full bg-red-500 mr-2 flex items-center justify-center">
|
|
<svg class="w-2 h-2 text-white" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
|
</svg>
|
|
</span>
|
|
<span class="text-sm text-gray-600 dark:text-gray-400">Missing</span>
|
|
</div>
|
|
<span id="status-missing" class="font-semibold text-red-600 dark:text-red-400">--</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</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 -->
|
|
<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')">
|
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Map</h2>
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-sm text-gray-500 dark:text-gray-400">Deployed units</span>
|
|
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="fleet-map-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<div class="card-content" id="fleet-map-content">
|
|
<div id="fleet-map" class="w-full h-64 md:h-96 rounded-lg"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Today's Schedule — horizontal collapsible card.
|
|
Default collapsed; auto-expands when an upcoming action is detected
|
|
(pending + scheduled within the next 4h). JS reads
|
|
data-has-upcoming on the inner partial after htmx swap. -->
|
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-4 mb-8" id="todays-actions-card">
|
|
<div class="flex items-center justify-between cursor-pointer" onclick="toggleTodaysSchedule()">
|
|
<div class="flex items-center gap-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="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
|
</svg>
|
|
<h2 class="text-base font-semibold text-gray-900 dark:text-white">Today's Schedule</h2>
|
|
<span id="todays-actions-badge"
|
|
class="hidden text-xs font-medium px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200">
|
|
</span>
|
|
</div>
|
|
<svg class="w-5 h-5 text-gray-500 transition-transform collapsed" id="todays-actions-chevron"
|
|
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
|
</svg>
|
|
</div>
|
|
<div class="card-content collapsed mt-4" id="todays-actions-content"
|
|
hx-get="/dashboard/todays-actions"
|
|
hx-trigger="load, every 30s"
|
|
hx-swap="innerHTML"
|
|
hx-on::after-swap="onTodaysActionsSwap(this)">
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">Loading scheduled actions...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recent Photos Section -->
|
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6 mb-8" id="recent-photos-card">
|
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-photos')">
|
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recent Photos</h2>
|
|
<div class="flex items-center gap-2">
|
|
<svg class="w-6 h-6 text-seismo-orange" 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>
|
|
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-photos-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<div class="card-content" id="recent-photos-content">
|
|
<div id="recentPhotosGallery" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
|
<p class="text-sm text-gray-500 dark:text-gray-400 col-span-full">Loading recent photos...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Fleet Status Section with Tabs -->
|
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="fleet-status-card">
|
|
|
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-status')">
|
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Status</h2>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<a href="/roster" class="text-seismo-orange hover:text-seismo-burgundy font-medium flex items-center" onclick="event.stopPropagation()">
|
|
Full Roster
|
|
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
|
</svg>
|
|
</a>
|
|
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="fleet-status-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card-content" id="fleet-status-content">
|
|
<!-- Tab Bar -->
|
|
<div class="flex border-b border-gray-200 dark:border-gray-700 mb-4">
|
|
<button
|
|
class="px-4 py-2 text-sm font-medium tab-button active-tab"
|
|
hx-get="/dashboard/active"
|
|
hx-target="#fleet-table"
|
|
hx-swap="innerHTML">
|
|
Active
|
|
</button>
|
|
|
|
<button
|
|
class="px-4 py-2 text-sm font-medium tab-button"
|
|
hx-get="/dashboard/benched"
|
|
hx-target="#fleet-table"
|
|
hx-swap="innerHTML">
|
|
Benched
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Tab Content Target -->
|
|
<div id="fleet-table" class="space-y-2"
|
|
hx-get="/dashboard/active"
|
|
hx-trigger="load"
|
|
hx-swap="innerHTML">
|
|
<p class="text-gray-500 dark:text-gray-400">Loading fleet data...</p>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<!-- TAB STYLE -->
|
|
<style>
|
|
.tab-button {
|
|
color: #6b7280; /* gray-500 */
|
|
border-bottom: 2px solid transparent;
|
|
}
|
|
.tab-button:hover {
|
|
color: #374151; /* gray-700 */
|
|
}
|
|
.active-tab {
|
|
color: #b84a12 !important; /* seismo orange */
|
|
border-bottom: 2px solid #b84a12 !important;
|
|
}
|
|
|
|
/* Collapsible cards (mobile only) */
|
|
@media (max-width: 767px) {
|
|
.card-content.collapsed {
|
|
display: none;
|
|
}
|
|
.chevron.collapsed {
|
|
transform: rotate(-90deg);
|
|
}
|
|
}
|
|
|
|
/* Today's Schedule — horizontal collapsible at all breakpoints. */
|
|
#todays-actions-content.collapsed {
|
|
display: none;
|
|
}
|
|
#todays-actions-chevron.collapsed {
|
|
transform: rotate(-90deg);
|
|
}
|
|
#todays-actions-chevron {
|
|
transition: transform 0.2s ease-in-out;
|
|
}
|
|
</style>
|
|
|
|
|
|
<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' && u.device_type !== 'modem');
|
|
|
|
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.
|
|
// Modems are not plotted — they inherit the paired device's location,
|
|
// which would just stack a duplicate marker on the same pin.
|
|
const deployedUnits = Object.entries(allUnits || {})
|
|
.filter(([_, u]) => u.deployed
|
|
&& u.coordinates
|
|
&& (u.device_type || 'seismograph') !== 'modem'
|
|
&& 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);
|
|
|
|
const locName = unit.location_name || '';
|
|
marker.bindPopup(`
|
|
<div class="p-2">
|
|
<h3 class="font-bold text-lg">${id}</h3>
|
|
<p class="text-sm text-gray-600">${deviceLabel}</p>
|
|
${locName ? `<p class="text-sm text-gray-700">📍 ${locName}</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]);
|
|
}
|
|
});
|
|
|
|
// Only fit bounds on initial load, not on subsequent updates
|
|
// This preserves the user's current map view when auto-refreshing
|
|
if (bounds.length > 0 && !fleetMapInitialized) {
|
|
const padding = window.innerWidth < 768 ? [20, 20] : [50, 50];
|
|
fleetMap.fitBounds(bounds, { padding: padding });
|
|
fleetMapInitialized = true;
|
|
}
|
|
}
|
|
|
|
// Toggle card collapse/expand (mobile only)
|
|
function toggleCard(cardName) {
|
|
// Only work on mobile
|
|
if (window.innerWidth >= 768) return;
|
|
|
|
const content = document.getElementById(`${cardName}-content`);
|
|
const chevron = document.getElementById(`${cardName}-chevron`);
|
|
|
|
if (!content || !chevron) return;
|
|
|
|
// Toggle collapsed state
|
|
const isCollapsed = content.classList.contains('collapsed');
|
|
|
|
if (isCollapsed) {
|
|
content.classList.remove('collapsed');
|
|
chevron.classList.remove('collapsed');
|
|
|
|
// If expanding the fleet map, invalidate size after animation
|
|
if (cardName === 'fleet-map' && window.fleetMap) {
|
|
setTimeout(() => {
|
|
window.fleetMap.invalidateSize();
|
|
}, 300);
|
|
}
|
|
} else {
|
|
content.classList.add('collapsed');
|
|
chevron.classList.add('collapsed');
|
|
}
|
|
|
|
// Save state to localStorage
|
|
const cardStates = JSON.parse(localStorage.getItem('dashboardCardStates') || '{}');
|
|
cardStates[cardName] = !isCollapsed;
|
|
localStorage.setItem('dashboardCardStates', JSON.stringify(cardStates));
|
|
}
|
|
|
|
// Restore card states from localStorage on page load
|
|
function restoreCardStates() {
|
|
const cardStates = JSON.parse(localStorage.getItem('dashboardCardStates') || '{}');
|
|
// Note: todays-actions has its own collapse handling (see toggleTodaysSchedule / onTodaysActionsSwap)
|
|
const cardNames = ['fleet-summary', 'recent-alerts', 'recent-callins', 'fleet-map', 'fleet-status'];
|
|
|
|
cardNames.forEach(cardName => {
|
|
const content = document.getElementById(`${cardName}-content`);
|
|
const chevron = document.getElementById(`${cardName}-chevron`);
|
|
|
|
if (!content || !chevron) return;
|
|
|
|
// Default to expanded (true) if no saved state
|
|
const isCollapsed = cardStates[cardName] === false;
|
|
|
|
if (isCollapsed) {
|
|
content.classList.add('collapsed');
|
|
chevron.classList.add('collapsed');
|
|
}
|
|
});
|
|
}
|
|
|
|
// Restore states when DOM is ready
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', restoreCardStates);
|
|
} else {
|
|
restoreCardStates();
|
|
}
|
|
|
|
function updateDashboard(event) {
|
|
try {
|
|
// Only process responses from /api/status-snapshot
|
|
const requestUrl = event.detail.xhr.responseURL || event.detail.pathInfo?.requestPath;
|
|
if (!requestUrl || !requestUrl.includes('/api/status-snapshot')) {
|
|
return; // Ignore responses from other endpoints (like /dashboard/todays-actions)
|
|
}
|
|
|
|
const data = JSON.parse(event.detail.xhr.response);
|
|
|
|
// Store data for filter re-application
|
|
currentSnapshotData = data;
|
|
|
|
// Update "Last updated" timestamp with timezone
|
|
const now = new Date();
|
|
const timezone = localStorage.getItem('timezone') || 'America/New_York';
|
|
document.getElementById('last-refresh').textContent = now.toLocaleTimeString('en-US', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
timeZone: timezone,
|
|
timeZoneName: 'short'
|
|
});
|
|
|
|
// ===== Fleet Summary: per-device-type counts (always unfiltered) =====
|
|
// Deployed = unit has an active UnitAssignment (location_id set by
|
|
// the snapshot helper). Benched = no active assignment.
|
|
// Retired, out-for-calibration, and roster-unknown units (emitters
|
|
// not in the roster) are excluded from totals.
|
|
const counts = {
|
|
seismograph: { total: 0, deployed: 0, benched: 0 },
|
|
sound_level_meter: { total: 0, deployed: 0, benched: 0 },
|
|
};
|
|
let monitoredOk = 0, monitoredPending = 0, monitoredMissing = 0;
|
|
const unknownIds = new Set(Object.keys(data.unknown || {}));
|
|
|
|
Object.entries(data.units || {}).forEach(([uid, unit]) => {
|
|
if (unit.retired || unit.out_for_calibration) return;
|
|
if (unknownIds.has(uid)) return;
|
|
const dt = unit.device_type || 'seismograph';
|
|
const bucket = counts[dt];
|
|
if (!bucket) return; // skip modems and anything else
|
|
|
|
bucket.total++;
|
|
if (unit.location_id) {
|
|
bucket.deployed++;
|
|
} else {
|
|
bucket.benched++;
|
|
}
|
|
|
|
// Status tally only for seismographs + SLMs that are actually
|
|
// deployed (assigned). Mirrors the per-device buckets so the
|
|
// sum matches.
|
|
if (unit.location_id) {
|
|
if (unit.status === 'OK') monitoredOk++;
|
|
else if (unit.status === 'Pending') monitoredPending++;
|
|
else if (unit.status === 'Missing') monitoredMissing++;
|
|
}
|
|
});
|
|
|
|
document.getElementById('seismo-count').textContent = counts.seismograph.total;
|
|
document.getElementById('seismo-deployed').textContent = counts.seismograph.deployed;
|
|
document.getElementById('seismo-benched').textContent = counts.seismograph.benched;
|
|
document.getElementById('slm-count').textContent = counts.sound_level_meter.total;
|
|
document.getElementById('slm-deployed').textContent = counts.sound_level_meter.deployed;
|
|
document.getElementById('slm-benched').textContent = counts.sound_level_meter.benched;
|
|
document.getElementById('status-ok').textContent = monitoredOk;
|
|
document.getElementById('status-pending').textContent = monitoredPending;
|
|
document.getElementById('status-missing').textContent = monitoredMissing;
|
|
|
|
// ===== Apply filters and render map + alerts =====
|
|
renderFilteredDashboard(data);
|
|
|
|
} catch (err) {
|
|
console.error("Dashboard update error:", err);
|
|
}
|
|
}
|
|
|
|
// Handle tab switching and initialize components
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
// Load filter preferences
|
|
loadFilterPreferences();
|
|
|
|
const tabButtons = document.querySelectorAll('.tab-button');
|
|
|
|
tabButtons.forEach(button => {
|
|
button.addEventListener('click', function() {
|
|
// Remove active-tab class from all buttons
|
|
tabButtons.forEach(btn => btn.classList.remove('active-tab'));
|
|
// Add active-tab class to clicked button
|
|
this.classList.add('active-tab');
|
|
});
|
|
});
|
|
|
|
// Initialize fleet map
|
|
initFleetMap();
|
|
});
|
|
|
|
let fleetMap = null;
|
|
let fleetMarkers = [];
|
|
let fleetMapInitialized = false;
|
|
|
|
// Make fleetMap accessible globally for toggleCard function
|
|
window.fleetMap = null;
|
|
|
|
function initFleetMap() {
|
|
// Initialize the map centered on the US (can adjust based on your deployment area)
|
|
fleetMap = L.map('fleet-map').setView([39.8283, -98.5795], 4);
|
|
window.fleetMap = fleetMap;
|
|
|
|
// Add OpenStreetMap tiles
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© OpenStreetMap contributors',
|
|
maxZoom: 18
|
|
}).addTo(fleetMap);
|
|
|
|
// Force map to recalculate size after a brief delay to ensure container is fully rendered
|
|
setTimeout(() => {
|
|
fleetMap.invalidateSize();
|
|
}, 100);
|
|
}
|
|
|
|
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)) {
|
|
return [lat, lon];
|
|
}
|
|
}
|
|
|
|
// TODO: Add geocoding support for address strings
|
|
return null;
|
|
}
|
|
|
|
// Load and display recent photos
|
|
async function loadRecentPhotos() {
|
|
try {
|
|
const response = await fetch('/api/recent-photos?limit=12');
|
|
if (!response.ok) {
|
|
throw new Error('Failed to load recent photos');
|
|
}
|
|
|
|
const data = await response.json();
|
|
const gallery = document.getElementById('recentPhotosGallery');
|
|
|
|
if (data.photos && data.photos.length > 0) {
|
|
gallery.innerHTML = '';
|
|
data.photos.forEach(photo => {
|
|
const photoDiv = document.createElement('div');
|
|
photoDiv.className = 'relative group';
|
|
photoDiv.innerHTML = `
|
|
<a href="/unit/${photo.unit_id}" class="block">
|
|
<img src="${photo.path}" alt="${photo.unit_id}"
|
|
class="w-full h-32 object-cover rounded-lg shadow hover:shadow-lg transition-shadow">
|
|
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2 rounded-b-lg">
|
|
<p class="text-white text-xs font-semibold">${photo.unit_id}</p>
|
|
</div>
|
|
</a>
|
|
`;
|
|
gallery.appendChild(photoDiv);
|
|
});
|
|
} else {
|
|
gallery.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 col-span-full">No photos uploaded yet. Upload photos from unit detail pages.</p>';
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading recent photos:', error);
|
|
document.getElementById('recentPhotosGallery').innerHTML = '<p class="text-sm text-red-500 col-span-full">Failed to load recent photos</p>';
|
|
}
|
|
}
|
|
|
|
// Load recent photos on page load and refresh every 30 seconds
|
|
loadRecentPhotos();
|
|
setInterval(loadRecentPhotos, 30000);
|
|
|
|
// Load and display recent call-ins.
|
|
// Source: SFM events (forwarded by series3-watcher from Blastware ACH).
|
|
// Each event = one call-home. Heartbeat-derived endpoint /api/recent-callins
|
|
// is being phased out but kept as a backup.
|
|
async function loadRecentCallins() {
|
|
const callinsList = document.getElementById('recent-callins-list');
|
|
try {
|
|
const response = await fetch('/api/recent-event-callins?limit=10');
|
|
if (!response.ok) {
|
|
throw new Error('Failed to load recent call-ins');
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (!data.call_ins || data.call_ins.length === 0) {
|
|
callinsList.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No recent event call-ins from SFM</p>';
|
|
return;
|
|
}
|
|
|
|
// Two-column dense grid on lg+, single column below.
|
|
let html = '<div class="grid grid-cols-1 lg:grid-cols-2 gap-x-4 gap-y-1">';
|
|
data.call_ins.forEach(c => {
|
|
const isFalse = c.false_trigger;
|
|
const pvs = c.peak_vector_sum;
|
|
const pvsStr = (pvs !== null && pvs !== undefined)
|
|
? Number(pvs).toFixed(3) + ' in/s'
|
|
: '—';
|
|
|
|
// Subtitle: prefer sensor_location, fallback to project.
|
|
const subtitle = c.sensor_location || c.project || '';
|
|
|
|
// Status dot: amber for false trigger, green for real event,
|
|
// gray if unit not in roster.
|
|
const dotClass = !c.in_roster
|
|
? 'bg-gray-400'
|
|
: (isFalse ? 'bg-amber-400' : 'bg-green-500');
|
|
|
|
// Format event timestamp short (e.g. "05-13 05:00").
|
|
let tsShort = '';
|
|
if (c.event_timestamp) {
|
|
const ts = c.event_timestamp.replace('T', ' ');
|
|
// "2026-05-13 05:00:13" → "05-13 05:00"
|
|
tsShort = ts.length >= 16 ? ts.slice(5, 16) : ts;
|
|
}
|
|
|
|
const unitLink = c.in_roster
|
|
? `<a href="/unit/${c.unit_id}" class="font-medium text-gray-900 dark:text-white hover:text-seismo-orange">${c.unit_id}</a>`
|
|
: `<span class="font-medium text-gray-500 dark:text-gray-400" title="Not in roster">${c.unit_id}</span>`;
|
|
|
|
html += `
|
|
<div class="flex items-center justify-between py-1.5 border-b border-gray-100 dark:border-gray-700/50 last:border-0">
|
|
<div class="flex items-center gap-2 min-w-0 flex-1">
|
|
<span class="w-2 h-2 rounded-full ${dotClass} flex-shrink-0" title="${isFalse ? 'False trigger' : 'Event'}"></span>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex items-center gap-2">
|
|
${unitLink}
|
|
${isFalse ? '<span class="text-[10px] uppercase tracking-wide text-amber-600 dark:text-amber-400">false</span>' : ''}
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">${pvsStr}</span>
|
|
</div>
|
|
${subtitle ? `<p class="text-xs text-gray-500 dark:text-gray-400 truncate" title="${subtitle.replace(/"/g, '"')}">${subtitle}</p>` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="text-right ml-2 flex-shrink-0">
|
|
<span class="text-xs text-gray-600 dark:text-gray-400 block">${c.time_ago}</span>
|
|
${tsShort ? `<span class="text-[10px] text-gray-400 dark:text-gray-500 block font-mono">${tsShort}</span>` : ''}
|
|
</div>
|
|
</div>`;
|
|
});
|
|
html += '</div>';
|
|
callinsList.innerHTML = html;
|
|
} catch (error) {
|
|
console.error('Error loading recent call-ins:', error);
|
|
callinsList.innerHTML = '<p class="text-sm text-red-500">Failed to load recent call-ins</p>';
|
|
}
|
|
}
|
|
|
|
// Load recent call-ins on page load and refresh every 30 seconds.
|
|
loadRecentCallins();
|
|
setInterval(loadRecentCallins, 30000);
|
|
|
|
// ===== Today's Schedule horizontal card =====
|
|
function toggleTodaysSchedule() {
|
|
const content = document.getElementById('todays-actions-content');
|
|
const chevron = document.getElementById('todays-actions-chevron');
|
|
if (!content || !chevron) return;
|
|
const isCollapsed = content.classList.toggle('collapsed');
|
|
chevron.classList.toggle('collapsed', isCollapsed);
|
|
// Remember the user's explicit choice so we don't fight them on the next
|
|
// 30s htmx refresh.
|
|
localStorage.setItem('todaysScheduleUserToggled', '1');
|
|
localStorage.setItem('todaysScheduleCollapsed', isCollapsed ? '1' : '0');
|
|
}
|
|
|
|
function onTodaysActionsSwap(el) {
|
|
// Read pending/total counts from the rendered partial to drive
|
|
// auto-expand + the header badge.
|
|
const badge = document.getElementById('todays-actions-badge');
|
|
const content = document.getElementById('todays-actions-content');
|
|
const chevron = document.getElementById('todays-actions-chevron');
|
|
if (!content || !chevron) return;
|
|
|
|
// Count yellow status indicators in the rendered partial as a proxy for
|
|
// "pending action present today".
|
|
const pendingDots = el.querySelectorAll('.bg-yellow-400').length;
|
|
const pendingTimes = el.querySelectorAll('.text-yellow-600').length;
|
|
const hasPending = pendingDots > 0 || pendingTimes > 0;
|
|
|
|
if (badge) {
|
|
if (hasPending) {
|
|
const n = Math.max(pendingDots, pendingTimes);
|
|
badge.textContent = `${n} pending today`;
|
|
badge.classList.remove('hidden');
|
|
} else {
|
|
badge.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
// Auto-expand only if the user hasn't manually toggled this session AND
|
|
// there's something pending. Once the user collapses/expands manually,
|
|
// their preference sticks.
|
|
const userToggled = localStorage.getItem('todaysScheduleUserToggled') === '1';
|
|
if (!userToggled && hasPending) {
|
|
content.classList.remove('collapsed');
|
|
chevron.classList.remove('collapsed');
|
|
} else if (!userToggled && !hasPending) {
|
|
content.classList.add('collapsed');
|
|
chevron.classList.add('collapsed');
|
|
} else if (userToggled) {
|
|
const stored = localStorage.getItem('todaysScheduleCollapsed') === '1';
|
|
content.classList.toggle('collapsed', stored);
|
|
chevron.classList.toggle('collapsed', stored);
|
|
}
|
|
}
|
|
</script>
|
|
|
|
{% endblock %}
|