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:
2GT-Rich
2026-08-23 13:58:58 -07:00
co-authored by Claude Opus 5
parent ec873ab8bd
commit 7a548abfd8
5 changed files with 43 additions and 13 deletions
+39 -9
View File
@@ -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
);