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:
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user