SLM config now sync to SLMM, SLMM caches configs for speed

This commit is contained in:
serversdwn
2026-01-07 18:33:58 +00:00
parent 6d34e543fe
commit c30d7fac22
12 changed files with 1893 additions and 124 deletions

View File

@@ -1,20 +1,20 @@
{% extends "base.html" %}
{% block title %}Fleet Roster - Seismo Fleet Manager{% endblock %}
{% block title %}Devices - Seismo Fleet Manager{% endblock %}
{% block content %}
<div class="mb-8">
<div class="flex justify-between items-start">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Fleet Roster</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">Real-time status of all seismograph units</p>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Devices</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">Manage all devices in your fleet</p>
</div>
<div class="flex gap-3">
<button onclick="openAddUnitModal()" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white 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="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
Add Unit
Add Device
</button>
<button onclick="openImportModal()" class="px-4 py-2 bg-seismo-navy hover:bg-blue-800 text-white rounded-lg flex items-center gap-2 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -31,69 +31,67 @@
<!-- Loading placeholder -->
</div>
<!-- Fleet Roster with Tabs -->
<!-- Devices View with Filters -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
<!-- Search Bar -->
<div class="mb-4">
<!-- Filter Controls -->
<div class="mb-6 space-y-4">
<!-- Search Bar -->
<div class="relative">
<input
type="text"
id="roster-search"
placeholder="Search by Unit ID, Type, or Note..."
id="device-search"
placeholder="Search by Device ID, Type, or Note..."
class="w-full px-4 py-2 pl-10 pr-4 text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-seismo-orange"
onkeyup="filterRosterTable()">
onkeyup="filterDevices()">
<svg class="absolute left-3 top-3 w-5 h-5 text-gray-400" 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>
<!-- Filter Pills -->
<div class="flex flex-wrap gap-3">
<!-- Device Type Filter -->
<div class="flex gap-2">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 self-center">Type:</span>
<button class="filter-btn filter-device-type active-filter" data-value="all">All</button>
<button class="filter-btn filter-device-type" data-value="seismograph">Seismographs</button>
<button class="filter-btn filter-device-type" data-value="modem">Modems</button>
<button class="filter-btn filter-device-type" data-value="sound_level_meter">SLMs</button>
</div>
<!-- Status Filter -->
<div class="flex gap-2">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 self-center">Status:</span>
<button class="filter-btn filter-status active-filter" data-value="all">All</button>
<button class="filter-btn filter-status" data-value="deployed">Deployed</button>
<button class="filter-btn filter-status" data-value="benched">Benched</button>
<button class="filter-btn filter-status" data-value="retired">Retired</button>
<button class="filter-btn filter-status" data-value="ignored">Ignored</button>
</div>
<!-- Health Status Filter (for non-retired/ignored devices) -->
<div class="flex gap-2" id="health-filter-group">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 self-center">Health:</span>
<button class="filter-btn filter-health active-filter" data-value="all">All</button>
<button class="filter-btn filter-health" data-value="ok">OK</button>
<button class="filter-btn filter-health" data-value="pending">Pending</button>
<button class="filter-btn filter-health" data-value="missing">Missing</button>
</div>
</div>
<!-- Results Count -->
<div class="text-sm text-gray-600 dark:text-gray-400">
Showing <span id="visible-count">0</span> of <span id="total-count">0</span> devices
</div>
</div>
<!-- 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 roster-tab-button active-roster-tab"
data-endpoint="/partials/roster-deployed"
hx-get="/partials/roster-deployed"
hx-target="#roster-content"
hx-swap="innerHTML">
Deployed
</button>
<button
class="px-4 py-2 text-sm font-medium roster-tab-button"
data-endpoint="/partials/roster-benched"
hx-get="/partials/roster-benched"
hx-target="#roster-content"
hx-swap="innerHTML">
Benched
</button>
<button
class="px-4 py-2 text-sm font-medium roster-tab-button"
data-endpoint="/partials/roster-retired"
hx-get="/partials/roster-retired"
hx-target="#roster-content"
hx-swap="innerHTML">
Retired
</button>
<button
class="px-4 py-2 text-sm font-medium roster-tab-button"
data-endpoint="/partials/roster-ignored"
hx-get="/partials/roster-ignored"
hx-target="#roster-content"
hx-swap="innerHTML">
Ignored
</button>
</div>
<!-- Tab Content Target -->
<div id="roster-content"
hx-get="/partials/roster-deployed"
<!-- Device List Container -->
<div id="device-content"
hx-get="/partials/devices-all"
hx-trigger="load"
hx-swap="innerHTML">
<p class="text-gray-500 dark:text-gray-400">Loading roster data...</p>
<p class="text-gray-500 dark:text-gray-400">Loading devices...</p>
</div>
</div>
@@ -114,9 +112,9 @@
<form id="addUnitForm" hx-post="/api/roster/add" hx-swap="none" class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Unit ID *</label>
<input type="text" name="id" required
<input type="text" name="id" required pattern="[^\s]+" title="Unit ID cannot contain spaces"
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"
placeholder="BE1234">
placeholder="BE1234 or MODEM-001 (no spaces)">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Device Type *</label>
@@ -550,7 +548,29 @@
// Show success message
alert('Unit added successfully!');
} else {
alert('Error adding unit. Please check the form and try again.');
// Log detailed error information
console.error('Error adding unit:', {
status: event.detail.xhr.status,
response: event.detail.xhr.responseText,
headers: event.detail.xhr.getAllResponseHeaders()
});
// Try to parse error message from response
let errorMsg = 'Error adding unit. Please check the form and try again.';
try {
const response = JSON.parse(event.detail.xhr.responseText);
if (response.detail) {
if (typeof response.detail === 'string') {
errorMsg = response.detail;
} else if (Array.isArray(response.detail)) {
errorMsg = response.detail.map(err => `${err.loc?.join('.')}: ${err.msg}`).join('\n');
}
}
} catch (e) {
console.error('Could not parse error response:', e);
}
alert(errorMsg);
}
});
@@ -904,33 +924,203 @@
}
}
// Filter roster table based on search input
function filterRosterTable() {
const searchInput = document.getElementById('roster-search').value.toLowerCase();
const table = document.querySelector('#roster-content table tbody');
// ===== DEVICE FILTERING SYSTEM =====
if (!table) return;
// Current active filters
let activeFilters = {
deviceType: 'all',
status: 'all',
health: 'all',
search: ''
};
const rows = table.getElementsByTagName('tr');
// Initialize filter button click handlers
document.addEventListener('DOMContentLoaded', function() {
// Device type filter buttons
document.querySelectorAll('.filter-device-type').forEach(btn => {
btn.addEventListener('click', function() {
// Update active state
document.querySelectorAll('.filter-device-type').forEach(b => b.classList.remove('active-filter'));
this.classList.add('active-filter');
for (let row of rows) {
const cells = row.getElementsByTagName('td');
if (cells.length === 0) continue; // Skip header or empty rows
// Update filter value
activeFilters.deviceType = this.dataset.value;
const unitId = cells[1]?.textContent?.toLowerCase() || '';
const unitType = cells[2]?.textContent?.toLowerCase() || '';
const note = cells[6]?.textContent?.toLowerCase() || '';
// Apply filters
filterDevices();
});
});
const matches = unitId.includes(searchInput) ||
unitType.includes(searchInput) ||
note.includes(searchInput);
// Status filter buttons
document.querySelectorAll('.filter-status').forEach(btn => {
btn.addEventListener('click', function() {
// Update active state
document.querySelectorAll('.filter-status').forEach(b => b.classList.remove('active-filter'));
this.classList.add('active-filter');
row.style.display = matches ? '' : 'none';
// Update filter value
activeFilters.status = this.dataset.value;
// Toggle health filter visibility (hide for retired/ignored)
const healthGroup = document.getElementById('health-filter-group');
if (this.dataset.value === 'retired' || this.dataset.value === 'ignored') {
healthGroup.style.display = 'none';
} else {
healthGroup.style.display = 'flex';
}
// Apply filters
filterDevices();
});
});
// Health status filter buttons
document.querySelectorAll('.filter-health').forEach(btn => {
btn.addEventListener('click', function() {
// Update active state
document.querySelectorAll('.filter-health').forEach(b => b.classList.remove('active-filter'));
this.classList.add('active-filter');
// Update filter value
activeFilters.health = this.dataset.value;
// Apply filters
filterDevices();
});
});
});
// Main filter function - filters devices based on all active criteria
function filterDevices() {
const searchInput = document.getElementById('device-search')?.value.toLowerCase() || '';
activeFilters.search = searchInput;
const table = document.querySelector('#device-content table tbody');
const cards = document.querySelectorAll('#device-content .device-card'); // For mobile view
let visibleCount = 0;
let totalCount = 0;
// Filter table rows (desktop view)
if (table) {
const rows = table.getElementsByTagName('tr');
totalCount = rows.length;
for (let row of rows) {
const cells = row.getElementsByTagName('td');
if (cells.length === 0) continue;
// Extract row data (adjust indices based on your table structure)
const status = cells[0]?.querySelector('.status-badge')?.textContent?.toLowerCase() || '';
const deviceId = cells[1]?.textContent?.toLowerCase() || '';
const deviceType = cells[2]?.textContent?.toLowerCase() || '';
const note = cells[6]?.textContent?.toLowerCase() || '';
// Get data attributes for filtering
const rowDeviceType = row.dataset.deviceType || '';
const rowStatus = row.dataset.status || '';
const rowHealth = row.dataset.health || '';
// Apply filters
const matchesSearch = !searchInput ||
deviceId.includes(searchInput) ||
deviceType.includes(searchInput) ||
note.includes(searchInput);
const matchesDeviceType = activeFilters.deviceType === 'all' ||
rowDeviceType === activeFilters.deviceType;
const matchesStatus = activeFilters.status === 'all' ||
rowStatus === activeFilters.status;
const matchesHealth = activeFilters.health === 'all' ||
rowHealth === activeFilters.health ||
activeFilters.status === 'retired' ||
activeFilters.status === 'ignored';
const isVisible = matchesSearch && matchesDeviceType && matchesStatus && matchesHealth;
row.style.display = isVisible ? '' : 'none';
if (isVisible) visibleCount++;
}
}
// Filter cards (mobile view)
if (cards.length > 0) {
totalCount = cards.length;
visibleCount = 0;
cards.forEach(card => {
const cardDeviceType = card.dataset.deviceType || '';
const cardStatus = card.dataset.status || '';
const cardHealth = card.dataset.health || '';
const cardText = card.textContent.toLowerCase();
const matchesSearch = !searchInput || cardText.includes(searchInput);
const matchesDeviceType = activeFilters.deviceType === 'all' || cardDeviceType === activeFilters.deviceType;
const matchesStatus = activeFilters.status === 'all' || cardStatus === activeFilters.status;
const matchesHealth = activeFilters.health === 'all' || cardHealth === activeFilters.health;
const isVisible = matchesSearch && matchesDeviceType && matchesStatus && matchesHealth;
card.style.display = isVisible ? '' : 'none';
if (isVisible) visibleCount++;
});
}
// Update count display
document.getElementById('visible-count').textContent = visibleCount;
document.getElementById('total-count').textContent = totalCount;
}
// Legacy function name for compatibility
function filterRosterTable() {
filterDevices();
}
</script>
<style>
/* Filter Button Styles */
.filter-btn {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.5rem;
transition: all 0.2s;
background-color: #f3f4f6; /* gray-100 */
color: #6b7280; /* gray-500 */
border: 1px solid #e5e7eb; /* gray-200 */
cursor: pointer;
}
.filter-btn:hover {
background-color: #e5e7eb; /* gray-200 */
color: #374151; /* gray-700 */
}
.filter-btn.active-filter {
background-color: #f48b1c; /* seismo-orange */
color: white;
border-color: #f48b1c;
}
/* Dark mode filter buttons */
@media (prefers-color-scheme: dark) {
.filter-btn {
background-color: #374151; /* gray-700 */
color: #9ca3af; /* gray-400 */
border-color: #4b5563; /* gray-600 */
}
.filter-btn:hover {
background-color: #4b5563; /* gray-600 */
color: #e5e7eb; /* gray-200 */
}
.filter-btn.active-filter {
background-color: #f48b1c;
color: white;
border-color: #f48b1c;
}
}
/* Legacy tab button styles (keeping for modals and other uses) */
.roster-tab-button {
color: #6b7280; /* gray-500 */
border-bottom: 2px solid transparent;