diff --git a/README.md b/README.md index c9dd690..e0cd2a0 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 2066a79..75a9c62 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.23.13-beta", + "version": "0.23.14-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 30b474b..bc641d1 100644 --- a/backend/src/routes/clients.ts +++ b/backend/src/routes/clients.ts @@ -102,11 +102,31 @@ router.get('/', async (req: Request, res: Response) => { // Stable, predictable: chosen column, then mac as a deterministic tiebreaker. const orderBy = `ORDER BY ${sortExpr} ${sortDir} NULLS LAST, deduped.mac_address ASC`; - // Build WHERE clause shared by both queries + // Filters split across the deduplication boundary, and the split is the whole + // point of this block. + // + // A MAC can carry several rows — one per device that has seen it — and the + // DISTINCT ON below reduces them to the single row the table displays. Which + // side a filter belongs on therefore depends on what it means: + // + // * Filters that choose which *sightings* to consider (device, active) run + // before, so they shape what deduplication has to pick from. + // * Filters on values the table *shows* (type, VLAN, signal) run after, or + // the filter and the column disagree. + // + // Both halves matter here. A wireless client's MAC is also learned by the + // switch its AP uplinks to, so it holds both a wireless and a wired row — on + // this fleet all 13 wireless clients do — and deduplication resolves that in + // favour of wireless. Filtering the raw rows returned every one of them under + // "wired" while the list showed them as wireless. Equally, a client seen by two + // APs at different strengths would appear in a signal band its displayed row is + // not in (#100). const filters: string[] = []; + const outerFilters: string[] = []; const filterParams: unknown[] = []; let idx = 1; + // ── Before deduplication: which sightings count ── if (deviceId) { filters.push(`c.device_id = $${idx++}`); filterParams.push(deviceId); @@ -114,28 +134,30 @@ router.get('/', async (req: Request, res: Response) => { if (active === 'true') { filters.push(`c.active = TRUE`); } - if (client_type) { - filters.push(`c.client_type = $${idx++}`); - filterParams.push(client_type); - } if (search) { filters.push(`(c.mac_address ILIKE $${idx} OR c.custom_name ILIKE $${idx} OR c.hostname ILIKE $${idx} OR c.ip_address ILIKE $${idx} OR c.vendor ILIKE $${idx})`); filterParams.push(`%${search}%`); idx++; } + + // ── After deduplication: must match the row on screen ── + if (client_type) { + outerFilters.push(`deduped.client_type = $${idx++}`); + filterParams.push(client_type); + } if (vlan_id) { - filters.push(`c.vlan_id = $${idx++}`); + outerFilters.push(`deduped.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++}`); + outerFilters.push(`deduped.signal_strength >= $${idx++}`); filterParams.push(Number(signal_min)); } if (signal_max) { - filters.push(`c.signal_strength < $${idx++}`); + outerFilters.push(`deduped.signal_strength < $${idx++}`); filterParams.push(Number(signal_max)); } // Category is deliberately absent. The effective category is the user's override @@ -145,6 +167,7 @@ router.get('/', async (req: Request, res: Response) => { // at the cost of going stale whenever the rules change. const where = filters.length ? `WHERE ${filters.join(' AND ')}` : ''; + const outerWhere = outerFilters.length ? `WHERE ${outerFilters.join(' AND ')}` : ''; // Deduplicate by MAC address across devices: prefer active rows, then most recently seen. // The inner DISTINCT ON picks the best row per MAC; the outer query applies final sort + pagination. @@ -165,13 +188,20 @@ router.get('/', async (req: Request, res: Response) => { ) deduped LEFT JOIN client_traffic_daily ctd ON ctd.mac_address = LOWER(deduped.mac_address) AND ctd.day = CURRENT_DATE + ${outerWhere} ${orderBy} LIMIT $${idx++} OFFSET $${idx} `; const clients = await query(sql, [...filterParams, parseInt(String(limit), 10), parseInt(String(offset), 10)]); const countResult = await query<{ total: string }>( - `SELECT COUNT(DISTINCT c.mac_address) as total FROM clients c ${where}`, + `SELECT COUNT(*) as total FROM ( + SELECT DISTINCT ON (c.mac_address) c.client_type, c.vlan_id, c.signal_strength + FROM clients c + ${where} + ORDER BY c.mac_address, (c.client_type = 'wireless') DESC, c.active DESC, c.last_seen DESC NULLS LAST + ) deduped + ${outerWhere}`, filterParams ); diff --git a/frontend/package.json b/frontend/package.json index 9b8aba8..15fa436 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.23.13-beta", + "version": "0.23.14-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 67575a1..7e6d621 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.23.13 Beta'; +export const APP_VERSION = 'v0.23.14 Beta';