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:
15
app/main.py
15
app/main.py
@@ -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)
|
||||||
|
|||||||
21
app/omada.py
21
app/omada.py
@@ -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)
|
||||||
|
|||||||
@@ -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,'&').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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user