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 && ( + + )} +