diff --git a/app/api/clients.py b/app/api/clients.py index f3c1620..a6ff210 100644 --- a/app/api/clients.py +++ b/app/api/clients.py @@ -6,14 +6,19 @@ import json import aiosqlite +from typing import Literal + from fastapi import APIRouter, Depends, HTTPException from app.database import get_db from app.dependencies import CurrentUser from app.wifi.freshness import client_is_current +from app.wifi.rf import SIGNAL_FAIR_DBM, SIGNAL_GOOD_DBM, signal_class router = APIRouter() +SignalFilter = Literal["good", "fair", "poor"] + # wifi_clients stores which radio a client is attached to (radio_id) but not # that radio's channel — join radios so callers can break clients out by @@ -41,6 +46,7 @@ def _client_out(row) -> dict: "channel_width_mhz": row["channel_width_mhz"], "protocol": row["protocol"], "rssi_dbm": row["rssi_dbm"], + "signal_class": signal_class(row["rssi_dbm"]) if row["rssi_dbm"] is not None else None, "snr_db": row["snr_db"], "tx_rate_mbps": row["tx_rate_mbps"], "rx_rate_mbps": row["rx_rate_mbps"], @@ -49,7 +55,9 @@ def _client_out(row) -> dict: } -def _list_filters(access_point_id: int | None, ssid: str | None, search: str | None) -> tuple[str, list]: +def _list_filters( + access_point_id: int | None, ssid: str | None, search: str | None, signal: SignalFilter | None = None, +) -> tuple[str, list]: # The list and its count show clients on air now, not every client the # poll engine ever stored (see app/wifi/freshness.py). where = f" WHERE {client_is_current('wc')}" @@ -60,6 +68,16 @@ def _list_filters(access_point_id: int | None, ssid: str | None, search: str | N if ssid: where += " AND wc.ssid = ?" params.append(ssid) + # Same cut-offs as signal_class(), so a Dashboard click lands on the clients it counted. + if signal == "good": + where += " AND wc.rssi_dbm >= ?" + params.append(SIGNAL_GOOD_DBM) + elif signal == "fair": + where += " AND wc.rssi_dbm >= ? AND wc.rssi_dbm < ?" + params.extend([SIGNAL_FAIR_DBM, SIGNAL_GOOD_DBM]) + elif signal == "poor": + where += " AND wc.rssi_dbm < ?" + params.append(SIGNAL_FAIR_DBM) if search: where += """ AND ( wc.hostname LIKE ? OR wc.mac_address LIKE ? OR wc.ip_address LIKE ? OR wc.ssid LIKE ? @@ -79,11 +97,12 @@ async def list_clients( access_point_id: int | None = None, ssid: str | None = None, search: str | None = None, + signal: SignalFilter | None = None, limit: int | None = None, offset: int = 0, db: aiosqlite.Connection = Depends(get_db), ): - where, params = _list_filters(access_point_id, ssid, search) + where, params = _list_filters(access_point_id, ssid, search, signal) query = _CLIENT_SELECT + where + " ORDER BY wc.last_seen DESC" if limit is not None: query += " LIMIT ? OFFSET ?" @@ -99,9 +118,10 @@ async def count_clients( access_point_id: int | None = None, ssid: str | None = None, search: str | None = None, + signal: SignalFilter | None = None, db: aiosqlite.Connection = Depends(get_db), ): - where, params = _list_filters(access_point_id, ssid, search) + where, params = _list_filters(access_point_id, ssid, search, signal) query = "SELECT COUNT(*) AS total FROM wifi_clients wc JOIN access_points ap ON ap.id = wc.access_point_id LEFT JOIN radios r ON r.id = wc.radio_id" + where async with db.execute(query, params) as cur: row = await cur.fetchone() diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 8c9d5c1..339636c 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -35,6 +35,8 @@ A searchable, paginated inventory of every AP across every configured controller A searchable, paginated list of connected wireless clients — SSID, band, channel, RSSI/SNR, tx/rx rate, real connect time, and which AP they're attached to. +Signal is colour-coded the same way as the Dashboard's Signal Quality — green good (≥ −65 dBm), amber fair (≥ −75 dBm), red poor — with a bar down the left of each row. The good / fair / poor buttons above the table filter to one level. Clicking a bar or a level in the Dashboard's **Signal Quality** opens this page already filtered to that level. + ## Metrics Pick an AP from the searchable list to see per-band channel-utilization, retry-rate, and client-count charts over a 1h/6h/24h/7d window. diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 280a984..606f08f 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -2,6 +2,7 @@ * pktWiFi API client — typed fetch wrappers. * Access token is stored in memory (not localStorage). */ +import type { SignalClass } from '../utils/rf' // new URLSearchParams({foo: undefined}) serializes to the literal string // "foo=undefined" instead of omitting the key — confirmed the hard way in @@ -176,9 +177,9 @@ export const api = { deleteAccessPoint: (id: number) => request(`/devices/${id}`, { method: 'DELETE' }), // -- Clients --------------------------------------------------------------------- - getClients: (params?: { access_point_id?: number; ssid?: string; search?: string; limit?: number; offset?: number }) => + getClients: (params?: { access_point_id?: number; ssid?: string; search?: string; signal?: SignalClass; limit?: number; offset?: number }) => request(`/clients${toQueryString(params)}`), - countClients: (params?: { access_point_id?: number; ssid?: string; search?: string }) => + countClients: (params?: { access_point_id?: number; ssid?: string; search?: string; signal?: SignalClass }) => request<{ total: number }>(`/clients/count${toQueryString(params)}`), getClient: (mac: string) => request(`/clients/${mac}`), getClientEvents: (mac: string) => request(`/clients/${mac}/events`), @@ -600,6 +601,7 @@ export interface WifiClient { channel_width_mhz: number | null protocol: string | null rssi_dbm: number | null + signal_class: SignalClass | null snr_db: number | null tx_rate_mbps: number | null rx_rate_mbps: number | null diff --git a/frontend/src/index.css b/frontend/src/index.css index aba1b88..ec62bfd 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -184,6 +184,8 @@ } .f-num-gold { color: var(--f-gold-hi); text-shadow: 0 0 26px rgba(216, 180, 110, 0.4); } .f-num-alarm { color: #ff8478; text-shadow: 0 0 26px rgba(255, 107, 94, 0.38); } + .f-num-good { color: #7ee0a8; text-shadow: 0 0 26px rgba(126, 224, 168, 0.34); } + .f-num-fair { color: #f3c265; text-shadow: 0 0 26px rgba(243, 194, 101, 0.34); } .f-num-ice { color: var(--f-ice); text-shadow: 0 0 26px rgba(126, 207, 226, 0.34); } /* ── Fading hairline rule ───────────────────────────────────────────── */ diff --git a/frontend/src/pages/Clients.tsx b/frontend/src/pages/Clients.tsx index b2ab350..9061703 100644 --- a/frontend/src/pages/Clients.tsx +++ b/frontend/src/pages/Clients.tsx @@ -1,11 +1,13 @@ import { useEffect, useState, useCallback } from 'react' import { useSearchParams } from 'react-router-dom' import { api, WifiClient } from '../api/client' +import { SIGNAL_COLOR, SignalClass } from '../utils/rf' import Pagination from '../components/Pagination' import HelpButton from '../components/HelpButton' const PAGE_SIZE_DEFAULT = 25 const PAGE_SIZE_OPTIONS = [25, 50, 75, 100] +const SIGNAL_CLASSES: SignalClass[] = ['poor', 'fair', 'good'] function fmtConnected(iso: string | null): string { if (!iso) return '—' @@ -22,6 +24,8 @@ export default function Clients() { const [searchParams, setSearchParams] = useSearchParams() const apFilterId = searchParams.get('access_point_id') const apFilterName = searchParams.get('access_point_name') + const sigParam = searchParams.get('signal') + const signalFilter = SIGNAL_CLASSES.find(c => c === sigParam) const [clients, setClients] = useState([]) const [total, setTotal] = useState(0) @@ -36,6 +40,7 @@ export default function Clients() { const filters = { access_point_id: apFilterId ? Number(apFilterId) : undefined, search: search || undefined, + signal: signalFilter, } Promise.all([ api.getClients({ ...filters, limit: size, offset: (toPage - 1) * size }), @@ -44,7 +49,7 @@ export default function Clients() { .then(([rows, countRes]) => { setClients(rows); setTotal(countRes.total) }) .catch(() => {}) .finally(() => setLoading(false)) - }, [apFilterId, search, pageSize]) + }, [apFilterId, search, signalFilter, pageSize]) useEffect(() => { load(1) }, [load]) @@ -53,6 +58,12 @@ export default function Clients() { load(1, size) } + const setSignalFilter = (c: SignalClass | null) => { + const next = new URLSearchParams(searchParams) + if (c) next.set('signal', c); else next.delete('signal') + setSearchParams(next) + } + const clearApFilter = () => { const next = new URLSearchParams(searchParams) next.delete('access_point_id') @@ -90,6 +101,24 @@ export default function Clients() { )} +
+ {SIGNAL_CLASSES.map(c => ( + + ))} +
{search && !apFilterId && ( )} @@ -128,14 +157,18 @@ export default function Clients() { {clients.map(c => ( - + {c.hostname || c.mac_address} {c.ssid ?? '—'} {c.band ?? '—'} {c.channel != null ? `ch ${c.channel}${c.channel_width_mhz ? ` @ ${c.channel_width_mhz}MHz` : ''}` : '—'} - + {c.rssi_dbm != null ? `${c.rssi_dbm} dBm` : '—'}{c.snr_db != null ? ` (${c.snr_db.toFixed(0)} dB SNR)` : ''} @@ -147,7 +180,7 @@ export default function Clients() { ))} {clients.length === 0 && ( - {search || apFilterId ? 'No clients match your filters.' : 'No clients seen yet.'} + {search || apFilterId || signalFilter ? 'No clients match your filters.' : 'No clients seen yet.'} )} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 213c7d3..2da6b53 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -19,7 +19,7 @@ import { axisProps, tooltipProps, gridProps, glow, INSTRUMENT, InstrumentFrame, RadialRing, FlowDefs, NodeRail, LinePulseGradient, liveEdgeDot, } from '../components/instrument' -import { ALARM, SIGNAL_COLOR, bandColor, bandLabel, bandRank, signalClass } from '../utils/rf' +import { ALARM, SIGNAL_COLOR, SignalClass, bandColor, bandLabel, bandRank, signalClass } from '../utils/rf' // ── Constants ────────────────────────────────────────────────────────────────── @@ -173,13 +173,13 @@ function Readout({ label, value, unit, sub, tone = 'ink', gauge, onClick }: { value: ReactNode unit?: string sub?: ReactNode - tone?: 'ink' | 'gold' | 'ice' | 'alarm' + tone?: 'ink' | 'gold' | 'ice' | 'alarm' | 'good' | 'fair' gauge?: number onClick?: () => void }) { const R = 19 const C = 2 * Math.PI * R - const toneClass = { ink: 'text-white', gold: 'f-num-gold', ice: 'f-num-ice', alarm: 'f-num-alarm' }[tone] + const toneClass = { ink: 'text-white', gold: 'f-num-gold', ice: 'f-num-ice', alarm: 'f-num-alarm', good: 'f-num-good', fair: 'f-num-fair' }[tone] return (
void }) { const [ref, W] = useWidth(360) const H = 128, PAD_T = 14, PAD_B = 18 const bins = signal.histogram @@ -541,11 +541,12 @@ function SignalQuality({ signal }: { signal: DashboardData['signal'] }) { {/* Weakest first, so the ledger reads in the same direction as the bars. */}
{(['poor', 'fair', 'good'] as const).map(c => ( - + ))}
@@ -556,10 +557,11 @@ function SignalQuality({ signal }: { signal: DashboardData['signal'] }) { const h = (b.clients / peak) * plotH const c = SIGNAL_COLOR[signalClass(b.dbm, signal.good_dbm, signal.fair_dbm)] return ( - + onOpen(signalClass(b.dbm, signal.good_dbm, signal.fair_dbm)) : undefined} + style={b.clients ? { cursor: 'pointer' } : undefined}> - {`${b.dbm} to ${b.dbm + 5} dBm: ${b.clients} client${b.clients === 1 ? '' : 's'}`} + {`${b.dbm} to ${b.dbm + 5} dBm: ${b.clients} client${b.clients === 1 ? '' : 's'}${b.clients ? ' — click to list' : ''}`} {b.clients > 0 && ( share >= 0.75 ? 'good' : share >= 0.5 ? 'fair' : 'alarm' + const apTone = ap.total ? shareTone(ap.online / ap.total) : 'ink' + const clientTone = sig.measured ? shareTone(sig.good / sig.measured) : 'ice' const openMetrics = (id: number) => navigate(`/metrics?ap=${id}`) return ( <> -
{ap.online} online · {ap.offline} offline{ap.rogue > 0 && · {ap.rogue} rogue}} onClick={() => navigate('/access-points')} /> + `${bandLabel(b.band)} ${b.clients}`).join(' · ') || 'none connected'} + onClick={() => navigate('/clients')} + /> + navigate('/alerts')} + /> - `${bandLabel(b.band)} ${b.clients}`).join(' · ') || 'none connected'} - onClick={() => navigate('/clients')} - /> - navigate('/alerts')} - />
@@ -838,7 +845,7 @@ function Estate({ data }: { data: DashboardData }) { }> - + navigate(`/clients?signal=${c}`)} />