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>
);
}