Add connected clients popup per AP

Clicking an AP name opens a modal showing all wireless clients currently
associated with that AP: hostname/MAC, IP, SSID, band, channel, signal
strength with quality label, TX/RX link rate, and connection uptime.

Backend: GET /api/ap-clients?mac=&site_key= calls the Omada clients
endpoint with filters.apMac; falls back to client-side filtering if the
controller doesn't support that query param.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 15:12:59 +02:00
parent f4ac0857b4
commit 47f553f410
3 changed files with 188 additions and 1 deletions

View File

@@ -263,6 +263,21 @@ async def api_reboot(request: Request, db: Session = Depends(get_db)):
return {"status": "ok", "mac": mac} return {"status": "ok", "mac": mac}
@app.get("/api/ap-clients")
async def api_ap_clients(request: Request, mac: str = "", site_key: str = ""):
user = get_current_user(request)
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
if not mac or not site_key:
raise HTTPException(status_code=400, detail="mac and site_key required")
try:
clients = await omada_client.get_ap_clients(mac, site_key)
return {"clients": clients}
except Exception as exc:
logger.error("Failed to fetch clients for AP %s: %s", mac, exc)
raise HTTPException(status_code=502, detail=str(exc))
@app.post("/api/reboot-bulk") @app.post("/api/reboot-bulk")
async def api_reboot_bulk(request: Request, db: Session = Depends(get_db)): async def api_reboot_bulk(request: Request, db: Session = Depends(get_db)):
user = get_current_user(request) user = get_current_user(request)

View File

@@ -188,6 +188,27 @@ class OmadaClient:
return int(device.get("uptimeLong", 0)) return int(device.get("uptimeLong", 0))
raise RuntimeError(f"AP {mac} not found in site {site_key}") raise RuntimeError(f"AP {mac} not found in site {site_key}")
async def get_ap_clients(self, ap_mac: str, site_key: str) -> list[dict]:
"""Fetch wireless clients currently connected to a specific AP."""
await self._ensure_ready()
norm_mac = ap_mac.upper().replace("-", ":")
try:
data = await self._request_with_retry(
"GET",
f"{OMADA_BASE_URL}/{self._omadac_id}/api/v2/sites/{site_key}/clients",
params={"page": 1, "pageSize": 200, "filters.active": "true", "filters.apMac": ap_mac},
)
except RuntimeError:
# Older firmware may not support filters.apMac — fall back and filter client-side
data = await self._request_with_retry(
"GET",
f"{OMADA_BASE_URL}/{self._omadac_id}/api/v2/sites/{site_key}/clients",
params={"page": 1, "pageSize": 200, "filters.active": "true"},
)
result = data.get("result", {})
clients = result.get("data", []) if isinstance(result, dict) else (result if isinstance(result, list) else [])
return [c for c in clients if c.get("apMac", "").upper().replace("-", ":") == norm_mac]
async def reboot_ap(self, mac: str, site_key: str, min_uptime: int = 300) -> dict: async def reboot_ap(self, mac: str, site_key: str, min_uptime: int = 300) -> dict:
await self._ensure_ready() await self._ensure_ready()
uptime = await self._get_ap_uptime(mac, site_key) uptime = await self._get_ap_uptime(mac, site_key)

View File

@@ -93,7 +93,16 @@
title="{% if not online %}AP is offline{% else %}Uptime too low — minimum 5 minutes required{% endif %}" title="{% if not online %}AP is offline{% else %}Uptime too low — minimum 5 minutes required{% endif %}"
{% endif %} /> {% endif %} />
</td> </td>
<td class="px-4 py-3 font-medium text-gray-900">{{ name }}</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 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">{{ 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 font-mono text-xs">{{ mac or '—' }}</td>
@@ -148,6 +157,25 @@
</div> </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 --> <!-- Confirmation Modal -->
<div id="modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4"> <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="absolute inset-0 bg-black/40 backdrop-blur-sm" id="modal-backdrop"></div>
@@ -317,5 +345,128 @@
} }
}); });
}); });
// ── 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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> </script>
{% endblock %} {% endblock %}