SLM config now sync to SLMM, SLMM caches configs for speed
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user