Add IP range filter button (.150–.155)

New "Filter .150–.155" button fetches all connected clients via
GET /api/all-clients (one request per site, grouped by AP MAC),
then hides any AP row that has no client with a last-octet IP
between 150 and 155. Clicking again clears the filter.

The name/site search and IP filter compose (AND logic) via a shared
applyFilters() function. Client data is cached in-memory for the
current page session so repeated toggles don't re-fetch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 15:48:50 +02:00
parent 1041777b9a
commit d693321ba1
3 changed files with 127 additions and 5 deletions

View File

@@ -263,6 +263,19 @@ async def api_reboot(request: Request, db: Session = Depends(get_db)):
return {"status": "ok", "mac": mac}
@app.get("/api/all-clients")
async def api_all_clients(request: Request):
user = get_current_user(request)
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
try:
data = await omada_client.get_all_clients()
return {"ap_clients": data}
except Exception as exc:
logger.error("Failed to fetch all clients: %s", exc)
raise HTTPException(status_code=502, detail=str(exc))
@app.get("/api/ap-clients")
async def api_ap_clients(request: Request, mac: str = "", site_key: str = ""):
user = get_current_user(request)

View File

@@ -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_all_clients(self) -> dict[str, list[dict]]:
"""Return {ap_mac: [clients]} for every site (wireless clients only)."""
await self._ensure_ready()
ap_clients: dict[str, list] = {}
for site_name, site_key in self._sites.items():
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": 1000, "filters.active": "true"},
)
result = data.get("result", {})
clients = result.get("data", []) if isinstance(result, dict) else (result if isinstance(result, list) else [])
for c in clients:
mac = c.get("apMac", "").upper().replace("-", ":")
if mac:
ap_clients.setdefault(mac, []).append(c)
except Exception as exc:
logger.warning("Failed to fetch clients for site '%s': %s", site_name, exc)
return ap_clients
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()

View File

@@ -30,6 +30,14 @@
</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">
@@ -208,23 +216,103 @@
<script>
const CSRF_TOKEN = {{ csrf_token | tojson }};
// ── Name / site filter ────────────────────────────────────────────────────
// ── 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')];
filterInput.addEventListener('input', () => {
let ipMatchingMacs = null; // null = inactive; Set<string> = active
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 match = !q || name.includes(q) || site.includes(q);
row.style.display = match ? '' : 'none';
if (match) shown++;
const nameMatch = !q || name.includes(q) || site.includes(q);
let ipMatch = true;
if (ipMatchingMacs !== null) {
const cb = row.querySelector('.ap-checkbox');
ipMatch = ipMatchingMacs.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');
let cachedApClients = null;
function isTargetIp(ip) {
if (!ip) return false;
const parts = ip.split('.');
if (parts.length !== 4) return false;
const last = parseInt(parts[3], 10);
return last >= 150 && last <= 155;
}
function setIpFilterActive(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 (ipMatchingMacs !== null) {
// Clear filter
ipMatchingMacs = null;
setIpFilterActive(false);
applyFilters();
return;
}
btnIpFilter.disabled = true;
ipFilterLabel.textContent = 'Loading…';
ipFilterIcon.classList.add('animate-spin');
try {
if (!cachedApClients) {
const res = await fetch('/api/all-clients');
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || 'Request failed'); }
cachedApClients = (await res.json()).ap_clients || {};
}
ipMatchingMacs = new Set();
for (const [apMac, clients] of Object.entries(cachedApClients)) {
if (clients.some(c => isTargetIp(c.ip))) ipMatchingMacs.add(apMac);
}
setIpFilterActive(true);
applyFilters();
} catch (e) {
showToast('Failed to load client data: ' + e.message, 'error');
ipFilterLabel.textContent = 'Filter .150.155';
} finally {
btnIpFilter.disabled = false;
ipFilterIcon.classList.remove('animate-spin');
}
});
// ── Refresh ────────────────────────────────────────────────────────────────