feat(wireless+ports): RF Health panels + physical per-port clients (v0.16.1-beta)

Wireless RF Health (fleet Wireless page + per-AP Radios tab):
- Channel usage map across 2.4/5/6 GHz with co-channel overlap; operating
  channel resolved live from /interface/wifi/monitor on the wifi package
  (auto-channel radios report no frequency in /print).
- AP deployment density: client RSSI distribution on the -90..-30 dBm scale
  with a coverage-gap warning.
- AP radio TX retries histogram (0%->35%+) with band selector, derived from
  per-client CCQ (new wireless_radio_quality InfluxDB series).
- WiFi connectivity success funnel (Association/Auth/DHCP) derived from logs.

Per-port connected clients (Ports tab):
- New card lists clients physically connected to a selected port (host/MAC/
  IP/VLAN/connection time, linked to client pages).
- Distinguishes physical clients from the raw MAC table: uplink/trunk ports
  are auto-detected (topology neighbor, multi-VLAN MAC spread, or high MAC
  count) and show an uplink explainer + "view MAC table" disclosure instead
  of every reachable MAC.
- Added clients.first_seen ("connected since"); set once, preserved across polls.
This commit is contained in:
2GT-Rich
2026-06-25 15:37:08 -07:00
parent 1be161e67a
commit 4e6c9f481e
17 changed files with 994 additions and 35 deletions
@@ -10,6 +10,7 @@ import {
} from 'recharts';
import { wirelessApi } from '../../services/api';
import { useCanWrite } from '../../hooks/useCanWrite';
import RfHealth from '../wireless/RfHealth';
import type { WirelessInterface, WirelessMetricPoint } from '../../types';
import clsx from 'clsx';
import { format } from 'date-fns';
@@ -1103,6 +1104,15 @@ export default function RadiosTab({ deviceId, deviceStatus }: Props) {
</div>
</div>
{/* RF Health — channel map, client density, TX retries, connectivity */}
<div>
<h2 className="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-2 mb-3">
<Signal className="w-4 h-4 text-blue-500" />
RF Health
</h2>
<RfHealth deviceId={deviceId} />
</div>
{/* Performance Charts */}
<div>
<h2 className="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-2 mb-3">
@@ -1,13 +1,15 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { RefreshCw, X, Check, AlertCircle, Activity, Cpu, Link2, Trash2, Plus, Network } from 'lucide-react';
import { RefreshCw, X, Check, AlertCircle, Activity, Cpu, Link2, Trash2, Plus, Network, Users, Wifi } from 'lucide-react';
import { devicesApi, metricsApi } from '../../services/api';
import { useCanWrite } from '../../hooks/useCanWrite';
import type { SwitchPort, Vlan, TrafficPoint, PortMonitorData } from '../../types';
import type { SwitchPort, Vlan, TrafficPoint, PortMonitorData, PortClient } from '../../types';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, Legend,
} from 'recharts';
import { formatDistanceToNow } from 'date-fns';
import clsx from 'clsx';
interface Props {
@@ -453,6 +455,136 @@ function PortInfoCard({ deviceId, portName }: { deviceId: number; portName: stri
);
}
// Table of client rows, reused for the connected list and the "view MAC table" disclosure.
function PortClientRows({ clients }: { clients: PortClient[] }) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/40">
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Host / Vendor</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">MAC</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">IP</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">VLAN</th>
<th className="px-4 py-2.5 text-right text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Connected</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-slate-700/50">
{clients.map((c, i) => {
const name = c.custom_name || c.hostname;
return (
<tr key={c.mac_address}
className={clsx('transition-colors hover:bg-blue-50/40 dark:hover:bg-slate-700/30',
i % 2 === 0 ? 'bg-white dark:bg-slate-900/20' : 'bg-gray-50 dark:bg-slate-800/40')}>
<td className="px-4 py-2.5">
<Link to={`/clients/${encodeURIComponent(c.mac_address)}`}
className="font-medium text-blue-600 dark:text-blue-400 hover:underline inline-flex items-center gap-1.5">
{c.client_type === 'wireless' && <Wifi className="w-3 h-3 flex-shrink-0" />}
{name || <span className="text-gray-400 dark:text-slate-500 italic">No hostname</span>}
</Link>
{c.vendor && <div className="text-xs text-gray-400 dark:text-slate-500 truncate">{c.vendor}</div>}
</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-600 dark:text-slate-400">{c.mac_address}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-600 dark:text-slate-400">{c.ip_address || '—'}</td>
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400">
{c.vlan_id != null
? <span className="px-1.5 py-0.5 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded font-mono">{c.vlan_id}</span>
: '—'}
</td>
<td className="px-4 py-2.5 text-right text-xs text-gray-500 dark:text-slate-400"
title={c.first_seen ? new Date(c.first_seen).toLocaleString() : undefined}>
{c.first_seen ? formatDistanceToNow(new Date(c.first_seen), { addSuffix: false }) : '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
// Clients on a single port. The bridge FDB lists every MAC reachable *through*
// a port, so on an uplink/trunk that's the whole network — not what's physically
// plugged in. We classify the port and only show genuinely-connected clients by
// default, with a disclosure to view the full MAC table.
function PortClientsCard({ deviceId, portName }: { deviceId: number; portName: string }) {
const [showMacTable, setShowMacTable] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ['port-clients', deviceId, portName],
queryFn: () => devicesApi.getPortClients(deviceId, portName).then((r) => r.data),
refetchInterval: 30_000,
});
const { data: macTable } = useQuery({
queryKey: ['port-clients-all', deviceId, portName],
queryFn: () => devicesApi.getPortClients(deviceId, portName, true).then((r) => r.data),
enabled: showMacTable,
});
const isUplink = data?.classification === 'uplink';
const connected = data?.clients ?? [];
return (
<div className="card overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
<Users className="w-4 h-4 text-blue-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
Connected Clients
<span className="ml-2 text-xs font-normal text-gray-400 dark:text-slate-500 font-mono">{portName}</span>
</h3>
{!isLoading && !isUplink && (
<span className="ml-auto text-xs text-gray-400 dark:text-slate-500">
{connected.length} device{connected.length !== 1 ? 's' : ''}
</span>
)}
</div>
{isLoading ? (
<div className="p-6 text-center text-sm text-gray-400 dark:text-slate-500">
<RefreshCw className="w-4 h-4 animate-spin inline mr-1" />Loading…
</div>
) : isUplink ? (
<div className="p-5 space-y-3">
<div className="flex items-start gap-2.5">
<Link2 className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" />
<div className="text-sm">
<div className="font-medium text-gray-800 dark:text-slate-200">This looks like an uplink / trunk port</div>
<p className="text-gray-500 dark:text-slate-400 mt-0.5">
{data?.reason}. The {data?.mac_count} MAC address{data?.mac_count !== 1 ? 'es' : ''} learned here are
reachable <em>through</em> this port, not physically connected to it.
</p>
</div>
</div>
<button
onClick={() => setShowMacTable(v => !v)}
className="text-xs font-medium text-blue-600 dark:text-blue-400 hover:underline"
>
{showMacTable ? 'Hide' : 'View'} MAC table ({data?.mac_count})
</button>
{showMacTable && (
macTable
? <div className="-mx-5 -mb-5 border-t border-gray-100 dark:border-slate-700/60">
<div className="px-5 py-2 text-xs text-gray-400 dark:text-slate-500 bg-gray-50 dark:bg-slate-800/40">
Reachable via this port — not necessarily physically connected.
</div>
<PortClientRows clients={macTable.clients} />
</div>
: <div className="text-xs text-gray-400"><RefreshCw className="w-3 h-3 animate-spin inline mr-1" />Loading…</div>
)}
</div>
) : connected.length === 0 ? (
<div className="p-6 text-center text-sm text-gray-400 dark:text-slate-500">
No clients currently connected to this port.
</div>
) : (
<PortClientRows clients={connected} />
)}
</div>
);
}
export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOpened }: Props) {
const queryClient = useQueryClient();
const canWrite = useCanWrite();
@@ -988,25 +1120,28 @@ export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOp
{selectedPorts.size === 1 && (() => {
const portName = Array.from(selectedPorts)[0];
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-stretch">
<div className="md:col-span-1">
<PortTrafficGraph
deviceId={deviceId}
portName={portName}
range={trafficRange}
onRangeChange={setTrafficRange}
/>
</div>
<div className="md:col-span-1">
<PortPacketGraph
deviceId={deviceId}
portName={portName}
range={trafficRange}
/>
</div>
<div className="md:col-span-1">
<PortInfoCard deviceId={deviceId} portName={portName} />
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-stretch">
<div className="md:col-span-1">
<PortTrafficGraph
deviceId={deviceId}
portName={portName}
range={trafficRange}
onRangeChange={setTrafficRange}
/>
</div>
<div className="md:col-span-1">
<PortPacketGraph
deviceId={deviceId}
portName={portName}
range={trafficRange}
/>
</div>
<div className="md:col-span-1">
<PortInfoCard deviceId={deviceId} portName={portName} />
</div>
</div>
<PortClientsCard deviceId={deviceId} portName={portName} />
</div>
);
})()}
@@ -0,0 +1,330 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Radio, Signal, Activity, ShieldCheck, AlertTriangle } from 'lucide-react';
import clsx from 'clsx';
import { wirelessApi } from '../../services/api';
import type {
RfChannelRow, RfSignalRow, RfTxQualityRow, RfConnectivity,
} from '../../types';
import {
BAND_CHANNELS, BAND_LABEL, RfBand, channelForFreq, bandForFreq, widthMhz,
RSSI_ZONES, rssiColor, RETRY_BUCKETS, retryBucketIndex, retryColor,
} from '../../utils/wifiChannels';
const ALL_BANDS: RfBand[] = ['2.4', '5', '6'];
// ─── Channel Map ──────────────────────────────────────────────────────────────
interface CellInfo { count: number; labels: string[] }
function ChannelBandRow({ band, radios }: { band: RfBand; radios: RfChannelRow[] }) {
const channels = BAND_CHANNELS[band];
const cells: CellInfo[] = channels.map(() => ({ count: 0, labels: [] }));
for (const r of radios) {
const ch = channelForFreq(r.frequency);
if (ch == null) continue;
const idx = channels.indexOf(ch);
if (idx < 0) continue;
const span = Math.max(1, Math.round(widthMhz(r.channel_width) / 20));
const start = Math.max(0, idx - Math.floor((span - 1) / 2));
const end = Math.min(channels.length - 1, start + span - 1);
for (let i = start; i <= end; i++) cells[i].count++;
cells[idx].labels.push(
`${r.device_name}${r.ssid ? ` · ${r.ssid}` : ''} — ch ${ch}, ${widthMhz(r.channel_width)} MHz, ${r.registered_clients} clients`
);
}
const cellColor = (c: CellInfo) =>
c.count === 0 ? 'bg-gray-200 dark:bg-slate-700'
: c.count === 1 ? 'bg-green-500'
: 'bg-amber-500'; // co-channel overlap
return (
<div>
<div className="text-xs font-medium text-gray-500 dark:text-slate-400 mb-1">{BAND_LABEL[band]}</div>
<div className="flex gap-px">
{cells.map((c, i) => (
<div key={channels[i]}
title={c.labels.length ? c.labels.join('\n') : `Channel ${channels[i]} — unused`}
className={clsx('h-2.5 flex-1 rounded-sm transition-colors', cellColor(c))} />
))}
</div>
<div className="flex mt-1">
{channels.map(ch => (
<div key={ch} className="flex-1 text-center text-[9px] leading-none text-gray-400 dark:text-slate-500">{ch}</div>
))}
</div>
</div>
);
}
export function ChannelMap({ deviceId }: { deviceId?: number }) {
const { data: radios = [] } = useQuery({
queryKey: ['rf-channels', deviceId],
queryFn: () => wirelessApi.getChannelUsage(deviceId).then(r => r.data),
refetchInterval: 60_000,
});
const byBand = (b: RfBand) => radios.filter(r => bandForFreq(r.frequency) === b);
// 2.4 + 5 always; 6 only when present (avoids an always-empty 59-cell row)
const bands = ALL_BANDS.filter(b => b !== '6' || byBand('6').length > 0);
return (
<div className="card p-5">
<div className="flex items-center gap-2 mb-4">
<Radio className="w-4 h-4 text-blue-500" />
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">Channel Usage</h2>
<span className="ml-auto flex items-center gap-3 text-[11px] text-gray-400 dark:text-slate-500">
<span className="flex items-center gap-1"><span className="w-2.5 h-2.5 rounded-sm bg-green-500" />in use</span>
<span className="flex items-center gap-1"><span className="w-2.5 h-2.5 rounded-sm bg-amber-500" />overlap</span>
</span>
</div>
{radios.length === 0 ? (
<div className="py-6 text-center text-sm text-gray-400 dark:text-slate-500">
No active radios reporting a channel yet.
</div>
) : (
<div className="space-y-4">
{bands.map(b => <ChannelBandRow key={b} band={b} radios={byBand(b)} />)}
</div>
)}
</div>
);
}
// ─── RSSI Density ──────────────────────────────────────────────────────────────
const RSSI_MIN = -90;
const RSSI_MAX = -30;
const RSSI_TICKS = [-90, -80, -70, -60, -50, -40, -30];
export function RssiDensity({ deviceId }: { deviceId?: number }) {
const { data: signals = [] } = useQuery({
queryKey: ['rf-signals', deviceId],
queryFn: () => wirelessApi.getClientSignals(deviceId).then(r => r.data),
refetchInterval: 30_000,
});
const clamped = (dbm: number) => Math.max(RSSI_MIN, Math.min(RSSI_MAX, dbm));
const pos = (dbm: number) => ((clamped(dbm) - RSSI_MIN) / (RSSI_MAX - RSSI_MIN)) * 100;
// Bin clients into 2 dB bins so co-located clients form a sized bubble.
const bins = new Map<number, RfSignalRow[]>();
for (const s of signals) {
const bin = Math.round(clamped(s.signal_strength) / 2) * 2;
if (!bins.has(bin)) bins.set(bin, []);
bins.get(bin)!.push(s);
}
const weak = signals.filter(s => s.signal_strength < -75).length;
const weakRatio = signals.length ? weak / signals.length : 0;
const needsImprovement = signals.length >= 3 && weakRatio > 0.3;
return (
<div className="card p-5">
<div className="flex items-center gap-2 mb-4">
<Signal className="w-4 h-4 text-emerald-500" />
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">AP Deployment Density</h2>
<span className="ml-auto text-[11px] text-gray-400 dark:text-slate-500">
{signals.length} client{signals.length !== 1 ? 's' : ''} by signal (dBm)
</span>
</div>
{signals.length === 0 ? (
<div className="py-6 text-center text-sm text-gray-400 dark:text-slate-500">
No connected wireless clients to plot.
</div>
) : (
<>
{needsImprovement && (
<div className="flex items-start gap-2 mb-3 text-xs text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded-lg px-3 py-2">
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<span>AP deployment density may need improvement — {weak} of {signals.length} clients are connecting below −75 dBm, which suggests coverage gaps.</span>
</div>
)}
{/* Density track */}
<div className="relative h-16">
{/* gradient zone background */}
<div className="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full overflow-hidden flex">
{RSSI_ZONES.map(z => (
<div key={z.label} style={{
width: `${((Math.min(z.max, RSSI_MAX) - Math.max(z.min, RSSI_MIN)) / (RSSI_MAX - RSSI_MIN)) * 100}%`,
backgroundColor: z.color, opacity: 0.25,
}} />
))}
</div>
{/* client bubbles */}
{Array.from(bins.entries()).map(([bin, rows]) => {
const size = Math.min(34, 12 + (rows.length - 1) * 4);
return (
<div key={bin}
title={rows.map(r => `${r.custom_name || r.hostname || r.mac_address} (${r.signal_strength} dBm, ${r.device_name})`).join('\n')}
className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full flex items-center justify-center text-[10px] font-semibold text-white shadow ring-2 ring-white dark:ring-slate-800"
style={{ left: `${pos(bin)}%`, width: size, height: size, backgroundColor: rssiColor(bin) }}>
{rows.length > 1 ? rows.length : ''}
</div>
);
})}
</div>
{/* axis */}
<div className="relative h-4 mt-1">
{RSSI_TICKS.map(t => (
<span key={t} className="absolute -translate-x-1/2 text-[10px] text-gray-400 dark:text-slate-500"
style={{ left: `${pos(t)}%` }}>{t}</span>
))}
</div>
</>
)}
</div>
);
}
// ─── TX Retries histogram ───────────────────────────────────────────────────────
export function TxRetries({ deviceId }: { deviceId?: number }) {
const [band, setBand] = useState<RfBand>('5');
const { data: radios = [] } = useQuery({
queryKey: ['rf-tx-quality', deviceId],
queryFn: () => wirelessApi.getTxQuality(deviceId, '6h').then(r => r.data),
refetchInterval: 60_000,
});
const inBand = radios.filter((r: RfTxQualityRow) => r.band === band);
const counts = RETRY_BUCKETS.map(() => 0);
for (const r of inBand) counts[retryBucketIndex(r.tx_retry_pct)]++;
// UniFi orders worst → best (35%+ … 0%)
const ordered = RETRY_BUCKETS.map((b, i) => ({ ...b, count: counts[i] })).reverse();
return (
<div className="card p-5">
<div className="flex items-center gap-2 mb-4">
<Activity className="w-4 h-4 text-violet-500" />
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">AP Radio TX Retries</h2>
<div className="ml-auto flex gap-1">
{ALL_BANDS.map(b => (
<button key={b} onClick={() => setBand(b)}
className={clsx('px-2 py-0.5 rounded-md text-xs font-medium transition-colors',
band === b ? 'bg-blue-600 text-white' : 'text-gray-500 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-700')}>
{BAND_LABEL[b]}
</button>
))}
</div>
</div>
{radios.length === 0 ? (
<div className="py-6 text-center text-sm text-gray-400 dark:text-slate-500">
No TX-retry data. This is derived from per-client CCQ, which the legacy
<span className="font-mono"> wireless</span> driver reports; Wi-Fi 6/7 (<span className="font-mono">wifi</span>) radios don&apos;t expose it.
</div>
) : (
<>
<div className="flex gap-1">
{ordered.map(b => (
<div key={b.label} className="flex-1 text-center">
<div className="h-9 rounded-md flex items-center justify-center text-sm font-bold text-white"
style={{ backgroundColor: b.count > 0 ? retryColor((b.min + Math.min(b.max, 40)) / 2) : 'var(--color-border, #e5e7eb)' }}>
<span className={clsx(b.count === 0 && 'text-gray-400 dark:text-slate-600')}>{b.count || ''}</span>
</div>
<div className="text-[10px] text-gray-400 dark:text-slate-500 mt-1">{b.label}</div>
</div>
))}
</div>
<p className="text-[11px] text-gray-400 dark:text-slate-500 mt-3">
Radios bucketed by transmit-retry rate over the last 6h ({inBand.length} on {BAND_LABEL[band]}). Lower is better.
</p>
</>
)}
</div>
);
}
// ─── Connectivity Success funnel ────────────────────────────────────────────────
const STAGE_META: { key: keyof RfConnectivity['stages']; label: string }[] = [
{ key: 'association', label: 'Association' },
{ key: 'authentication', label: 'Authentication' },
{ key: 'dhcp', label: 'DHCP' },
];
export function ConnectivitySuccess({ deviceId }: { deviceId?: number }) {
const [range, setRange] = useState('24h');
const { data } = useQuery({
queryKey: ['rf-connectivity', deviceId, range],
queryFn: () => wirelessApi.getConnectivity(deviceId, range).then(r => r.data),
refetchInterval: 60_000,
});
const stages = data?.stages;
const totalEvents = stages
? STAGE_META.reduce((s, m) => s + stages[m.key].success + stages[m.key].failure, 0)
: 0;
return (
<div className="card p-5">
<div className="flex items-center gap-2 mb-4">
<ShieldCheck className="w-4 h-4 text-green-500" />
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">WiFi Connectivity Success</h2>
<div className="ml-auto flex gap-1">
{['1h', '24h', '7d'].map(r => (
<button key={r} onClick={() => setRange(r)}
className={clsx('px-2 py-0.5 rounded-md text-xs font-medium transition-colors',
range === r ? 'bg-blue-600 text-white' : 'text-gray-500 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-700')}>
{r}
</button>
))}
</div>
</div>
{!stages || totalEvents === 0 ? (
<div className="py-6 text-center text-sm text-gray-400 dark:text-slate-500">
No connectivity events in this window. This funnel is derived from device
logs — enable wireless &amp; DHCP logging on the APs to populate it.
</div>
) : (
<>
<div className="space-y-3">
{STAGE_META.map(({ key, label }) => {
const s = stages[key];
const pct = s.pct;
const color = pct == null ? '#94a3b8' : pct >= 98 ? '#22c55e' : pct >= 90 ? '#84cc16' : pct >= 75 ? '#f59e0b' : '#ef4444';
return (
<div key={key}>
<div className="flex items-center justify-between text-xs mb-1">
<span className="font-medium text-gray-600 dark:text-slate-300">{label}</span>
<span className="text-gray-400 dark:text-slate-500">
{pct == null ? '—' : `${pct}%`}
<span className="ml-2 text-[10px]">({s.success} ok / {s.failure} fail)</span>
</span>
</div>
<div className="h-2 rounded-full bg-gray-100 dark:bg-slate-700 overflow-hidden">
<div className="h-full rounded-full transition-all" style={{ width: `${pct ?? 0}%`, backgroundColor: color }} />
</div>
</div>
);
})}
</div>
<p className="text-[11px] text-gray-400 dark:text-slate-500 mt-3">
Approximate, derived from device logs. DNS success isn&apos;t observable on RouterOS and is omitted.
</p>
</>
)}
</div>
);
}
// ─── Combined section ─────────────────────────────────────────────────────────
export default function RfHealth({ deviceId }: { deviceId?: number }) {
return (
<div className="space-y-6">
<ChannelMap deviceId={deviceId} />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
<RssiDensity deviceId={deviceId} />
<TxRetries deviceId={deviceId} />
</div>
<ConnectivitySuccess deviceId={deviceId} />
</div>
);
}
+4
View File
@@ -5,6 +5,7 @@ import {
} from 'lucide-react';
import { wirelessApi } from '../services/api';
import type { WirelessAP } from '../types';
import RfHealth from '../components/wireless/RfHealth';
import clsx from 'clsx';
function timeAgo(ts: string | undefined): string {
@@ -97,6 +98,9 @@ export default function WirelessPage() {
))}
</div>
{/* RF Health — channel map, density, TX retries, connectivity */}
{aps.length > 0 && <RfHealth />}
{/* AP table */}
<div className="card overflow-hidden">
<div className="px-5 py-4 border-b border-gray-200 dark:border-slate-700">
+12
View File
@@ -225,6 +225,8 @@ export const devicesApi = {
reboot: (id: number) => api.post<{ message: string }>(`/devices/${id}/reboot`),
getPortMonitor: (id: number, name: string) =>
api.get<PortMonitorData>(`/devices/${id}/ports/${encodeURIComponent(name)}/monitor`),
getPortClients: (id: number, name: string, all = false) =>
api.get<import('../types').PortClientsResult>(`/devices/${id}/ports/${encodeURIComponent(name)}/clients`, { params: { all: all || undefined } }),
createBond: (id: number, data: {
name: string; mode: string; slaves: string[];
lacp_rate?: string; transmit_hash_policy?: string; mtu?: number; min_links?: number;
@@ -721,6 +723,16 @@ export const wirelessApi = {
runAPScan: (apId: number) => api.post(`/wireless/${apId}/ap-scan`),
getAPScanHistory: (apId: number, limit = 5) =>
api.get(`/wireless/${apId}/ap-scan-history`, { params: { limit } }),
// RF Health (fleet-wide, or scoped to one AP via deviceId)
getChannelUsage: (deviceId?: number) =>
api.get<import('../types').RfChannelRow[]>('/wireless/rf/channels', { params: { deviceId } }),
getClientSignals: (deviceId?: number) =>
api.get<import('../types').RfSignalRow[]>('/wireless/rf/signals', { params: { deviceId } }),
getTxQuality: (deviceId?: number, range = '6h') =>
api.get<import('../types').RfTxQualityRow[]>('/wireless/rf/tx-quality', { params: { deviceId, range } }),
getConnectivity: (deviceId?: number, range = '24h') =>
api.get<import('../types').RfConnectivity>('/wireless/rf/connectivity', { params: { deviceId, range } }),
};
// ─── Network Services ─────────────────────────────────────────────────────────
+68
View File
@@ -118,6 +118,28 @@ export interface SwitchPort extends Interface {
bridgeInfo?: BridgeVlanEntry | null;
}
export interface PortClient {
mac_address: string;
hostname: string | null;
custom_name: string | null;
vendor: string | null;
ip_address: string | null;
client_type: string;
vlan_id: number | null;
signal_strength: number | null;
first_seen: string | null;
last_seen: string | null;
}
export interface PortClientsResult {
classification: 'access' | 'uplink';
reason: string;
mac_count: number;
vlan_count: number;
neighbor: { neighbor_identity: string | null; neighbor_platform: string | null } | null;
clients: PortClient[];
}
export interface PortMonitorData {
// Link state
status?: string;
@@ -342,6 +364,52 @@ export interface WirelessMetricPoint {
noise_floor?: number;
}
// ─── RF Health ──────────────────────────────────────────────────────────────
export interface RfChannelRow {
device_id: number;
device_name: string;
name: string;
ssid?: string;
band?: string;
frequency: number;
channel_width?: string;
registered_clients: number;
}
export interface RfSignalRow {
mac_address: string;
signal_strength: number;
device_id: number;
device_name: string;
custom_name?: string | null;
hostname?: string | null;
}
export interface RfTxQualityRow {
device_id: string;
device_name?: string;
interface: string;
band: string;
tx_retry_pct: number;
}
export interface RfConnectivityStage {
success: number;
failure: number;
pct: number | null;
}
export interface RfConnectivity {
range: string;
log_derived: boolean;
stages: {
association: RfConnectivityStage;
authentication: RfConnectivityStage;
dhcp: RfConnectivityStage;
};
}
export interface ConfigSnapshotMeta {
id: number;
device_id: number;
+100
View File
@@ -0,0 +1,100 @@
// WiFi channel helpers for the RF Health panels.
//
// Maps RouterOS radio frequencies (MHz) to a band + channel number, supplies the
// canonical channel lists each band's channel map renders, and provides the
// bucketing used by the TX-retries histogram and the RSSI density scale.
export type RfBand = '2.4' | '5' | '6';
// Canonical channel scales shown on the channel map (mirrors what UniFi displays).
export const CHANNELS_24: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
export const CHANNELS_5: number[] = [
36, 40, 44, 48, 52, 56, 60, 64, 100, 104, 108, 112, 116, 120, 124, 128,
132, 136, 140, 144, 149, 153, 157, 161, 165,
];
// 6 GHz: channels 1..233 in steps of 4
export const CHANNELS_6: number[] = Array.from({ length: 59 }, (_, i) => 1 + i * 4);
export const BAND_CHANNELS: Record<RfBand, number[]> = {
'2.4': CHANNELS_24,
'5': CHANNELS_5,
'6': CHANNELS_6,
};
export const BAND_LABEL: Record<RfBand, string> = {
'2.4': '2.4 GHz',
'5': '5 GHz',
'6': '6 GHz',
};
/** Classify a frequency (MHz) into a coarse RF band, or null if unrecognized. */
export function bandForFreq(freq: number): RfBand | null {
if (freq >= 2400 && freq < 2500) return '2.4';
if (freq >= 4900 && freq < 5925) return '5';
if (freq >= 5925 && freq <= 7125) return '6';
return null;
}
/** Convert a frequency (MHz) to its channel number for the appropriate band. */
export function channelForFreq(freq: number): number | null {
const band = bandForFreq(freq);
if (band === '2.4') return freq === 2484 ? 14 : Math.round((freq - 2407) / 5);
if (band === '5') return Math.round((freq - 5000) / 5);
if (band === '6') return Math.round((freq - 5950) / 5);
return null;
}
/** Parse a RouterOS channel-width string to its width in MHz (best effort). */
export function widthMhz(width: string | undefined): number {
if (!width) return 20;
const m = width.match(/(\d+)/);
const n = m ? parseInt(m[1], 10) : 20;
// RouterOS strings like "20/40/80mhz" report the smallest first; take the max present.
const all = (width.match(/\d+/g) || []).map(Number).filter(v => v >= 20 && v <= 160);
return all.length ? Math.max(...all) : (n >= 20 ? n : 20);
}
// ─── RSSI density helpers ──────────────────────────────────────────────────
export interface RssiBand { label: string; min: number; max: number; color: string }
// Signal-quality zones across the −90…−30 dBm scale (weak → excellent).
export const RSSI_ZONES: RssiBand[] = [
{ label: 'Poor', min: -100, max: -80, color: '#ef4444' },
{ label: 'Fair', min: -80, max: -70, color: '#f59e0b' },
{ label: 'Good', min: -70, max: -60, color: '#84cc16' },
{ label: 'Excellent', min: -60, max: -20, color: '#22c55e' },
];
export function rssiColor(dbm: number): string {
for (const z of RSSI_ZONES) if (dbm >= z.min && dbm < z.max) return z.color;
return dbm >= -20 ? '#22c55e' : '#ef4444';
}
// ─── TX-retry histogram buckets (mirrors UniFi's 0%..35%+ scale) ─────────────
export interface RetryBucket { label: string; min: number; max: number }
export const RETRY_BUCKETS: RetryBucket[] = [
{ label: '0%', min: 0, max: 2.5 },
{ label: '5%', min: 2.5, max: 7.5 },
{ label: '10%', min: 7.5, max: 12.5 },
{ label: '15%', min: 12.5, max: 17.5 },
{ label: '20%', min: 17.5, max: 22.5 },
{ label: '25%', min: 22.5, max: 30 },
{ label: '35%+', min: 30, max: 101 },
];
export function retryBucketIndex(pct: number): number {
for (let i = 0; i < RETRY_BUCKETS.length; i++) {
if (pct >= RETRY_BUCKETS[i].min && pct < RETRY_BUCKETS[i].max) return i;
}
return RETRY_BUCKETS.length - 1;
}
/** Color for a retry %: green (low) → red (high). */
export function retryColor(pct: number): string {
if (pct < 5) return '#22c55e';
if (pct < 10) return '#84cc16';
if (pct < 20) return '#f59e0b';
return '#ef4444';
}
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = 'v0.16.0 Beta';
export const APP_VERSION = 'v0.16.1 Beta';