From 60d7d13bd35ff94ff498da0c417c1ceaeed03659 Mon Sep 17 00:00:00 2001 From: 2GT-Rich <107219997+2GT-Rich@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:32:18 -0700 Subject: [PATCH] feat(ui): filters on the clients, devices and events tables (v0.23.9-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #99, #100, #107, #108. Four reports with one root complaint: the tables sort but do not filter, which is fine at four devices and painful at forty. #100 / #99 — clients VLAN and signal-range filters, both server-side so counts and pagination stay correct across the whole dataset rather than the page in hand. Filter state lives in the URL alongside the existing wired/wireless choice, which makes a filtered view shareable and gives the AP Deployment Density chart somewhere to point. Clicking a density bubble now opens the clients list filtered to wireless clients in that signal zone (#99). The zone, not the 2 dB drawing bin — the bin is a rendering detail and the zone is the question being asked. Verified against the fleet: the four zones partition all 17 wireless clients exactly. VLAN options are built from VLANs actually present, so the list never offers a choice that returns nothing. Category filtering is deliberately absent. The effective category is a user override where set and otherwise a fingerprint computed in JS from vendor and hostname, so there is no column to filter on, and filtering after pagination would report wrong totals. Persisting the fingerprint would fix it and is noted in the route, along with the cost: a stored value goes stale whenever the rules change. #107 — devices Rack and location selectors, populated from what is actually recorded so neither offers an empty choice. Free-text search now also covers location, rack and slot, so "datacenter" or "rack-4" finds devices without touching a dropdown. A RACK column joins the table, sortable like the rest. #108 — events RouterOS reports topics as a comma-separated list, so the useful unit of filtering is the token rather than the string. A new endpoint splits and counts them across the whole table — not merely the loaded page — and the free-text topic box becomes a populated selector. Topic filtering also changed from ILIKE '%token%' to exact array membership. The badges present topics as discrete tags, so `error` must not quietly also match a hypothetical `no-error`. On this fleet the endpoint reports 537,346 `info`, 535,439 `system` and 535,362 `account` — which makes the reporter's aside about login noise dominating the events table concretely true, and worth its own issue. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- backend/package.json | 2 +- backend/src/routes/clients.ts | 25 +++++- backend/src/routes/events.ts | 30 ++++++- frontend/package.json | 2 +- frontend/src/components/wireless/RfHealth.tsx | 20 ++++- frontend/src/pages/ClientsPage.tsx | 90 ++++++++++++++++++- frontend/src/pages/DevicesPage.tsx | 71 +++++++++++++-- frontend/src/pages/EventsPage.tsx | 27 ++++-- frontend/src/services/api.ts | 5 ++ frontend/src/version.ts | 2 +- 11 files changed, 247 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 6162353..d13a7ff 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

- Version + Version License Docker TypeScript diff --git a/backend/package.json b/backend/package.json index b4f9e9a..97ae2dc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.23.8-beta", + "version": "0.23.9-beta", "description": "Mikrotik Network Management Platform - Backend API", "main": "dist/index.js", "scripts": { diff --git a/backend/src/routes/clients.ts b/backend/src/routes/clients.ts index 84c031d..70c0327 100644 --- a/backend/src/routes/clients.ts +++ b/backend/src/routes/clients.ts @@ -25,7 +25,10 @@ export function setPollerService(p: PollerService): void { pollerService = p; } // GET /api/clients router.get('/', async (req: Request, res: Response) => { - const { deviceId, active, search, client_type, limit = '100', offset = '0', sort, dir } = req.query; + const { + deviceId, active, search, client_type, limit = '100', offset = '0', sort, dir, + vlan_id, signal_min, signal_max, + } = req.query; // Whitelist sortable columns → safe SQL expressions on the outer (deduped) // result. Sorting must run server-side across the whole dataset, not just the @@ -68,6 +71,26 @@ router.get('/', async (req: Request, res: Response) => { filterParams.push(`%${search}%`); idx++; } + if (vlan_id) { + filters.push(`c.vlan_id = $${idx++}`); + filterParams.push(vlan_id); + } + // Signal range, so the AP-density chart can hand off the band the user clicked + // (issues #99, #100). Bounds are inclusive of min and exclusive of max, matching + // how the density buckets are drawn. + if (signal_min) { + filters.push(`c.signal_strength >= $${idx++}`); + filterParams.push(Number(signal_min)); + } + if (signal_max) { + filters.push(`c.signal_strength < $${idx++}`); + filterParams.push(Number(signal_max)); + } + // Category is deliberately absent. The effective category is the user's override + // where set, otherwise a fingerprint computed in JS from vendor and hostname + // (utils/clientFingerprint) — there is no column to filter on, and filtering after + // pagination would produce wrong counts. Persisting the fingerprint would fix it, + // at the cost of going stale whenever the rules change. const where = filters.length ? `WHERE ${filters.join(' AND ')}` : ''; diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index d964aee..4071ecb 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -6,6 +6,30 @@ const router = Router(); router.use(requireAuth); // GET /api/events +/** + * GET /api/events/topics — the distinct RouterOS topic tokens in use, with counts. + * + * RouterOS reports a topic as a comma-separated list such as `system,info,account`, + * so the useful unit of filtering is the individual token rather than the whole + * string. Splitting server-side keeps the badge list honest across the entire table + * instead of only the page currently loaded (issue #108). + */ +router.get('/topics', async (_req: Request, res: Response) => { + const rows = await query<{ token: string; n: string }>(` + SELECT token, COUNT(*) AS n + FROM ( + SELECT unnest(string_to_array(topic, ',')) AS token + FROM events + WHERE topic IS NOT NULL AND topic <> '' + AND event_time > NOW() - INTERVAL '30 days' + ) t + WHERE btrim(token) <> '' + GROUP BY token + ORDER BY COUNT(*) DESC, token ASC + LIMIT 40`); + res.json(rows.map((r) => ({ topic: r.token.trim(), count: Number(r.n) }))); +}); + router.get('/', async (req: Request, res: Response) => { const { deviceId, @@ -39,8 +63,10 @@ router.get('/', async (req: Request, res: Response) => { params.push(...Array.from(expanded)); } if (topic) { - filters.push(`e.topic ILIKE $${idx++}`); - params.push(`%${topic}%`); + // Match a whole token rather than a substring: the badges present topics as + // discrete tags, so `error` must not also match a hypothetical `no-error`. + filters.push(`$${idx++} = ANY(string_to_array(e.topic, ','))`); + params.push(topic); } if (since) { filters.push(`e.event_time >= $${idx++}`); diff --git a/frontend/package.json b/frontend/package.json index 36eea49..d01bc8e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.23.8-beta", + "version": "0.23.9-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/wireless/RfHealth.tsx b/frontend/src/components/wireless/RfHealth.tsx index 07397b3..c936735 100644 --- a/frontend/src/components/wireless/RfHealth.tsx +++ b/frontend/src/components/wireless/RfHealth.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; import { Radio, Signal, Activity, ShieldCheck, AlertTriangle } from 'lucide-react'; import clsx from 'clsx'; import { wirelessApi } from '../../services/api'; @@ -173,6 +174,7 @@ const RSSI_MAX = -30; const RSSI_TICKS = [-90, -80, -70, -60, -50, -40, -30]; export function RssiDensity({ deviceId }: { deviceId?: number }) { + const navigate = useNavigate(); const { data: signals = [] } = useQuery({ queryKey: ['rf-signals', deviceId], queryFn: () => wirelessApi.getClientSignals(deviceId).then(r => r.data), @@ -231,13 +233,23 @@ export function RssiDensity({ deviceId }: { deviceId?: number }) { {/* client bubbles */} {Array.from(bins.entries()).map(([bin, rows]) => { const size = Math.min(34, 12 + (rows.length - 1) * 4); + // Clicking a bubble opens the Clients page filtered to wireless clients + // in the signal zone that bubble sits in (#99). The zone rather than the + // 2 dB bin, because the bin is a drawing detail and the zone is the thing + // an operator is actually asking about. + const zone = RSSI_ZONES.find(z => bin >= z.min && bin < z.max) ?? RSSI_ZONES[0]; return ( -

`${r.custom_name || r.hostname || r.mac_address} (${r.signal_strength} dBm, ${r.device_name})`).join('\n')} - className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full flex items-center justify-center text-[10px] font-semibold text-white shadow ring-2 ring-white dark:ring-slate-800" +
+ ); })} diff --git a/frontend/src/pages/ClientsPage.tsx b/frontend/src/pages/ClientsPage.tsx index 90a903a..ab7e460 100644 --- a/frontend/src/pages/ClientsPage.tsx +++ b/frontend/src/pages/ClientsPage.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from 'react'; +import { useState, useRef, useEffect, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { @@ -6,6 +6,7 @@ import { ChevronLeft, ChevronRight, RefreshCw, } from 'lucide-react'; import { CATEGORY_META } from '../utils/clientCategories'; +import { RSSI_ZONES } from '../utils/wifiChannels'; import { clientsApi } from '../services/api'; import type { Client } from '../types'; import { useCanWrite } from '../hooks/useCanWrite'; @@ -203,6 +204,30 @@ export default function ClientsPage() { setPage(0); }; const isWireless = typeFilter === 'wireless'; + + // VLAN and signal-range filters live in the URL alongside type, so the AP + // Deployment Density chart can hand off the band the user clicked (#99) and any + // filtered view stays shareable (#100). + const urlNum = (k: string): number | undefined => { + const v = searchParams.get(k); + if (v == null || v === '') return undefined; + const n = Number(v); + return Number.isFinite(n) ? n : undefined; + }; + const vlanFilter = urlNum('vlan'); + const signalMin = urlNum('smin'); + const signalMax = urlNum('smax'); + const setUrlFilter = (patch: Record) => { + setSearchParams(prev => { + const next = new URLSearchParams(prev); + for (const [k, v] of Object.entries(patch)) { + if (v == null || v === '') next.delete(k); else next.set(k, v); + } + return next; + }, { replace: true }); + setPage(0); + }; + const activeFilterCount = [vlanFilter, signalMin ?? signalMax].filter(v => v !== undefined).length; const [page, setPage] = useState(0); const [editingClient, setEditingClient] = useState(null); const [sortCol, setSortCol] = useState('last_seen'); @@ -233,10 +258,18 @@ export default function ClientsPage() { }; const { data, isLoading } = useQuery({ - queryKey: ['clients', { search, showAll, typeFilter, page, pageSize, sortCol, sortDir }], + queryKey: ['clients', { search, showAll, typeFilter, vlanFilter, signalMin, signalMax, page, pageSize, sortCol, sortDir }], queryFn: () => clientsApi - .list({ search: search || undefined, active: showAll ? undefined : true, client_type: typeFilter ?? undefined, limit: pageSize, offset: page * pageSize, sort: sortCol, dir: sortDir }) + .list({ + search: search || undefined, + active: showAll ? undefined : true, + client_type: typeFilter ?? undefined, + vlan_id: vlanFilter, + signal_min: signalMin, + signal_max: signalMax, + limit: pageSize, offset: page * pageSize, sort: sortCol, dir: sortDir, + }) .then((r) => r.data), refetchInterval: refreshInterval ?? false, // Keep the current rows on screen while a sort/page/search refetch is in @@ -244,6 +277,15 @@ export default function ClientsPage() { placeholderData: keepPreviousData, }); + // Offer only VLANs the fleet actually uses, so the list never presents a + // choice that would return nothing. + const vlanOptions = useMemo(() => { + const seen = new Set(); + for (const c of (data?.clients ?? [])) if (c.vlan_id != null) seen.add(c.vlan_id); + if (vlanFilter != null) seen.add(vlanFilter); // keep the active choice selectable + return [...seen].sort((a, b) => a - b); + }, [data, vlanFilter]); + const [purgeResult, setPurgeResult] = useState(''); const purgeMutation = useMutation({ mutationFn: () => clientsApi.purgeStale(), @@ -316,6 +358,48 @@ export default function ClientsPage() { ))} + {/* VLAN filter — populated from what the fleet actually uses, so the + operator picks a real VLAN rather than guessing an id (#100). */} + {vlanOptions.length > 0 && ( + + )} + + {/* Signal band — the same buckets the AP density chart clusters by, which + is what lets that chart link straight here (#99). */} + {isWireless && ( + + )} + + {activeFilterCount > 0 && ( + + )} +