diff --git a/README.md b/README.md index c9dd690..e0cd2a0 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@
-
+
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';