Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions app/api/clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"],
Expand All @@ -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')}"
Expand All @@ -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 ?
Expand All @@ -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 ?"
Expand All @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<WifiClient[]>(`/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<WifiClient>(`/clients/${mac}`),
getClientEvents: (mac: string) => request<ClientEvent[]>(`/clients/${mac}/events`),
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────── */
Expand Down
41 changes: 37 additions & 4 deletions frontend/src/pages/Clients.tsx
Original file line number Diff line number Diff line change
@@ -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 '—'
Expand All @@ -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<WifiClient[]>([])
const [total, setTotal] = useState(0)
Expand All @@ -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 }),
Expand All @@ -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])

Expand All @@ -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')
Expand Down Expand Up @@ -90,6 +101,24 @@ export default function Clients() {
<button onClick={clearApFilter} className="text-sky-500 hover:text-sky-200 ml-1" title="Clear AP filter">✕</button>
</div>
)}
<div className="flex items-center gap-2">
{SIGNAL_CLASSES.map(c => (
<button
key={c}
onClick={() => setSignalFilter(signalFilter === c ? null : c)}
aria-pressed={signalFilter === c}
className="flex items-center gap-1.5 text-xs text-white border px-2.5 py-1.5"
style={{
borderColor: signalFilter === c ? SIGNAL_COLOR[c] : 'rgba(255,255,255,.12)',
background: signalFilter === c ? `${SIGNAL_COLOR[c]}22` : undefined,
}}
title={`Show ${c} signal only`}
>
<span className="w-1.5 h-1.5 rounded-full" style={{ background: SIGNAL_COLOR[c] }} />
{c}
</button>
))}
</div>
{search && !apFilterId && (
<button onClick={() => setSearch('')} className="text-xs text-white hover:text-white">Clear</button>
)}
Expand Down Expand Up @@ -128,14 +157,18 @@ export default function Clients() {
</thead>
<tbody>
{clients.map(c => (
<tr key={c.id} className="border-t border-gray-800">
<tr
key={c.id}
className="border-t border-gray-800"
style={c.signal_class ? { boxShadow: `inset 3px 0 0 ${SIGNAL_COLOR[c.signal_class]}` } : undefined}
>
<td className="px-4 py-2 text-white">{c.hostname || c.mac_address}</td>
<td className="px-4 py-2 text-gray-300">{c.ssid ?? '—'}</td>
<td className="px-4 py-2 text-gray-300">{c.band ?? '—'}</td>
<td className="px-4 py-2 text-gray-300">
{c.channel != null ? `ch ${c.channel}${c.channel_width_mhz ? ` @ ${c.channel_width_mhz}MHz` : ''}` : '—'}
</td>
<td className="px-4 py-2 text-gray-300">
<td className="px-4 py-2 text-gray-300" style={c.signal_class ? { color: SIGNAL_COLOR[c.signal_class] } : undefined}>
{c.rssi_dbm != null ? `${c.rssi_dbm} dBm` : '—'}{c.snr_db != null ? ` (${c.snr_db.toFixed(0)} dB SNR)` : ''}
</td>
<td className="px-4 py-2 text-gray-300">
Expand All @@ -147,7 +180,7 @@ export default function Clients() {
))}
{clients.length === 0 && (
<tr><td colSpan={8} className="px-4 py-8 text-center text-gray-500">
{search || apFilterId ? 'No clients match your filters.' : 'No clients seen yet.'}
{search || apFilterId || signalFilter ? 'No clients match your filters.' : 'No clients seen yet.'}
</td></tr>
)}
</tbody>
Expand Down
55 changes: 31 additions & 24 deletions frontend/src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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 (
<div
className={`f-tick relative bg-gray-950 px-4 py-3.5 min-h-[104px] flex flex-col min-w-0 transition-colors ${
Expand Down Expand Up @@ -515,7 +515,7 @@ function ClientMix({ clients, generations }: {

// ── Signal quality ─────────────────────────────────────────────────────────────

function SignalQuality({ signal }: { signal: DashboardData['signal'] }) {
function SignalQuality({ signal, onOpen }: { signal: DashboardData['signal']; onOpen: (c: SignalClass) => void }) {
const [ref, W] = useWidth(360)
const H = 128, PAD_T = 14, PAD_B = 18
const bins = signal.histogram
Expand All @@ -541,11 +541,12 @@ function SignalQuality({ signal }: { signal: DashboardData['signal'] }) {
{/* Weakest first, so the ledger reads in the same direction as the bars. */}
<div className="flex items-center gap-3">
{(['poor', 'fair', 'good'] as const).map(c => (
<span key={c} className="flex items-center gap-1.5">
<button key={c} onClick={() => onOpen(c)} title={`Show ${c} signal clients`}
className="flex items-center gap-1.5 hover:opacity-80">
<span className="w-1.5 h-1.5 rounded-full" style={{ background: SIGNAL_COLOR[c], boxShadow: `0 0 6px ${SIGNAL_COLOR[c]}` }} />
<span className="font-mono text-[11px] text-white">{signal[c]}</span>
<span className="f-lbl">{c}</span>
</span>
</button>
))}
</div>
</div>
Expand All @@ -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 (
<g key={b.dbm}>
<g key={b.dbm} onClick={b.clients ? () => onOpen(signalClass(b.dbm, signal.good_dbm, signal.fair_dbm)) : undefined}
style={b.clients ? { cursor: 'pointer' } : undefined}>
<rect x={xAt(b.dbm) + 2} y={PAD_T + plotH - h} width={Math.max(1, bw - 4)} height={h}
fill={c} fillOpacity={0.72} style={b.clients ? glow(c, 4) : undefined}>
<title>{`${b.dbm} to ${b.dbm + 5} dBm: ${b.clients} client${b.clients === 1 ? '' : 's'}`}</title>
<title>{`${b.dbm} to ${b.dbm + 5} dBm: ${b.clients} client${b.clients === 1 ? '' : 's'}${b.clients ? ' — click to list' : ''}`}</title>
</rect>
{b.clients > 0 && (
<text x={xAt(b.dbm) + bw / 2} y={PAD_T + plotH - h - 3} textAnchor="middle"
Expand Down Expand Up @@ -742,18 +744,37 @@ function Estate({ data }: { data: DashboardData }) {
const sig = data.signal
const goodShare = sig.measured ? Math.round((sig.good / sig.measured) * 100) : 0
const alerts = data.alerts
// Share-based service level: 75%+ green, 50-75% amber, under 50% red.
const shareTone = (share: number) => 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 (
<>
<div className="grid grid-cols-3 xl:grid-cols-6 gap-px border"
<div className="grid grid-cols-3 gap-px border"
style={{ background: 'rgba(216,180,110,.08)', borderColor: 'rgba(216,180,110,.08)' }}>
<Readout
label="Access Points"
value={ap.total.toLocaleString()}
tone={apTone}
sub={<>{ap.online} online · {ap.offline} offline{ap.rogue > 0 && <span className="text-red-400"> · {ap.rogue} rogue</span>}</>}
onClick={() => navigate('/access-points')}
/>
<Readout
label="Clients"
value={data.clients.total.toLocaleString()}
tone={clientTone}
sub={data.clients.by_band.map(b => `${bandLabel(b.band)} ${b.clients}`).join(' · ') || 'none connected'}
onClick={() => navigate('/clients')}
/>
<Readout
label="Active Alerts"
value={alerts.active}
tone={alerts.active ? 'alarm' : 'good'}
sub={alerts.active ? `${alerts.unacked} unacknowledged` : 'all clear'}
onClick={() => navigate('/alerts')}
/>
<Readout
label="Availability"
value={Math.round(availability)}
Expand All @@ -762,13 +783,6 @@ function Estate({ data }: { data: DashboardData }) {
gauge={availability}
sub={`${ap.online} of ${ap.total} online`}
/>
<Readout
label="Clients"
value={data.clients.total.toLocaleString()}
tone="ice"
sub={data.clients.by_band.map(b => `${bandLabel(b.band)} ${b.clients}`).join(' · ') || 'none connected'}
onClick={() => navigate('/clients')}
/>
<Readout
label="Airtime · mean"
value={air.mean_pct != null ? Math.round(air.mean_pct) : '—'}
Expand All @@ -786,13 +800,6 @@ function Estate({ data }: { data: DashboardData }) {
tone={sig.median_dbm != null && sig.median_dbm < sig.fair_dbm ? 'alarm' : 'ink'}
sub={sig.measured ? `${goodShare}% good · ${sig.poor} poor` : 'no client reports RSSI'}
/>
<Readout
label="Active Alerts"
value={alerts.active}
tone={alerts.active ? 'alarm' : 'ink'}
sub={alerts.active ? `${alerts.unacked} unacknowledged` : 'all clear'}
onClick={() => navigate('/alerts')}
/>
</div>

<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
Expand Down Expand Up @@ -838,7 +845,7 @@ function Estate({ data }: { data: DashboardData }) {
<ClientMix clients={data.clients} generations={data.generations} />
</Card>
<Card title="Signal Quality" chip={<NowChip />}>
<SignalQuality signal={sig} />
<SignalQuality signal={sig} onOpen={c => navigate(`/clients?signal=${c}`)} />
</Card>
</div>

Expand Down
Loading