Merge dev v0.5.1 before 0.6 update with calender. #25

Merged
serversdown merged 11 commits from dev into main 2026-02-06 14:56:13 -05:00
3 changed files with 476 additions and 7 deletions
Showing only changes of commit 8373cff10d - Show all commits

View File

@@ -284,3 +284,146 @@ async def get_modem_diagnostics(modem_id: str, db: Session = Depends(get_db)):
"carrier": None, "carrier": None,
"connection_type": None # LTE, 5G, etc. "connection_type": None # LTE, 5G, etc.
} }
@router.get("/{modem_id}/pairable-devices")
async def get_pairable_devices(
modem_id: str,
db: Session = Depends(get_db),
search: str = Query(None),
hide_paired: bool = Query(True)
):
"""
Get list of devices (seismographs and SLMs) that can be paired with this modem.
Used by the device picker modal in unit_detail.html.
"""
# Check modem exists
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
if not modem:
return {"status": "error", "detail": f"Modem {modem_id} not found"}
# Query seismographs and SLMs
query = db.query(RosterUnit).filter(
RosterUnit.device_type.in_(["seismograph", "sound_level_meter"]),
RosterUnit.retired == False
)
# Filter by search term if provided
if search:
search_term = f"%{search}%"
query = query.filter(
(RosterUnit.id.ilike(search_term)) |
(RosterUnit.project_id.ilike(search_term)) |
(RosterUnit.location.ilike(search_term)) |
(RosterUnit.address.ilike(search_term)) |
(RosterUnit.note.ilike(search_term))
)
devices = query.order_by(
RosterUnit.deployed.desc(),
RosterUnit.device_type.asc(),
RosterUnit.id.asc()
).all()
# Build device list
device_list = []
for device in devices:
# Skip already paired devices if hide_paired is True
is_paired_to_other = (
device.deployed_with_modem_id is not None and
device.deployed_with_modem_id != modem_id
)
is_paired_to_this = device.deployed_with_modem_id == modem_id
if hide_paired and is_paired_to_other:
continue
device_list.append({
"id": device.id,
"device_type": device.device_type,
"deployed": device.deployed,
"project_id": device.project_id,
"location": device.location or device.address,
"note": device.note,
"paired_modem_id": device.deployed_with_modem_id,
"is_paired_to_this": is_paired_to_this,
"is_paired_to_other": is_paired_to_other
})
return {"devices": device_list, "modem_id": modem_id}
@router.post("/{modem_id}/pair")
async def pair_device_to_modem(
modem_id: str,
db: Session = Depends(get_db),
device_id: str = Query(..., description="ID of the device to pair")
):
"""
Pair a device (seismograph or SLM) to this modem.
Updates the device's deployed_with_modem_id field.
"""
# Check modem exists
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
if not modem:
return {"status": "error", "detail": f"Modem {modem_id} not found"}
# Find the device
device = db.query(RosterUnit).filter(
RosterUnit.id == device_id,
RosterUnit.device_type.in_(["seismograph", "sound_level_meter"]),
RosterUnit.retired == False
).first()
if not device:
return {"status": "error", "detail": f"Device {device_id} not found"}
# Unpair any device currently paired to this modem
currently_paired = db.query(RosterUnit).filter(
RosterUnit.deployed_with_modem_id == modem_id
).all()
for paired_device in currently_paired:
paired_device.deployed_with_modem_id = None
# Pair the new device
device.deployed_with_modem_id = modem_id
db.commit()
return {
"status": "success",
"modem_id": modem_id,
"device_id": device_id,
"message": f"Device {device_id} paired to modem {modem_id}"
}
@router.post("/{modem_id}/unpair")
async def unpair_device_from_modem(modem_id: str, db: Session = Depends(get_db)):
"""
Unpair any device currently paired to this modem.
"""
# Check modem exists
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
if not modem:
return {"status": "error", "detail": f"Modem {modem_id} not found"}
# Find and unpair device
device = db.query(RosterUnit).filter(
RosterUnit.deployed_with_modem_id == modem_id
).first()
if device:
old_device_id = device.id
device.deployed_with_modem_id = None
db.commit()
return {
"status": "success",
"modem_id": modem_id,
"unpaired_device_id": old_device_id,
"message": f"Device {old_device_id} unpaired from modem {modem_id}"
}
return {
"status": "success",
"modem_id": modem_id,
"message": "No device was paired to this modem"
}

View File

@@ -2,7 +2,7 @@
{% if device %} {% if device %}
<div class="flex items-center gap-4 p-4 bg-green-50 dark:bg-green-900/20 rounded-lg"> <div class="flex items-center gap-4 p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div class="bg-green-100 dark:bg-green-900/30 p-3 rounded-lg"> <div class="bg-green-100 dark:bg-green-900/30 p-3 rounded-lg">
{% if device.device_type == "slm" %} {% if device.device_type == "slm" or device.device_type == "sound_level_meter" %}
<svg class="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6 text-green-600 dark:text-green-400" 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> <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>
@@ -18,7 +18,7 @@
{{ device.id }} {{ device.id }}
</a> </a>
<div class="flex items-center gap-2 mt-1 text-sm text-gray-600 dark:text-gray-400"> <div class="flex items-center gap-2 mt-1 text-sm text-gray-600 dark:text-gray-400">
<span class="capitalize">{{ device.device_type }}</span> <span class="capitalize">{{ device.device_type | replace("_", " ") }}</span>
{% if device.project_id %} {% if device.project_id %}
<span class="text-gray-400">|</span> <span class="text-gray-400">|</span>
<span>{{ device.project_id }}</span> <span>{{ device.project_id }}</span>
@@ -30,12 +30,18 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="flex items-center gap-2">
<button onclick="openModemPairDeviceModal()"
class="px-3 py-1.5 text-sm bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors">
Edit Pairing
</button>
<a href="/unit/{{ device.id }}" class="text-gray-400 hover:text-seismo-orange transition-colors"> <a href="/unit/{{ device.id }}" class="text-gray-400 hover:text-seismo-orange transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg> </svg>
</a> </a>
</div> </div>
</div>
{% else %} {% else %}
<div class="flex items-center gap-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg"> <div class="flex items-center gap-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="bg-gray-200 dark:bg-gray-700 p-3 rounded-lg"> <div class="bg-gray-200 dark:bg-gray-700 p-3 rounded-lg">
@@ -47,5 +53,12 @@
<p class="text-gray-600 dark:text-gray-400">No device currently paired</p> <p class="text-gray-600 dark:text-gray-400">No device currently paired</p>
<p class="text-sm text-gray-500 dark:text-gray-500">This modem is available for assignment</p> <p class="text-sm text-gray-500 dark:text-gray-500">This modem is available for assignment</p>
</div> </div>
<button onclick="openModemPairDeviceModal()"
class="px-4 py-2 text-sm bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors flex items-center gap-2">
<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="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>
Pair Device
</button>
</div> </div>
{% endif %} {% endif %}

View File

@@ -1571,6 +1571,242 @@ function clearPairing(deviceType) {
closePairDeviceModal(); closePairDeviceModal();
} }
// ===== Modem Pair Device Modal Functions (for modems to pick a device) =====
let modemPairDevices = []; // Cache loaded devices
let modemHasCurrentPairing = false;
function openModemPairDeviceModal() {
const modal = document.getElementById('modemPairDeviceModal');
const searchInput = document.getElementById('modemPairDeviceSearch');
if (!modal) return;
modal.classList.remove('hidden');
if (searchInput) {
searchInput.value = '';
searchInput.focus();
}
// Reset checkboxes
document.getElementById('modemPairHidePaired').checked = true;
document.getElementById('modemPairShowSeismo').checked = true;
document.getElementById('modemPairShowSLM').checked = true;
// Load available devices
loadPairableDevices();
}
function closeModemPairDeviceModal() {
const modal = document.getElementById('modemPairDeviceModal');
if (modal) modal.classList.add('hidden');
modemPairDevices = [];
}
async function loadPairableDevices() {
const listContainer = document.getElementById('modemPairDeviceList');
listContainer.innerHTML = '<div class="text-center py-8"><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 devices...</p></div>';
try {
const response = await fetch(`/api/modem-dashboard/${unitId}/pairable-devices?hide_paired=false`);
if (!response.ok) throw new Error('Failed to load devices');
const data = await response.json();
modemPairDevices = data.devices || [];
// Check if modem has a current pairing
modemHasCurrentPairing = modemPairDevices.some(d => d.is_paired_to_this);
const unpairBtn = document.getElementById('modemUnpairBtn');
if (unpairBtn) {
unpairBtn.classList.toggle('hidden', !modemHasCurrentPairing);
}
if (modemPairDevices.length === 0) {
listContainer.innerHTML = '<p class="text-center py-8 text-gray-500">No devices found in roster</p>';
return;
}
renderModemPairDeviceList();
} catch (error) {
console.error('Failed to load pairable devices:', error);
listContainer.innerHTML = '<p class="text-center py-8 text-red-500">Failed to load devices</p>';
}
}
function filterModemPairDeviceList() {
renderModemPairDeviceList();
}
function renderModemPairDeviceList() {
const listContainer = document.getElementById('modemPairDeviceList');
const searchInput = document.getElementById('modemPairDeviceSearch');
const hidePairedCheckbox = document.getElementById('modemPairHidePaired');
const showSeismoCheckbox = document.getElementById('modemPairShowSeismo');
const showSLMCheckbox = document.getElementById('modemPairShowSLM');
const searchTerm = searchInput?.value?.toLowerCase() || '';
const hidePaired = hidePairedCheckbox?.checked ?? true;
const showSeismo = showSeismoCheckbox?.checked ?? true;
const showSLM = showSLMCheckbox?.checked ?? true;
// Filter devices
let filteredDevices = modemPairDevices.filter(device => {
// Filter by device type
if (device.device_type === 'seismograph' && !showSeismo) return false;
if (device.device_type === 'sound_level_meter' && !showSLM) return false;
// Hide devices paired to OTHER modems (but show unpaired and paired-to-this)
if (hidePaired && device.is_paired_to_other) return false;
// Search filter
if (searchTerm) {
const searchFields = [
device.id,
device.project_id || '',
device.location || '',
device.note || ''
].join(' ').toLowerCase();
if (!searchFields.includes(searchTerm)) return false;
}
return true;
});
if (filteredDevices.length === 0) {
listContainer.innerHTML = '<p class="text-center py-8 text-gray-500">No devices match your criteria</p>';
return;
}
// Build device list HTML
let html = '<div class="divide-y divide-gray-200 dark:divide-gray-700">';
for (const device of filteredDevices) {
const deviceTypeLabel = device.device_type === 'sound_level_meter' ? 'SLM' : 'Seismograph';
const deviceTypeClass = device.device_type === 'sound_level_meter'
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300'
: 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300';
const deployedBadge = device.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-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-300 text-xs rounded">Benched</span>';
let pairingBadge = '';
if (device.is_paired_to_this) {
pairingBadge = '<span class="px-2 py-0.5 bg-seismo-orange/20 text-seismo-orange text-xs rounded font-medium">Current</span>';
} else if (device.is_paired_to_other) {
pairingBadge = `<span class="px-2 py-0.5 bg-gray-200 dark:bg-gray-600 text-gray-600 dark:text-gray-300 text-xs rounded">Paired: ${device.paired_modem_id}</span>`;
}
const isCurrentlyPaired = device.is_paired_to_this;
html += `
<div class="px-6 py-3 hover:bg-gray-50 dark:hover:bg-slate-700 cursor-pointer transition-colors ${isCurrentlyPaired ? 'bg-seismo-orange/5' : ''}"
onclick="selectDeviceForModem('${device.id}')">
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900 dark:text-white">${device.id}</span>
<span class="px-2 py-0.5 ${deviceTypeClass} text-xs rounded">${deviceTypeLabel}</span>
</div>
${device.project_id ? `<div class="text-sm text-gray-500 dark:text-gray-400">${device.project_id}</div>` : ''}
${device.location ? `<div class="text-sm text-gray-500 dark:text-gray-400 truncate">${device.location}</div>` : ''}
</div>
<div class="flex items-center gap-2 ml-4">
${deployedBadge}
${pairingBadge}
</div>
</div>
</div>
`;
}
html += '</div>';
listContainer.innerHTML = html;
}
async function selectDeviceForModem(deviceId) {
const listContainer = document.getElementById('modemPairDeviceList');
// Show loading state
listContainer.innerHTML = '<div class="text-center py-8"><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">Pairing device...</p></div>';
try {
const response = await fetch(`/api/modem-dashboard/${unitId}/pair?device_id=${encodeURIComponent(deviceId)}`, {
method: 'POST'
});
const result = await response.json();
if (result.status === 'success') {
closeModemPairDeviceModal();
// Reload the paired device section
loadPairedDevice();
// Show success message (optional toast)
showToast(`Paired with ${deviceId}`, 'success');
} else {
listContainer.innerHTML = `<p class="text-center py-8 text-red-500">${result.detail || 'Failed to pair device'}</p>`;
}
} catch (error) {
console.error('Failed to pair device:', error);
listContainer.innerHTML = '<p class="text-center py-8 text-red-500">Failed to pair device</p>';
}
}
async function unpairDeviceFromModem() {
const listContainer = document.getElementById('modemPairDeviceList');
// Show loading state
listContainer.innerHTML = '<div class="text-center py-8"><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">Unpairing device...</p></div>';
try {
const response = await fetch(`/api/modem-dashboard/${unitId}/unpair`, {
method: 'POST'
});
const result = await response.json();
if (result.status === 'success') {
closeModemPairDeviceModal();
// Reload the paired device section
loadPairedDevice();
// Show success message
if (result.unpaired_device_id) {
showToast(`Unpaired ${result.unpaired_device_id}`, 'success');
} else {
showToast('No device was paired', 'info');
}
} else {
listContainer.innerHTML = `<p class="text-center py-8 text-red-500">${result.detail || 'Failed to unpair device'}</p>`;
}
} catch (error) {
console.error('Failed to unpair device:', error);
listContainer.innerHTML = '<p class="text-center py-8 text-red-500">Failed to unpair device</p>';
}
}
// Simple toast function (if not already defined)
function showToast(message, type = 'info') {
// Check if there's already a toast container
let toastContainer = document.getElementById('toast-container');
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.id = 'toast-container';
toastContainer.className = 'fixed bottom-4 right-4 z-50 space-y-2';
document.body.appendChild(toastContainer);
}
const toast = document.createElement('div');
const bgColor = type === 'success' ? 'bg-green-500' : type === 'error' ? 'bg-red-500' : 'bg-gray-700';
toast.className = `${bgColor} text-white px-4 py-2 rounded-lg shadow-lg transform transition-all duration-300 translate-x-0`;
toast.textContent = message;
toastContainer.appendChild(toast);
// Auto-remove after 3 seconds
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-x-full');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
</script> </script>
<!-- Pair Device Modal --> <!-- Pair Device Modal -->
@@ -1637,4 +1873,81 @@ function clearPairing(deviceType) {
<!-- Include Project Create Modal for inline project creation --> <!-- Include Project Create Modal for inline project creation -->
{% include "partials/project_create_modal.html" %} {% include "partials/project_create_modal.html" %}
<!-- Modem Pair Device Modal (for modems to pick a device) -->
<div id="modemPairDeviceModal" 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="closeModemPairDeviceModal()"></div>
<!-- Modal Content -->
<div class="relative bg-white dark:bg-slate-800 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] flex flex-col">
<!-- 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 Device to Modem</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Select a seismograph or SLM to pair</p>
</div>
<button onclick="closeModemPairDeviceModal()" 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 Filters -->
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 space-y-3">
<input type="text"
placeholder="Search by ID, project, location..."
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange focus:border-transparent"
id="modemPairDeviceSearch"
autocomplete="off"
oninput="filterModemPairDeviceList()">
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 cursor-pointer">
<input type="checkbox"
id="modemPairHidePaired"
onchange="filterModemPairDeviceList()"
checked
class="rounded border-gray-300 dark:border-gray-600 text-seismo-orange focus:ring-seismo-orange">
Hide paired devices
</label>
<label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 cursor-pointer">
<input type="checkbox"
id="modemPairShowSeismo"
onchange="filterModemPairDeviceList()"
checked
class="rounded border-gray-300 dark:border-gray-600 text-seismo-orange focus:ring-seismo-orange">
Seismographs
</label>
<label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 cursor-pointer">
<input type="checkbox"
id="modemPairShowSLM"
onchange="filterModemPairDeviceList()"
checked
class="rounded border-gray-300 dark:border-gray-600 text-seismo-orange focus:ring-seismo-orange">
SLMs
</label>
</div>
</div>
<!-- Device List -->
<div id="modemPairDeviceList" class="flex-1 overflow-y-auto max-h-80">
<!-- 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 flex gap-3">
<button onclick="unpairDeviceFromModem()"
id="modemUnpairBtn"
class="hidden px-4 py-2 bg-red-100 hover:bg-red-200 dark:bg-red-900/30 dark:hover:bg-red-900/50 text-red-700 dark:text-red-300 rounded-lg transition-colors">
Unpair Current
</button>
<button onclick="closeModemPairDeviceModal()" class="flex-1 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>
{% endblock %} {% endblock %}