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
+24 -1
View File
@@ -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 ')}` : '';
+28 -2
View File
@@ -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++}`);