feat(ui): filters on the clients, devices and events tables (v0.23.9-beta)

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 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-08-22 20:32:18 -07:00
co-authored by Claude Opus 5
parent 5071e1f512
commit 60d7d13bd3
11 changed files with 247 additions and 29 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-frontend",
"version": "0.23.8-beta",
"version": "0.23.9-beta",
"private": true,
"type": "module",
"scripts": {
+16 -4
View File
@@ -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 (
<div key={bin}
title={rows.map(r => `${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"
<button key={bin}
onClick={() => navigate(`/clients?type=wireless&smin=${zone.min}&smax=${zone.max}`)}
title={[
...rows.map(r => `${r.custom_name || r.hostname || r.mac_address} (${r.signal_strength} dBm, ${r.device_name})`),
'',
`Click to list ${zone.label.toLowerCase()}-signal clients`,
].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 hover:ring-blue-400 transition-shadow cursor-pointer"
style={{ left: `${pos(bin)}%`, width: size, height: size, backgroundColor: rssiColor(bin) }}>
{rows.length > 1 ? rows.length : ''}
</div>
</button>
);
})}
</div>
+87 -3
View File
@@ -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<string, string | null>) => {
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<Client | null>(null);
const [sortCol, setSortCol] = useState<string>('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<number>();
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() {
</button>
))}
</div>
{/* 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 && (
<select
className="input w-auto"
value={vlanFilter ?? ''}
onChange={(e) => setUrlFilter({ vlan: e.target.value || null })}
>
<option value="">All VLANs</option>
{vlanOptions.map(v => <option key={v} value={v}>VLAN {v}</option>)}
</select>
)}
{/* Signal band — the same buckets the AP density chart clusters by, which
is what lets that chart link straight here (#99). */}
{isWireless && (
<select
className="input w-auto"
value={signalMin != null || signalMax != null ? `${signalMin ?? ''}:${signalMax ?? ''}` : ''}
onChange={(e) => {
const [lo, hi] = e.target.value.split(':');
setUrlFilter({ smin: lo || null, smax: hi || null });
}}
>
<option value="">Any signal</option>
{RSSI_ZONES.map(z => (
<option key={z.label} value={`${z.min}:${z.max}`}>
{z.label} ({z.min} to {z.max} dBm)
</option>
))}
</select>
)}
{activeFilterCount > 0 && (
<button
onClick={() => setUrlFilter({ vlan: null, smin: null, smax: null })}
className="px-3 py-2 rounded-lg text-sm font-medium text-gray-500 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-700"
>
Clear {activeFilterCount} filter{activeFilterCount !== 1 ? 's' : ''}
</button>
)}
<button
onClick={() => { setShowAll((s) => !s); setPage(0); }}
className={clsx(
+63 -8
View File
@@ -14,7 +14,7 @@ import AddDeviceModal from '../components/devices/AddDeviceModal';
import EditDeviceModal from '../components/devices/EditDeviceModal';
import TryAllDiscoveredModal from '../components/devices/TryAllDiscoveredModal';
type DeviceSortKey = 'name' | 'ip_address' | 'model' | 'serial_number' | 'ros_version' | 'status' | 'last_seen';
type DeviceSortKey = 'name' | 'ip_address' | 'model' | 'serial_number' | 'ros_version' | 'status' | 'last_seen' | 'rack_name' | 'location_address';
type DiscoveredSortKey = 'identity' | 'address' | 'mac_address' | 'seen_by' | 'discovered_at';
type SortDir = 'asc' | 'desc';
@@ -139,6 +139,10 @@ export default function DevicesPage() {
const [hideDuplicates, setHideDuplicates] = useState(true);
const [showTryAllModal, setShowTryAllModal] = useState(false);
const [deviceSort, setDeviceSort] = useState<{ key: DeviceSortKey; dir: SortDir }>({ key: 'name', dir: 'asc' });
// Physical-placement filters (#107). Both are free-form strings on the device, so
// the options come from what is actually recorded rather than a fixed list.
const [rackFilter, setRackFilter] = useState('');
const [locationFilter, setLocationFilter] = useState('');
const [discoveredSort, setDiscoveredSort] = useState<{ key: DiscoveredSortKey; dir: SortDir }>({
key: 'discovered_at',
dir: 'desc',
@@ -189,6 +193,15 @@ export default function DevicesPage() {
});
// Fetch 1h CPU history for all devices (for LOAD sparkline column)
// Placement options come from what is actually recorded, so the dropdowns never
// offer a rack or site that would match nothing (#107).
const rackOptions = useMemo(
() => [...new Set(devices.map(d => d.rack_name).filter(Boolean) as string[])].sort(),
[devices]);
const locationOptions = useMemo(
() => [...new Set(devices.map(d => d.location_address).filter(Boolean) as string[])].sort(),
[devices]);
const cpuHistoryResults = useQueries({
queries: devices.map(d => ({
queryKey: ['device-resources-history', d.id],
@@ -209,12 +222,16 @@ export default function DevicesPage() {
const filtered = useMemo(() => {
const base = devices.filter(d => {
if (search &&
!d.name.toLowerCase().includes(search.toLowerCase()) &&
!d.ip_address.includes(search) &&
!d.model?.toLowerCase().includes(search.toLowerCase()) &&
!d.serial_number?.toLowerCase().includes(search.toLowerCase())
) return false;
if (search) {
const q = search.toLowerCase();
const haystack = [
d.name, d.ip_address, d.model, d.serial_number,
d.location_address, d.rack_name, d.rack_slot,
];
if (!haystack.some(v => v && String(v).toLowerCase().includes(q))) return false;
}
if (rackFilter && d.rack_name !== rackFilter) return false;
if (locationFilter && d.location_address !== locationFilter) return false;
if (statusFilter === 'online' && d.status !== 'online') return false;
if (statusFilter === 'offline' && d.status !== 'offline') return false;
if (statusFilter === 'updates' && !d.firmware_update_available && !d.routerboard_upgrade_available) return false;
@@ -231,6 +248,8 @@ export default function DevicesPage() {
case 'ip_address': return x.ip_address || '';
case 'model': return x.model || '';
case 'serial_number': return x.serial_number || '';
case 'rack_name': return x.rack_name || '';
case 'location_address': return x.location_address || '';
case 'ros_version': return x.ros_version || '';
case 'status': return x.status || '';
case 'last_seen': return x.last_seen ? new Date(x.last_seen).getTime() : 0;
@@ -245,7 +264,7 @@ export default function DevicesPage() {
return deviceSort.dir === 'asc' ? cmp : -cmp;
});
return sorted;
}, [devices, search, statusFilter, typeFilter, tagFilter, deviceSort]);
}, [devices, search, statusFilter, typeFilter, tagFilter, rackFilter, locationFilter, deviceSort]);
const discoveredList = discovered as DiscoveredDevice[];
const duplicateCount = useMemo(
@@ -345,6 +364,29 @@ export default function DevicesPage() {
onChange={e => setSearch(e.target.value)}
/>
</div>
{/* Placement filters — only offered when placement is actually recorded (#107) */}
{rackOptions.length > 0 && (
<select
value={rackFilter}
onChange={e => setRackFilter(e.target.value)}
className="mono text-[11.5px] px-[8px] py-[4px] rounded-[5px] bg-transparent outline-none"
style={{ color: rackFilter ? 'var(--ink)' : 'var(--ink-3)', border: '1px solid var(--line)' }}
>
<option value="">All racks</option>
{rackOptions.map(r => <option key={r} value={r}>{r}</option>)}
</select>
)}
{locationOptions.length > 0 && (
<select
value={locationFilter}
onChange={e => setLocationFilter(e.target.value)}
className="text-[11.5px] px-[8px] py-[4px] rounded-[5px] bg-transparent outline-none max-w-[180px]"
style={{ color: locationFilter ? 'var(--ink)' : 'var(--ink-3)', border: '1px solid var(--line)' }}
>
<option value="">All locations</option>
{locationOptions.map(l => <option key={l} value={l}>{l}</option>)}
</select>
)}
{(['all', 'online', 'offline', 'updates'] as const).map(f => (
<button
key={f}
@@ -424,6 +466,7 @@ export default function DevicesPage() {
{ key: null, label: 'TYPE', w: 72 },
{ key: 'model', label: 'MODEL', w: null },
{ key: 'serial_number', label: 'SERIAL', w: 130 },
{ key: 'rack_name', label: 'RACK', w: 100 },
{ key: 'ip_address', label: 'IP ADDRESS', w: null },
{ key: 'ros_version',label: 'ROS', w: 110 },
{ key: null, label: 'CPU', w: 110 },
@@ -496,6 +539,18 @@ export default function DevicesPage() {
{device.serial_number || '—'}
</span>
</td>
<td className="px-4 py-[12px]">
<span
className="text-[11.5px] block truncate"
style={{ color: 'var(--ink-3)', maxWidth: 100 }}
title={[device.rack_name, device.rack_slot && `slot ${device.rack_slot}`, device.location_address]
.filter(Boolean).join(' · ') || undefined}
>
{device.rack_name
? `${device.rack_name}${device.rack_slot ? `/${device.rack_slot}` : ''}`
: '—'}
</span>
</td>
<td className="px-4 py-[12px]">
<span className="mono num-tab text-[12px]" style={{ color: 'var(--ink-2)' }}>{device.ip_address}</span>
</td>
+20 -7
View File
@@ -30,6 +30,14 @@ export default function EventsPage() {
const [topic, setTopic] = useState('');
const [deviceId, setDeviceId] = useState('');
const [page, setPage] = useState(0);
// RouterOS reports topics as a comma-separated list, so the server splits them
// into individual tokens and counts them across the whole table — the operator
// picks from what actually exists rather than guessing a spelling (#108).
const { data: topicList = [] } = useQuery({
queryKey: ['event-topics'],
queryFn: () => eventsApi.topics().then(r => r.data),
staleTime: 300_000,
});
const PAGE_SIZE = 100;
const toggleSeverity = (s: string) => {
@@ -122,13 +130,18 @@ export default function EventsPage() {
/>
</div>
<input
type="text"
className="input w-full sm:w-36"
placeholder="Filter topic…"
value={topic}
onChange={(e) => { setTopic(e.target.value); setPage(0); }}
/>
{topicList.length > 0 && (
<select
className="input w-full sm:w-44"
value={topic}
onChange={(e) => { setTopic(e.target.value); setPage(0); }}
>
<option value="">All topics</option>
{topicList.map(t => (
<option key={t.topic} value={t.topic}>{t.topic} ({t.count})</option>
))}
</select>
)}
<div className="flex items-center gap-3 px-3 py-1.5 border border-gray-200 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800">
{([
+5
View File
@@ -439,6 +439,10 @@ export const clientsApi = {
active?: boolean;
search?: string;
client_type?: string;
vlan_id?: number;
/** Inclusive lower / exclusive upper dBm bound, matching the density buckets. */
signal_min?: number;
signal_max?: number;
limit?: number;
offset?: number;
sort?: string;
@@ -490,6 +494,7 @@ export const systemApi = {
// ─── Events ───────────────────────────────────────────────────────────────────
export const eventsApi = {
topics: () => api.get<{ topic: string; count: number }[]>('/events/topics'),
list: (params?: {
deviceId?: number;
severity?: string;
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = 'v0.23.8 Beta';
export const APP_VERSION = 'v0.23.9 Beta';