-
+
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 (
-