Fetches clients for every AP in parallel using the existing /api/ap-clients endpoint, then keeps only APs where at least one client has an IP with last octet 150–155. Shows a loading spinner while fetching. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
586 lines
29 KiB
HTML
586 lines
29 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block title %}Access Points – Salus by Stranto{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="space-y-6">
|
||
|
||
<!-- Header row -->
|
||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||
<div>
|
||
<h1 class="text-2xl font-bold text-gray-900">Access Points</h1>
|
||
<p class="text-sm text-gray-500 mt-0.5">Live from Omada Controller · <span id="visible-count">{{ aps|length }}</span> of {{ aps|length }} device{{ 's' if aps|length != 1 }} across all sites</p>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<div class="relative">
|
||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none"
|
||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/>
|
||
</svg>
|
||
<input id="filter-name" type="text" placeholder="Filter by name or site…"
|
||
class="pl-9 pr-3 py-2 text-sm rounded-lg bg-white border border-gray-300 text-gray-900 placeholder-gray-400
|
||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent w-52" />
|
||
</div>
|
||
<button id="btn-refresh"
|
||
class="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-white hover:bg-gray-50 border border-gray-300 text-gray-600 transition-colors">
|
||
<svg id="refresh-icon" 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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||
</svg>
|
||
Refresh
|
||
</button>
|
||
<button id="btn-ip-filter"
|
||
class="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-white hover:bg-gray-50 border border-gray-300 text-gray-600 transition-colors">
|
||
<svg id="ip-filter-icon" class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2a1 1 0 01-.293.707L13 13.414V19a1 1 0 01-.553.894l-4 2A1 1 0 017 21v-7.586L3.293 6.707A1 1 0 013 6V4z"/>
|
||
</svg>
|
||
<span id="ip-filter-label">Filter .150–.155</span>
|
||
</button>
|
||
<button id="btn-reboot-selected" disabled
|
||
class="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-red-600 hover:bg-red-500 disabled:opacity-40 disabled:cursor-not-allowed border border-red-500 text-white transition-colors">
|
||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||
</svg>
|
||
Reboot selected (<span id="sel-count">0</span>)
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{% if error %}
|
||
<div class="flex items-start gap-3 p-4 rounded-xl bg-red-50 border border-red-200 text-red-700 text-sm">
|
||
<svg class="w-5 h-5 flex-shrink-0 mt-0.5 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"/>
|
||
</svg>
|
||
<div>
|
||
<p class="font-semibold">Failed to connect to Omada Controller</p>
|
||
<p class="mt-0.5 text-red-600">{{ error }}</p>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% if aps %}
|
||
<!-- Table -->
|
||
<div class="overflow-x-auto rounded-xl border border-gray-200 shadow-sm">
|
||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||
<thead class="bg-gray-50">
|
||
<tr>
|
||
<th class="px-4 py-3 text-left w-10">
|
||
<input type="checkbox" id="chk-all"
|
||
class="rounded border-gray-300 bg-white text-blue-500 focus:ring-blue-500 cursor-pointer" />
|
||
</th>
|
||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Name</th>
|
||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Site</th>
|
||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">IP Address</th>
|
||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">MAC Address</th>
|
||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Model</th>
|
||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Status</th>
|
||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Uptime</th>
|
||
<th class="px-4 py-3 text-right font-semibold text-gray-500 uppercase tracking-wide text-xs">Action</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-gray-200 bg-white" id="ap-table-body">
|
||
{% for ap in aps %}
|
||
{% set online = ap.get('statusCategory', 0) == 1 %}
|
||
{% set mac = ap.get('mac', '') %}
|
||
{% set name = ap.get('name', 'Unknown') %}
|
||
{% set ip = ap.get('ip', '') %}
|
||
{% set site_name = ap.get('_site_name', '') %}
|
||
{% set site_key = ap.get('_site_key', '') %}
|
||
{% set uptime_secs = ap.get('uptimeLong', 0) | int %}
|
||
{% set reboot_allowed = online and uptime_secs >= 300 %}
|
||
<tr class="hover:bg-gray-50 transition-colors">
|
||
<td class="px-4 py-3">
|
||
<input type="checkbox" class="ap-checkbox rounded border-gray-300 bg-white text-blue-500 focus:ring-blue-500 cursor-pointer
|
||
{% if not reboot_allowed %}opacity-40 cursor-not-allowed{% endif %}"
|
||
data-mac="{{ mac }}" data-name="{{ name }}" data-ip="{{ ip }}" data-site-key="{{ site_key }}"
|
||
{% if not reboot_allowed %}disabled
|
||
title="{% if not online %}AP is offline{% else %}Uptime too low — minimum 5 minutes required{% endif %}"
|
||
{% endif %} />
|
||
</td>
|
||
<td class="px-4 py-3 font-medium text-gray-900 cursor-pointer select-none group"
|
||
data-action="view-clients" data-mac="{{ mac }}" data-name="{{ name }}" data-site-key="{{ site_key }}">
|
||
<span class="flex items-center gap-1.5 group-hover:text-blue-600 transition-colors">
|
||
{{ name }}
|
||
<svg class="w-3.5 h-3.5 text-gray-300 group-hover:text-blue-400 transition-colors flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
|
||
</svg>
|
||
</span>
|
||
</td>
|
||
<td class="px-4 py-3 text-gray-500 text-xs">{{ site_name }}</td>
|
||
<td class="px-4 py-3 text-gray-500 font-mono text-xs">{{ ip or '—' }}</td>
|
||
<td class="px-4 py-3 text-gray-500 font-mono text-xs">{{ mac or '—' }}</td>
|
||
<td class="px-4 py-3 text-gray-500">{{ ap.get('model', '—') }}</td>
|
||
<td class="px-4 py-3">
|
||
{% if online %}
|
||
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700 border border-green-200">
|
||
<span class="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse"></span>
|
||
Online
|
||
</span>
|
||
{% else %}
|
||
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500 border border-gray-200">
|
||
<span class="w-1.5 h-1.5 rounded-full bg-gray-400"></span>
|
||
Offline
|
||
</span>
|
||
{% endif %}
|
||
</td>
|
||
<td class="px-4 py-3 text-gray-500 text-xs">
|
||
{% set up = ap.get('uptimeLong', 0) | int %}
|
||
{% if up %}
|
||
{{ up // 86400 }}d {{ (up % 86400) // 3600 }}h {{ (up % 3600) // 60 }}m
|
||
{% else %}—{% endif %}
|
||
</td>
|
||
<td class="px-4 py-3 text-right">
|
||
<button
|
||
class="btn-reboot px-3 py-1.5 text-xs rounded-lg font-medium transition-colors
|
||
{% if reboot_allowed %}bg-red-600 hover:bg-red-500 border border-red-500 text-white cursor-pointer
|
||
{% else %}bg-gray-100 border border-gray-200 text-gray-400 cursor-not-allowed opacity-50{% endif %}"
|
||
data-mac="{{ mac }}" data-name="{{ name }}" data-ip="{{ ip }}" data-site-key="{{ site_key }}"
|
||
{% if not reboot_allowed %}disabled
|
||
title="{% if not online %}AP is offline{% else %}Uptime too low ({{ uptime_secs // 60 }}m {{ uptime_secs % 60 }}s) — minimum 5 minutes required{% endif %}"
|
||
{% endif %}>
|
||
Reboot
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{% elif not error %}
|
||
<div class="flex flex-col items-center justify-center py-20 text-gray-400">
|
||
<svg class="w-12 h-12 mb-4 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||
d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.14 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"/>
|
||
</svg>
|
||
<p class="text-lg font-medium">No access points found</p>
|
||
<p class="text-sm mt-1">Check your Omada site name configuration.</p>
|
||
</div>
|
||
{% endif %}
|
||
|
||
</div>
|
||
|
||
<!-- Clients Modal -->
|
||
<div id="clients-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4">
|
||
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" id="clients-backdrop"></div>
|
||
<div class="relative bg-white border border-gray-200 rounded-2xl shadow-xl w-full max-w-4xl max-h-[80vh] flex flex-col">
|
||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 flex-shrink-0">
|
||
<div>
|
||
<h2 class="text-lg font-semibold text-gray-900" id="clients-modal-title">Connected Clients</h2>
|
||
<p class="text-sm text-gray-500 mt-0.5" id="clients-modal-subtitle"></p>
|
||
</div>
|
||
<button id="clients-close" class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-600 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="M6 18L18 6M6 6l12 12"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="flex-1 overflow-auto" id="clients-body"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Confirmation Modal -->
|
||
<div id="modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4">
|
||
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" id="modal-backdrop"></div>
|
||
<div class="relative bg-white border border-gray-200 rounded-2xl shadow-xl w-full max-w-md p-6">
|
||
<div class="flex items-center gap-3 mb-4">
|
||
<div class="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
|
||
<svg class="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
|
||
</svg>
|
||
</div>
|
||
<h2 class="text-lg font-semibold text-gray-900">Confirm Reboot</h2>
|
||
</div>
|
||
<p class="text-gray-600 text-sm mb-6" id="modal-message">Are you sure?</p>
|
||
<div class="flex gap-3 justify-end">
|
||
<button id="modal-cancel"
|
||
class="px-4 py-2 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 border border-gray-200 text-gray-700 transition-colors">
|
||
Cancel
|
||
</button>
|
||
<button id="modal-confirm"
|
||
class="px-4 py-2 text-sm rounded-lg bg-red-600 hover:bg-red-500 border border-red-500 text-white font-semibold transition-colors">
|
||
Reboot
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block scripts %}
|
||
<script>
|
||
const CSRF_TOKEN = {{ csrf_token | tojson }};
|
||
|
||
// ── Filters (name/site + IP range) ────────────────────────────────────────
|
||
const filterInput = document.getElementById('filter-name');
|
||
const visibleCount = document.getElementById('visible-count');
|
||
const allRows = [...document.querySelectorAll('#ap-table-body tr')];
|
||
|
||
let ipFilterActive = false;
|
||
let ipMatchedMacs = new Set(); // AP MACs that have a client with a .150–.155 IP
|
||
|
||
function isTargetIp(ip) {
|
||
if (!ip) return false;
|
||
const last = parseInt((ip.split('.')[3] || ''), 10);
|
||
return last >= 150 && last <= 155;
|
||
}
|
||
|
||
function normMac(s) { return (s || '').toUpperCase().replace(/-/g, ':'); }
|
||
|
||
function applyFilters() {
|
||
const q = filterInput.value.toLowerCase().trim();
|
||
let shown = 0;
|
||
allRows.forEach(row => {
|
||
const name = (row.querySelector('td:nth-child(2)')?.textContent || '').toLowerCase();
|
||
const site = (row.querySelector('td:nth-child(3)')?.textContent || '').toLowerCase();
|
||
const nameMatch = !q || name.includes(q) || site.includes(q);
|
||
|
||
let ipMatch = true;
|
||
if (ipFilterActive) {
|
||
const cb = row.querySelector('.ap-checkbox');
|
||
ipMatch = ipMatchedMacs.has(normMac(cb?.dataset.mac));
|
||
}
|
||
|
||
const visible = nameMatch && ipMatch;
|
||
row.style.display = visible ? '' : 'none';
|
||
if (visible) shown++;
|
||
});
|
||
visibleCount.textContent = shown;
|
||
updateBulkButton();
|
||
}
|
||
|
||
filterInput.addEventListener('input', applyFilters);
|
||
|
||
// ── IP range filter button ─────────────────────────────────────────────────
|
||
const btnIpFilter = document.getElementById('btn-ip-filter');
|
||
const ipFilterLabel = document.getElementById('ip-filter-label');
|
||
const ipFilterIcon = document.getElementById('ip-filter-icon');
|
||
|
||
function setIpFilterActive(active) {
|
||
ipFilterActive = active;
|
||
if (active) {
|
||
btnIpFilter.classList.replace('bg-white', 'bg-blue-50');
|
||
btnIpFilter.classList.replace('hover:bg-gray-50', 'hover:bg-blue-100');
|
||
btnIpFilter.classList.replace('border-gray-300', 'border-blue-400');
|
||
btnIpFilter.classList.replace('text-gray-600', 'text-blue-700');
|
||
ipFilterLabel.textContent = '✕ Clear filter';
|
||
} else {
|
||
btnIpFilter.classList.replace('bg-blue-50', 'bg-white');
|
||
btnIpFilter.classList.replace('hover:bg-blue-100', 'hover:bg-gray-50');
|
||
btnIpFilter.classList.replace('border-blue-400', 'border-gray-300');
|
||
btnIpFilter.classList.replace('text-blue-700', 'text-gray-600');
|
||
ipFilterLabel.textContent = 'Filter .150–.155';
|
||
}
|
||
}
|
||
|
||
btnIpFilter?.addEventListener('click', async () => {
|
||
if (ipFilterActive) {
|
||
ipMatchedMacs.clear();
|
||
setIpFilterActive(false);
|
||
applyFilters();
|
||
return;
|
||
}
|
||
|
||
btnIpFilter.disabled = true;
|
||
ipFilterLabel.textContent = 'Loading…';
|
||
ipFilterIcon.classList.add('animate-spin');
|
||
|
||
try {
|
||
// Collect each AP's mac + site_key from the DOM checkboxes
|
||
const apList = [];
|
||
allRows.forEach(row => {
|
||
const cb = row.querySelector('.ap-checkbox');
|
||
if (cb?.dataset.mac && cb?.dataset.siteKey) {
|
||
apList.push({ mac: cb.dataset.mac, siteKey: cb.dataset.siteKey });
|
||
}
|
||
});
|
||
|
||
// Fetch clients for every AP in parallel (reuses the same endpoint as the popup)
|
||
const hits = await Promise.all(apList.map(async ({ mac, siteKey }) => {
|
||
try {
|
||
const res = await fetch(`/api/ap-clients?mac=${encodeURIComponent(mac)}&site_key=${encodeURIComponent(siteKey)}`);
|
||
if (!res.ok) return null;
|
||
const { clients } = await res.json();
|
||
return (clients || []).some(c => isTargetIp(c.ip)) ? normMac(mac) : null;
|
||
} catch { return null; }
|
||
}));
|
||
|
||
ipMatchedMacs = new Set(hits.filter(Boolean));
|
||
setIpFilterActive(true);
|
||
applyFilters();
|
||
} catch (e) {
|
||
showToast('Failed to load client data', 'error');
|
||
ipFilterLabel.textContent = 'Filter .150–.155';
|
||
} finally {
|
||
btnIpFilter.disabled = false;
|
||
ipFilterIcon.classList.remove('animate-spin');
|
||
}
|
||
});
|
||
|
||
// ── Refresh ────────────────────────────────────────────────────────────────
|
||
document.getElementById('btn-refresh').addEventListener('click', () => {
|
||
const icon = document.getElementById('refresh-icon');
|
||
icon.classList.add('animate-spin');
|
||
window.location.reload();
|
||
});
|
||
|
||
// ── Checkbox logic ─────────────────────────────────────────────────────────
|
||
const chkAll = document.getElementById('chk-all');
|
||
const btnRebootSel = document.getElementById('btn-reboot-selected');
|
||
const selCount = document.getElementById('sel-count');
|
||
|
||
function updateBulkButton() {
|
||
const checked = [...document.querySelectorAll('.ap-checkbox:checked')]
|
||
.filter(cb => cb.closest('tr').style.display !== 'none');
|
||
selCount.textContent = checked.length;
|
||
btnRebootSel.disabled = checked.length === 0;
|
||
}
|
||
|
||
chkAll?.addEventListener('change', () => {
|
||
document.querySelectorAll('.ap-checkbox:not(:disabled)').forEach(cb => {
|
||
cb.checked = chkAll.checked;
|
||
});
|
||
updateBulkButton();
|
||
});
|
||
|
||
document.querySelectorAll('.ap-checkbox').forEach(cb => {
|
||
cb.addEventListener('change', updateBulkButton);
|
||
});
|
||
|
||
// ── Modal ──────────────────────────────────────────────────────────────────
|
||
const modal = document.getElementById('modal');
|
||
const modalMsg = document.getElementById('modal-message');
|
||
const modalConfirm = document.getElementById('modal-confirm');
|
||
|
||
let pendingAction = null;
|
||
|
||
function showModal(message, onConfirm) {
|
||
modalMsg.textContent = message;
|
||
pendingAction = onConfirm;
|
||
modal.classList.remove('hidden');
|
||
}
|
||
|
||
function hideModal() {
|
||
modal.classList.add('hidden');
|
||
pendingAction = null;
|
||
}
|
||
|
||
document.getElementById('modal-backdrop').addEventListener('click', hideModal);
|
||
document.getElementById('modal-cancel').addEventListener('click', hideModal);
|
||
modalConfirm.addEventListener('click', () => {
|
||
if (pendingAction) pendingAction();
|
||
hideModal();
|
||
});
|
||
|
||
// ── Single reboot ──────────────────────────────────────────────────────────
|
||
function lockApRow(mac) {
|
||
const rowBtn = document.querySelector(`.btn-reboot[data-mac="${CSS.escape(mac)}"]`);
|
||
if (rowBtn) {
|
||
rowBtn.disabled = true;
|
||
rowBtn.className = 'btn-reboot px-3 py-1.5 text-xs rounded-lg font-medium bg-gray-100 border border-gray-200 text-gray-400 cursor-not-allowed opacity-50';
|
||
}
|
||
const cb = document.querySelector(`.ap-checkbox[data-mac="${CSS.escape(mac)}"]`);
|
||
if (cb) { cb.disabled = true; cb.checked = false; }
|
||
updateBulkButton();
|
||
}
|
||
|
||
document.querySelectorAll('.btn-reboot').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
if (btn.disabled) return;
|
||
const mac = btn.dataset.mac;
|
||
const name = btn.dataset.name;
|
||
const ip = btn.dataset.ip;
|
||
const site_key = btn.dataset.siteKey;
|
||
showModal(`Reboot AP "${name}"?\nThis will disconnect all clients connected to this AP.`, async () => {
|
||
lockApRow(mac);
|
||
btn.textContent = 'Rebooting…';
|
||
try {
|
||
const res = await fetch('/api/reboot', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ mac, name, ip, site_key, csrf_token: CSRF_TOKEN }),
|
||
});
|
||
if (res.ok) {
|
||
btn.textContent = 'Rebooted';
|
||
showToast(`Reboot command sent to "${name}"`, 'success');
|
||
} else {
|
||
const err = await res.json();
|
||
btn.textContent = 'Failed';
|
||
showToast(`Error: ${err.detail || 'Unknown error'}`, 'error');
|
||
}
|
||
} catch (e) {
|
||
btn.textContent = 'Failed';
|
||
showToast('Network error', 'error');
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
// ── Bulk reboot ────────────────────────────────────────────────────────────
|
||
btnRebootSel?.addEventListener('click', () => {
|
||
const checked = [...document.querySelectorAll('.ap-checkbox:checked')];
|
||
const aps = checked.map(cb => ({ mac: cb.dataset.mac, name: cb.dataset.name, ip: cb.dataset.ip, site_key: cb.dataset.siteKey }));
|
||
const names = aps.map(a => a.name).join(', ');
|
||
showModal(`Reboot ${aps.length} AP${aps.length > 1 ? 's' : ''}?\n${names}`, async () => {
|
||
// Lock all selected AP rows immediately on confirm
|
||
aps.forEach(ap => lockApRow(ap.mac));
|
||
btnRebootSel.disabled = true;
|
||
btnRebootSel.querySelector('span').textContent = '0';
|
||
try {
|
||
const res = await fetch('/api/reboot-bulk', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ aps, csrf_token: CSRF_TOKEN }),
|
||
});
|
||
const data = await res.json();
|
||
const errors = (data.results || []).filter(r => r.result === 'error');
|
||
// Update button text per AP based on result
|
||
(data.results || []).forEach(r => {
|
||
const b = document.querySelector(`.btn-reboot[data-mac="${CSS.escape(r.mac)}"]`);
|
||
if (b) b.textContent = r.result === 'success' ? 'Rebooted' : 'Failed';
|
||
});
|
||
if (errors.length === 0) {
|
||
showToast(`Reboot sent to ${aps.length} AP${aps.length > 1 ? 's' : ''}`, 'success');
|
||
} else {
|
||
showToast(`${errors.length} AP(s) failed to reboot`, 'error');
|
||
}
|
||
} catch (e) {
|
||
aps.forEach(ap => {
|
||
const b = document.querySelector(`.btn-reboot[data-mac="${CSS.escape(ap.mac)}"]`);
|
||
if (b) b.textContent = 'Failed';
|
||
});
|
||
showToast('Network error', 'error');
|
||
}
|
||
});
|
||
});
|
||
|
||
// ── Clients modal ──────────────────────────────────────────────────────────
|
||
const clientsModal = document.getElementById('clients-modal');
|
||
const clientsTitle = document.getElementById('clients-modal-title');
|
||
const clientsSubtitle = document.getElementById('clients-modal-subtitle');
|
||
const clientsBody = document.getElementById('clients-body');
|
||
|
||
function closeClientsModal() { clientsModal.classList.add('hidden'); }
|
||
document.getElementById('clients-backdrop').addEventListener('click', closeClientsModal);
|
||
document.getElementById('clients-close').addEventListener('click', closeClientsModal);
|
||
|
||
// Event delegation on the table body — catch clicks on name cells
|
||
document.getElementById('ap-table-body')?.addEventListener('click', e => {
|
||
const cell = e.target.closest('[data-action="view-clients"]');
|
||
if (!cell) return;
|
||
openClientsModal(cell.dataset.mac, cell.dataset.name, cell.dataset.siteKey);
|
||
});
|
||
|
||
function esc(str) {
|
||
return String(str ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
function signalInfo(dbm) {
|
||
if (dbm == null) return { label: '—', color: 'text-gray-400', display: '—' };
|
||
if (dbm >= -50) return { label: 'Excellent', color: 'text-green-600', display: dbm + ' dBm' };
|
||
if (dbm >= -65) return { label: 'Good', color: 'text-blue-500', display: dbm + ' dBm' };
|
||
if (dbm >= -75) return { label: 'Fair', color: 'text-yellow-500',display: dbm + ' dBm' };
|
||
return { label: 'Poor', color: 'text-red-500', display: dbm + ' dBm' };
|
||
}
|
||
|
||
function fmtRate(kbps) {
|
||
if (!kbps) return '—';
|
||
return kbps >= 1000 ? Math.round(kbps / 1000) + ' Mbps' : kbps + ' Kbps';
|
||
}
|
||
|
||
function fmtUptime(s) {
|
||
if (!s) return '—';
|
||
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
|
||
return h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m ${sec}s` : `${sec}s`;
|
||
}
|
||
|
||
const bandMap = { 0: '2.4 GHz', 1: '5 GHz', 2: '6 GHz' };
|
||
|
||
async function openClientsModal(mac, name, siteKey) {
|
||
clientsTitle.textContent = name;
|
||
clientsSubtitle.textContent = 'Loading…';
|
||
clientsBody.innerHTML = '<div class="flex justify-center py-16"><div class="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div></div>';
|
||
clientsModal.classList.remove('hidden');
|
||
|
||
try {
|
||
const res = await fetch(`/api/ap-clients?mac=${encodeURIComponent(mac)}&site_key=${encodeURIComponent(siteKey)}`);
|
||
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || 'Request failed'); }
|
||
const { clients } = await res.json();
|
||
renderClients(clients);
|
||
} catch (err) {
|
||
clientsSubtitle.textContent = '';
|
||
clientsBody.innerHTML = `<div class="flex flex-col items-center py-16 text-red-500">
|
||
<p class="font-medium">Failed to load clients</p>
|
||
<p class="text-sm mt-1 text-red-400">${esc(err.message)}</p></div>`;
|
||
}
|
||
}
|
||
|
||
function renderClients(clients) {
|
||
if (!clients || clients.length === 0) {
|
||
clientsSubtitle.textContent = 'No clients connected';
|
||
clientsBody.innerHTML = `<div class="flex flex-col items-center py-16 text-gray-400">
|
||
<svg class="w-12 h-12 mb-3 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
|
||
</svg>
|
||
<p class="text-lg font-medium">No clients connected</p>
|
||
<p class="text-sm mt-1">No wireless devices are currently associated with this AP.</p></div>`;
|
||
return;
|
||
}
|
||
|
||
clientsSubtitle.textContent = `${clients.length} device${clients.length !== 1 ? 's' : ''} connected`;
|
||
|
||
const rows = clients.map(c => {
|
||
const displayName = esc(c.name || c.hostName || c.hostname || '');
|
||
const mac = esc(c.mac || '—');
|
||
const ip = esc(c.ip || '—');
|
||
const ssid = esc(c.ssid || '—');
|
||
const band = esc(bandMap[c.radioId ?? c.radioType] ?? '—');
|
||
const ch = esc(c.channel || '—');
|
||
const signal = c.signal ?? c.rssi ?? null;
|
||
const sq = signalInfo(signal);
|
||
const tx = fmtRate(c.txRate);
|
||
const rx = fmtRate(c.rxRate);
|
||
const uptime = fmtUptime(c.uptime ?? c.connectTime);
|
||
return `<tr class="border-t border-gray-100 hover:bg-gray-50 transition-colors">
|
||
<td class="px-4 py-3">
|
||
${displayName ? `<div class="font-medium text-gray-900 text-sm">${displayName}</div>` : ''}
|
||
<div class="text-xs text-gray-400 font-mono">${mac}</div>
|
||
</td>
|
||
<td class="px-4 py-3 text-sm text-gray-600 font-mono">${ip}</td>
|
||
<td class="px-4 py-3 text-sm text-gray-600">${ssid}</td>
|
||
<td class="px-4 py-3 text-sm text-gray-600">${band}</td>
|
||
<td class="px-4 py-3 text-sm text-gray-600">${ch}</td>
|
||
<td class="px-4 py-3 text-sm">
|
||
<span class="font-mono ${sq.color}">${sq.display}</span>
|
||
<span class="text-xs ml-1 ${sq.color}">${sq.label}</span>
|
||
</td>
|
||
<td class="px-4 py-3 text-xs text-gray-500 whitespace-nowrap">↑ ${tx}<br>↓ ${rx}</td>
|
||
<td class="px-4 py-3 text-xs text-gray-500">${uptime}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
|
||
clientsBody.innerHTML = `<table class="min-w-full text-sm">
|
||
<thead class="bg-gray-50 border-b border-gray-200 sticky top-0">
|
||
<tr>
|
||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Client</th>
|
||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">IP</th>
|
||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">SSID</th>
|
||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Band</th>
|
||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Ch</th>
|
||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Signal</th>
|
||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Link Rate</th>
|
||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Connected</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>`;
|
||
}
|
||
</script>
|
||
{% endblock %}
|