fix(clients): filter the row the table shows, not the rows behind it (v0.23.14-beta)
Closes #100. The clients list deduplicates by MAC: a client seen by several devices collapses to one row, and DISTINCT ON resolves the collision in favour of the wireless sighting. The filters, however, ran against the raw rows before that happened, so a filter and the column beside it could disagree. The wired/wireless filter showed it plainly. A wireless client's MAC is also learned by the switch its AP uplinks to, so it holds both a wireless row and a wired one — on the test fleet every wireless client does. Filtering by "wired" therefore returned all 176 clients rather than 163, including the 13 the table was labelling wireless. Filtering by signal had the same flaw in a subtler form: a client seen by two APs at different strengths could appear in a band its displayed row was not in. Filters now sit on the correct side of deduplication according to what they mean. Device and active-state choose which sightings to consider, so they still run before. Type, VLAN and signal describe what the table displays, so they run after. The count query deduplicates identically, so totals and rows agree. Verified against the live fleet by running the same predicates as SQL and through the API in the same moment: 176 total, 162 wired, 14 wireless, 29 on VLAN 1, 13 at -67 dBm or stronger — matching on every one, with the wired and wireless halves now summing to the total. Also closes four issues that were implemented in v0.23.9 and v0.23.10 but stayed open: their commit messages listed several numbers behind a single "Closes" keyword, which GitHub applies only to the first. #97 — location selector on the Channel Usage panel #102 — snoozeable "Things to handle" items #107 — rack and location filters on the device table #108 — topic, device and severity filters on the events table Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ec873ab8bd
commit
7a548abfd8
@@ -6,7 +6,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/version-0.23.13_Beta-blue" alt="Version" />
|
<img src="https://img.shields.io/badge/version-0.23.14_Beta-blue" alt="Version" />
|
||||||
<img src="https://img.shields.io/badge/license-AGPLv3-blue" alt="License" />
|
<img src="https://img.shields.io/badge/license-AGPLv3-blue" alt="License" />
|
||||||
<img src="https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white" alt="Docker" />
|
<img src="https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white" alt="Docker" />
|
||||||
<img src="https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white" alt="TypeScript" />
|
<img src="https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white" alt="TypeScript" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mikrotik-manager-backend",
|
"name": "mikrotik-manager-backend",
|
||||||
"version": "0.23.13-beta",
|
"version": "0.23.14-beta",
|
||||||
"description": "Mikrotik Network Management Platform - Backend API",
|
"description": "Mikrotik Network Management Platform - Backend API",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -102,11 +102,31 @@ router.get('/', async (req: Request, res: Response) => {
|
|||||||
// Stable, predictable: chosen column, then mac as a deterministic tiebreaker.
|
// Stable, predictable: chosen column, then mac as a deterministic tiebreaker.
|
||||||
const orderBy = `ORDER BY ${sortExpr} ${sortDir} NULLS LAST, deduped.mac_address ASC`;
|
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 filters: string[] = [];
|
||||||
|
const outerFilters: string[] = [];
|
||||||
const filterParams: unknown[] = [];
|
const filterParams: unknown[] = [];
|
||||||
let idx = 1;
|
let idx = 1;
|
||||||
|
|
||||||
|
// ── Before deduplication: which sightings count ──
|
||||||
if (deviceId) {
|
if (deviceId) {
|
||||||
filters.push(`c.device_id = $${idx++}`);
|
filters.push(`c.device_id = $${idx++}`);
|
||||||
filterParams.push(deviceId);
|
filterParams.push(deviceId);
|
||||||
@@ -114,28 +134,30 @@ router.get('/', async (req: Request, res: Response) => {
|
|||||||
if (active === 'true') {
|
if (active === 'true') {
|
||||||
filters.push(`c.active = TRUE`);
|
filters.push(`c.active = TRUE`);
|
||||||
}
|
}
|
||||||
if (client_type) {
|
|
||||||
filters.push(`c.client_type = $${idx++}`);
|
|
||||||
filterParams.push(client_type);
|
|
||||||
}
|
|
||||||
if (search) {
|
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})`);
|
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}%`);
|
filterParams.push(`%${search}%`);
|
||||||
idx++;
|
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) {
|
if (vlan_id) {
|
||||||
filters.push(`c.vlan_id = $${idx++}`);
|
outerFilters.push(`deduped.vlan_id = $${idx++}`);
|
||||||
filterParams.push(vlan_id);
|
filterParams.push(vlan_id);
|
||||||
}
|
}
|
||||||
// Signal range, so the AP-density chart can hand off the band the user clicked
|
// 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
|
// (issues #99, #100). Bounds are inclusive of min and exclusive of max, matching
|
||||||
// how the density buckets are drawn.
|
// how the density buckets are drawn.
|
||||||
if (signal_min) {
|
if (signal_min) {
|
||||||
filters.push(`c.signal_strength >= $${idx++}`);
|
outerFilters.push(`deduped.signal_strength >= $${idx++}`);
|
||||||
filterParams.push(Number(signal_min));
|
filterParams.push(Number(signal_min));
|
||||||
}
|
}
|
||||||
if (signal_max) {
|
if (signal_max) {
|
||||||
filters.push(`c.signal_strength < $${idx++}`);
|
outerFilters.push(`deduped.signal_strength < $${idx++}`);
|
||||||
filterParams.push(Number(signal_max));
|
filterParams.push(Number(signal_max));
|
||||||
}
|
}
|
||||||
// Category is deliberately absent. The effective category is the user's override
|
// 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.
|
// at the cost of going stale whenever the rules change.
|
||||||
|
|
||||||
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
|
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.
|
// 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.
|
// 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
|
) deduped
|
||||||
LEFT JOIN client_traffic_daily ctd
|
LEFT JOIN client_traffic_daily ctd
|
||||||
ON ctd.mac_address = LOWER(deduped.mac_address) AND ctd.day = CURRENT_DATE
|
ON ctd.mac_address = LOWER(deduped.mac_address) AND ctd.day = CURRENT_DATE
|
||||||
|
${outerWhere}
|
||||||
${orderBy}
|
${orderBy}
|
||||||
LIMIT $${idx++} OFFSET $${idx}
|
LIMIT $${idx++} OFFSET $${idx}
|
||||||
`;
|
`;
|
||||||
const clients = await query(sql, [...filterParams, parseInt(String(limit), 10), parseInt(String(offset), 10)]);
|
const clients = await query(sql, [...filterParams, parseInt(String(limit), 10), parseInt(String(offset), 10)]);
|
||||||
|
|
||||||
const countResult = await query<{ total: string }>(
|
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
|
filterParams
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mikrotik-manager-frontend",
|
"name": "mikrotik-manager-frontend",
|
||||||
"version": "0.23.13-beta",
|
"version": "0.23.14-beta",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = 'v0.23.13 Beta';
|
export const APP_VERSION = 'v0.23.14 Beta';
|
||||||
|
|||||||
Reference in New Issue
Block a user