diff --git a/app/main.py b/app/main.py
index a5af512..b8ba21e 100644
--- a/app/main.py
+++ b/app/main.py
@@ -263,6 +263,21 @@ async def api_reboot(request: Request, db: Session = Depends(get_db)):
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")
async def api_reboot_bulk(request: Request, db: Session = Depends(get_db)):
user = get_current_user(request)
diff --git a/app/omada.py b/app/omada.py
index 05ea806..03df688 100644
--- a/app/omada.py
+++ b/app/omada.py
@@ -188,6 +188,27 @@ class OmadaClient:
return int(device.get("uptimeLong", 0))
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:
await self._ensure_ready()
uptime = await self._get_ap_uptime(mac, site_key)
diff --git a/app/templates/index.html b/app/templates/index.html
index e448d42..1f4f068 100644
--- a/app/templates/index.html
+++ b/app/templates/index.html
@@ -93,7 +93,16 @@
title="{% if not online %}AP is offline{% else %}Uptime too low — minimum 5 minutes required{% endif %}"
{% endif %} />
-
{{ name }} |
+
+
+ {{ name }}
+
+
+ |
{{ site_name }} |
{{ ip or '—' }} |
{{ mac or '—' }} |
@@ -148,6 +157,25 @@
+
+
+
@@ -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,'&').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 = '
';
+ 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 = `
+
Failed to load clients
+
${esc(err.message)}
`;
+ }
+ }
+
+ function renderClients(clients) {
+ if (!clients || clients.length === 0) {
+ clientsSubtitle.textContent = 'No clients connected';
+ clientsBody.innerHTML = `
+
+
No clients connected
+
No wireless devices are currently associated with this AP.
`;
+ 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 `
+ |
+ ${displayName ? ` ${displayName} ` : ''}
+ ${mac}
+ |
+ ${ip} |
+ ${ssid} |
+ ${band} |
+ ${ch} |
+
+ ${sq.display}
+ ${sq.label}
+ |
+ ↑ ${tx} ↓ ${rx} |
+ ${uptime} |
+
`;
+ }).join('');
+
+ clientsBody.innerHTML = `
+
+
+ | Client |
+ IP |
+ SSID |
+ Band |
+ Ch |
+ Signal |
+ Link Rate |
+ Connected |
+
+
+ ${rows}
+
`;
+ }
{% endblock %}