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:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mikrotik-manager-frontend",
|
||||
"version": "0.16.0-beta",
|
||||
"version": "0.16.1-beta",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mikrotik-manager-frontend",
|
||||
"version": "0.16.0-beta",
|
||||
"version": "0.16.1-beta",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mikrotik-manager-frontend",
|
||||
"version": "0.16.0-beta",
|
||||
"version": "0.16.1-beta",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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'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 & 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 @@
|
||||
export const APP_VERSION = 'v0.16.0 Beta';
|
||||
export const APP_VERSION = 'v0.16.1 Beta';
|
||||
|
||||
Reference in New Issue
Block a user