diff --git a/README.md b/README.md index 6448570..802d612 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

- Version + Version License Docker TypeScript diff --git a/backend/package.json b/backend/package.json index 2fbc3e3..95ef0e6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.23.4-beta", + "version": "0.23.5-beta", "description": "Mikrotik Network Management Platform - Backend API", "main": "dist/index.js", "scripts": { diff --git a/backend/src/routes/wireless.ts b/backend/src/routes/wireless.ts index e26c5d8..cccf721 100644 --- a/backend/src/routes/wireless.ts +++ b/backend/src/routes/wireless.ts @@ -3,6 +3,7 @@ import { query } from '../config/database'; import { requireAuth, requireWrite } from '../middleware/auth'; import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector'; import { lookupVendor } from '../utils/oui'; +import { analyzeSpectrum, summarize, BAND_RANGES } from '../utils/rfSpectrum'; const router = Router(); router.use(requireAuth); @@ -859,14 +860,35 @@ function deviceScope(req: Request): number | null { return Number.isFinite(id) ? id : null; } -// GET /api/wireless/rf/channels — active physical radios with band/frequency/width +/** + * GET /api/wireless/rf/channels — radios with their actual spectrum occupancy. + * + * Returns the interference analysis rather than raw channel numbers. Overlap is a + * property of megahertz, not of channel indices, and getting it wrong gives + * actively bad planning advice — so the model is computed and tested server-side + * (see utils/rfSpectrum.ts) instead of being re-derived in the browser. + * + * Radios also carry the CAPsMAN controller that provisions them, because channel + * planning is per physical location: two APs on channel 6 in different buildings + * are not a conflict (issue #97). + */ router.get('/rf/channels', async (req: Request, res: Response) => { const deviceId = deviceScope(req); - const rows = await query(` + const rows = await query<{ + device_id: number; device_name: string; name: string; ssid: string | null; + band: string | null; frequency: number; channel_width: string | null; + registered_clients: number | null; + controller_device_id: number | null; controller_name: string | null; + }>(` SELECT wi.device_id, d.name AS device_name, wi.name, wi.ssid, - wi.band, wi.frequency, wi.channel_width, wi.registered_clients + wi.band, wi.frequency, wi.channel_width, wi.registered_clients, + cr.controller_device_id, + cd.name AS controller_name FROM wireless_interfaces wi JOIN devices d ON d.id = wi.device_id + LEFT JOIN capsman_radios cr ON cr.matched_device_id = wi.device_id + AND cr.radio_mac = wi.radio_mac + LEFT JOIN devices cd ON cd.id = cr.controller_device_id WHERE (d.device_type = 'wireless_ap' OR d.wifi_role IN ('cap','controller','controller_cap')) AND wi.disabled = FALSE AND wi.frequency IS NOT NULL AND wi.frequency > 0 @@ -874,7 +896,48 @@ router.get('/rf/channels', async (req: Request, res: Response) => { ${deviceId ? 'AND wi.device_id = $1' : ''} ORDER BY wi.frequency ASC, d.name ASC `, deviceId ? [deviceId] : []); - res.json(rows); + + // Interference is only meaningful between radios that can hear each other, so + // the analysis runs per location: each controller separately, and everything + // unmanaged as one group. + const groups = new Map(); + for (const r of rows) { + const key = r.controller_device_id != null ? `c:${r.controller_device_id}` : 'standalone'; + groups.set(key, [...(groups.get(key) ?? []), r]); + } + + const analyzed: Record[] = []; + for (const group of groups.values()) { + const verdicts = analyzeSpectrum(group); + group.forEach((r, i) => { + const v = verdicts[i]; + analyzed.push({ + ...r, + low_mhz: v.spectrum.lowMhz, + high_mhz: v.spectrum.highMhz, + width_mhz: v.spectrum.widthMhz, + channel: v.spectrum.channel, + overlap: v.kind, + clashes: v.clashes.map((c) => ({ + kind: c.kind, + overlap_mhz: c.overlapMhz, + device_name: group[c.with]?.device_name ?? null, + interface_name: group[c.with]?.name ?? null, + channel: analyzeSpectrum([group[c.with]])[0].spectrum.channel, + })), + }); + }); + } + + const locations = [...groups.entries()].map(([key, group]) => ({ + key, + name: group[0]?.controller_name ?? 'Standalone access points', + controller_device_id: group[0]?.controller_device_id ?? null, + radios: group.length, + summary: summarize(analyzeSpectrum(group)), + })); + + res.json({ radios: analyzed, locations, bands: BAND_RANGES }); }); // GET /api/wireless/rf/signals — active wireless clients' RSSI (for density view) diff --git a/backend/src/utils/__tests__/rfSpectrum.test.ts b/backend/src/utils/__tests__/rfSpectrum.test.ts new file mode 100644 index 0000000..16905d0 --- /dev/null +++ b/backend/src/utils/__tests__/rfSpectrum.test.ts @@ -0,0 +1,192 @@ +import { + spectrumFor, classifyPair, overlapMhz, analyzeSpectrum, summarize, freeSpans, + widthMhz, channelForFreq, bandForFreq, +} from '../rfSpectrum'; + +const ch = (n: number) => 2407 + n * 5; // 2.4 GHz channel → centre MHz +const radio = (frequency: number, channel_width = '20mhz') => ({ frequency, channel_width }); + +describe('widthMhz', () => { + it('reads a pinned width', () => { + expect(widthMhz('20mhz')).toBe(20); + expect(widthMhz('80mhz')).toBe(80); + }); + + /** + * RouterOS reports a capability list on a radio allowed to bond opportunistically. + * The widest entry is the spectrum it may occupy, so that is what a planner must + * treat as unavailable. + */ + it('takes the widest entry of a capability list', () => { + expect(widthMhz('20/40/80mhz')).toBe(80); + expect(widthMhz('20/40mhz')).toBe(40); + }); + + it('defaults to 20 MHz when unknown', () => { + expect(widthMhz(undefined)).toBe(20); + expect(widthMhz(null)).toBe(20); + expect(widthMhz('auto')).toBe(20); + }); +}); + +describe('band and channel mapping', () => { + it('maps 2.4 GHz channels', () => { + expect(channelForFreq(2412)).toBe(1); + expect(channelForFreq(2437)).toBe(6); + expect(channelForFreq(2462)).toBe(11); + expect(bandForFreq(2437)).toBe('2.4'); + }); + + it('maps 5 and 6 GHz', () => { + expect(channelForFreq(5180)).toBe(36); + expect(bandForFreq(5180)).toBe('5'); + expect(bandForFreq(6115)).toBe('6'); + }); + + it('returns null outside known bands', () => { + expect(bandForFreq(900)).toBeNull(); + expect(channelForFreq(900)).toBeNull(); + }); +}); + +describe('spectrumFor', () => { + it('centres the occupied range on the frequency', () => { + const s = spectrumFor(2412, '20mhz'); + expect(s.lowMhz).toBe(2402); + expect(s.highMhz).toBe(2422); + expect(s.channel).toBe(1); + }); + + it('widens the range for a bonded channel', () => { + const s = spectrumFor(5180, '80mhz'); + expect(s.lowMhz).toBe(5140); + expect(s.highMhz).toBe(5220); + }); +}); + +describe('classifyPair — the heart of issue #98', () => { + /** + * The case the old channel-index model got wrong. Channels 1 and 3 are 10 MHz + * apart while each is 20 MHz wide, so they share half their spectrum — yet a + * model keyed on channel numbers reports them as two separate, fine channels. + */ + it('calls two adjacent 2.4 GHz channels a partial overlap', () => { + expect(classifyPair(spectrumFor(ch(1)), spectrumFor(ch(3)))).toBe('partial'); + expect(overlapMhz(spectrumFor(ch(1)), spectrumFor(ch(3)))).toBe(10); + }); + + it('calls the classic 1 / 6 / 11 plan clear', () => { + expect(classifyPair(spectrumFor(ch(1)), spectrumFor(ch(6)))).toBe('clear'); + expect(classifyPair(spectrumFor(ch(6)), spectrumFor(ch(11)))).toBe('clear'); + expect(classifyPair(spectrumFor(ch(1)), spectrumFor(ch(11)))).toBe('clear'); + }); + + /** + * Channel spacing is 5 MHz, so the arithmetic is unintuitive and worth pinning + * down: 1 and 4 are 15 MHz apart and share 5 MHz, while 4 and 8 are a full + * 20 MHz apart and merely abut. A plan of 1/4/8 is therefore *partly* broken — + * exactly the nuance a channel-number model cannot express. + */ + it('distinguishes overlap from mere adjacency', () => { + expect(classifyPair(spectrumFor(ch(1)), spectrumFor(ch(4)))).toBe('partial'); + expect(overlapMhz(spectrumFor(ch(1)), spectrumFor(ch(4)))).toBe(5); + + expect(classifyPair(spectrumFor(ch(4)), spectrumFor(ch(8)))).toBe('clear'); + expect(overlapMhz(spectrumFor(ch(4)), spectrumFor(ch(8)))).toBe(0); + }); + + /** + * Sharing a channel exactly is the *benign* case: the radios decode each other + * and take turns. Ranking it below partial overlap is the point of the issue. + */ + it('calls an identical channel co-channel, not partial', () => { + expect(classifyPair(spectrumFor(ch(6)), spectrumFor(ch(6)))).toBe('co-channel'); + }); + + it('treats a narrow radio nested inside a bonded one as partial', () => { + // 20 MHz on ch 40 sits entirely inside 80 MHz centred on ch 36. + expect(classifyPair(spectrumFor(5180, '80mhz'), spectrumFor(5200, '20mhz'))).toBe('partial'); + }); + + it('never clashes across bands', () => { + expect(classifyPair(spectrumFor(2437), spectrumFor(5180))).toBe('clear'); + expect(overlapMhz(spectrumFor(2437), spectrumFor(5180))).toBe(0); + }); + + it('treats touching-but-not-overlapping ranges as clear', () => { + // 2402–2422 and 2422–2442 share only a boundary. + expect(classifyPair(spectrumFor(2412), spectrumFor(2432))).toBe('clear'); + }); + + it('is symmetric', () => { + const a = spectrumFor(ch(1)), b = spectrumFor(ch(3)); + expect(classifyPair(a, b)).toBe(classifyPair(b, a)); + expect(overlapMhz(a, b)).toBe(overlapMhz(b, a)); + }); +}); + +describe('analyzeSpectrum', () => { + it('reports a lone radio as clear, not co-channel with itself', () => { + const [v] = analyzeSpectrum([radio(ch(6))]); + expect(v.kind).toBe('clear'); + expect(v.clashes).toHaveLength(0); + }); + + it('gives every radio in a 1 / 6 / 11 deployment a clean bill', () => { + const v = analyzeSpectrum([radio(ch(1)), radio(ch(6)), radio(ch(11))]); + expect(v.every((x) => x.kind === 'clear')).toBe(true); + expect(summarize(v)).toEqual({ radios: 3, clear: 3, coChannel: 0, partial: 0 }); + }); + + it('flags the misleading case: three radios spread across 1, 3 and 5', () => { + const v = analyzeSpectrum([radio(ch(1)), radio(ch(3)), radio(ch(5))]); + expect(v.every((x) => x.kind === 'partial')).toBe(true); + expect(summarize(v).partial).toBe(3); + }); + + it('ranks partial above co-channel when a radio has both', () => { + // ch6 twice (co-channel) plus a ch8 that partially overlaps them. + const v = analyzeSpectrum([radio(ch(6)), radio(ch(6)), radio(ch(8))]); + expect(v[0].kind).toBe('partial'); + expect(v[0].clashes[0].kind).toBe('partial'); // worst first + expect(v[0].clashes.map((c) => c.kind)).toContain('co-channel'); + }); + + it('separates radios by band', () => { + const v = analyzeSpectrum([radio(ch(6)), radio(5180, '80mhz'), radio(6115)]); + expect(v.every((x) => x.kind === 'clear')).toBe(true); + }); + + it('summarises a mixed deployment', () => { + const v = analyzeSpectrum([radio(ch(1)), radio(ch(1)), radio(ch(4)), radio(ch(11))]); + const s = summarize(v); + expect(s.radios).toBe(4); + expect(s.partial).toBe(3); // both ch1s and the ch4 overlap each other + expect(s.clear).toBe(1); // ch11 is untouched + }); +}); + +describe('freeSpans', () => { + it('finds the room left in 2.4 GHz after channels 1 and 11', () => { + const v = analyzeSpectrum([radio(ch(1)), radio(ch(11))]); + const spans = freeSpans(v, '2.4'); + // The gap between them must be able to hold channel 6 (2427–2447). + expect(spans.some((s) => s.startMhz <= 2427 && s.endMhz >= 2447)).toBe(true); + }); + + it('reports no usable gap when a band is saturated', () => { + const v = analyzeSpectrum([radio(2450, '160mhz')]); // swamps 2370–2530 + expect(freeSpans(v, '2.4')).toHaveLength(0); + }); + + it('ignores gaps narrower than a channel', () => { + // ch1 (2402-2422) and ch5 (2422-2442) leave no gap at all. + const v = analyzeSpectrum([radio(ch(1)), radio(ch(5))]); + const spans = freeSpans(v, '2.4'); + expect(spans.every((s) => s.endMhz - s.startMhz >= 20)).toBe(true); + }); + + it('returns the whole band when nothing is deployed', () => { + expect(freeSpans([], '2.4')).toEqual([{ startMhz: 2400, endMhz: 2500 }]); + }); +}); diff --git a/backend/src/utils/rfSpectrum.ts b/backend/src/utils/rfSpectrum.ts new file mode 100644 index 0000000..550e187 --- /dev/null +++ b/backend/src/utils/rfSpectrum.ts @@ -0,0 +1,205 @@ +/** + * Wi-Fi spectrum occupancy and interference analysis. + * + * The channel map used to model a radio as occupying a *channel number*, and + * coloured a channel amber when two radios sat on it. Both halves of that are + * wrong, and the result actively misleads spectrum planning + * (github.com/2GT-Media-Group-LLC/mikrotik-manager/issues/98). + * + * **Channels are not slots.** In 2.4 GHz the channel centres are 5 MHz apart while + * a channel is 20 MHz wide, so an AP on channel 1 occupies roughly 2401–2423 MHz + * and pollutes channels 1 through 5. Showing channels 2–5 as free is not a missing + * feature, it is bad advice: 1/6/11 is the only three-way non-overlapping set, and + * a model built on channel indices cannot express why. + * + * **Sharing a channel is not the problem.** Two radios on exactly the same channel + * hear each other and take turns — CSMA/CA does its job and the cost is airtime. + * Two radios *partially* overlapping cannot decode each other, so they transmit + * over one another and the cost is corruption. Partial overlap is the harmful case + * and must rank above co-channel, not below it. + * + * So occupancy is modelled in megahertz and overlap is classified by how two + * ranges relate, not by whether two numbers match. + */ + +export type RfBand = '2.4' | '5' | '6'; + +export interface BandRange { + band: RfBand; + label: string; + startMhz: number; + endMhz: number; +} + +/** Spectrum drawn for each band. Wide enough to contain every legal channel. */ +export const BAND_RANGES: BandRange[] = [ + { band: '2.4', label: '2.4 GHz', startMhz: 2400, endMhz: 2500 }, + { band: '5', label: '5 GHz', startMhz: 5150, endMhz: 5895 }, + { band: '6', label: '6 GHz', startMhz: 5925, endMhz: 7125 }, +]; + +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; +} + +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; +} + +/** + * Operating width in MHz from a RouterOS width string. + * + * RouterOS reports a *capability* list such as `20/40/80mhz` on a radio configured + * to bond opportunistically, and a single value such as `20mhz` when pinned. The + * widest entry is taken, because that is the spectrum the radio may occupy and + * therefore the spectrum a planner must treat as unavailable. + */ +export function widthMhz(width: string | null | undefined): number { + if (!width) return 20; + const all = (width.match(/\d+/g) || []).map(Number).filter((v) => v >= 20 && v <= 320); + return all.length ? Math.max(...all) : 20; +} + +export interface RadioSpectrum { + centerMhz: number; + widthMhz: number; + lowMhz: number; + highMhz: number; + band: RfBand | null; + channel: number | null; +} + +/** The slice of spectrum a radio actually occupies. */ +export function spectrumFor(freq: number, width?: string | null): RadioSpectrum { + const w = widthMhz(width); + return { + centerMhz: freq, + widthMhz: w, + lowMhz: freq - w / 2, + highMhz: freq + w / 2, + band: bandForFreq(freq), + channel: channelForFreq(freq), + }; +} + +export type OverlapKind = 'clear' | 'co-channel' | 'partial'; + +/** + * How two occupied ranges relate. + * + * Identical ranges are co-channel: the radios decode each other and share airtime. + * Anything else that intersects — including one range nested inside a wider one — + * is partial, because the two cannot reliably carrier-sense one another. + */ +export function classifyPair(a: RadioSpectrum, b: RadioSpectrum): OverlapKind { + if (a.band !== b.band) return 'clear'; + const overlap = Math.min(a.highMhz, b.highMhz) - Math.max(a.lowMhz, b.lowMhz); + if (overlap <= 0) return 'clear'; + if (a.lowMhz === b.lowMhz && a.highMhz === b.highMhz) return 'co-channel'; + return 'partial'; +} + +/** How much spectrum two radios share, in MHz. */ +export function overlapMhz(a: RadioSpectrum, b: RadioSpectrum): number { + if (a.band !== b.band) return 0; + return Math.max(0, Math.min(a.highMhz, b.highMhz) - Math.max(a.lowMhz, b.lowMhz)); +} + +const SEVERITY: Record = { clear: 0, 'co-channel': 1, partial: 2 }; + +export interface Clash { + /** Index into the input array of the other radio. */ + with: number; + kind: Exclude; + overlapMhz: number; +} + +export interface RadioVerdict { + spectrum: RadioSpectrum; + /** The worst relationship this radio has with any other. */ + kind: OverlapKind; + clashes: Clash[]; +} + +/** + * Classify every radio against every other. Order-independent and symmetric. + * + * Radios on different bands never clash, and a radio is never compared with + * itself — a single AP is `clear`, not co-channel with its own reflection. + */ +export function analyzeSpectrum( + radios: { frequency: number; channel_width?: string | null }[] +): RadioVerdict[] { + const spectra = radios.map((r) => spectrumFor(r.frequency, r.channel_width)); + + return spectra.map((s, i) => { + const clashes: Clash[] = []; + let worst: OverlapKind = 'clear'; + + for (let j = 0; j < spectra.length; j++) { + if (i === j) continue; + const kind = classifyPair(s, spectra[j]); + if (kind === 'clear') continue; + clashes.push({ with: j, kind, overlapMhz: overlapMhz(s, spectra[j]) }); + if (SEVERITY[kind] > SEVERITY[worst]) worst = kind; + } + + // Worst first, so the UI leads with the problem rather than a benign neighbour. + clashes.sort((a, b) => SEVERITY[b.kind] - SEVERITY[a.kind] || b.overlapMhz - a.overlapMhz); + return { spectrum: s, kind: worst, clashes }; + }); +} + +export interface SpectrumSummary { + radios: number; + clear: number; + coChannel: number; + partial: number; +} + +export function summarize(verdicts: RadioVerdict[]): SpectrumSummary { + return { + radios: verdicts.length, + clear: verdicts.filter((v) => v.kind === 'clear').length, + coChannel: verdicts.filter((v) => v.kind === 'co-channel').length, + partial: verdicts.filter((v) => v.kind === 'partial').length, + }; +} + +/** + * Contiguous stretches of a band that no radio occupies — where an additional AP + * could go without partially overlapping anything already deployed. + * + * Only spans at least `minWidth` wide are returned, since a 5 MHz gap between two + * 20 MHz radios is not somewhere you can put an access point. + */ +export function freeSpans( + verdicts: RadioVerdict[], + band: RfBand, + minWidth = 20 +): { startMhz: number; endMhz: number }[] { + const range = BAND_RANGES.find((b) => b.band === band); + if (!range) return []; + + const occupied = verdicts + .filter((v) => v.spectrum.band === band) + .map((v) => ({ lo: Math.max(v.spectrum.lowMhz, range.startMhz), hi: Math.min(v.spectrum.highMhz, range.endMhz) })) + .filter((o) => o.hi > o.lo) + .sort((a, b) => a.lo - b.lo); + + const spans: { startMhz: number; endMhz: number }[] = []; + let cursor = range.startMhz; + for (const o of occupied) { + if (o.lo - cursor >= minWidth) spans.push({ startMhz: cursor, endMhz: o.lo }); + cursor = Math.max(cursor, o.hi); + } + if (range.endMhz - cursor >= minWidth) spans.push({ startMhz: cursor, endMhz: range.endMhz }); + return spans; +} diff --git a/frontend/package.json b/frontend/package.json index 0d46736..0a344fa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.23.4-beta", + "version": "0.23.5-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/wireless/RfHealth.tsx b/frontend/src/components/wireless/RfHealth.tsx index 3c39177..60a9275 100644 --- a/frontend/src/components/wireless/RfHealth.tsx +++ b/frontend/src/components/wireless/RfHealth.tsx @@ -5,89 +5,162 @@ import clsx from 'clsx'; import { wirelessApi } from '../../services/api'; import type { RfChannelRow, RfSignalRow, RfTxQualityRow, RfConnectivity, + RfOverlapKind, RfBandRange, } from '../../types'; import { - BAND_CHANNELS, BAND_LABEL, RfBand, channelForFreq, bandForFreq, widthMhz, + BAND_LABEL, RfBand, RSSI_ZONES, rssiColor, RETRY_BUCKETS, retryBucketIndex, retryColor, } from '../../utils/wifiChannels'; const ALL_BANDS: RfBand[] = ['2.4', '5', '6']; // ─── Channel Map ────────────────────────────────────────────────────────────── +// +// Drawn as real spectrum rather than a row of channel cells. Channel centres in +// 2.4 GHz are 5 MHz apart while a channel is 20 MHz wide, so a cell-per-channel +// model shows overlapping channels as free — bad advice for anyone planning a +// deployment. Occupancy and interference are computed and tested server-side +// (backend/src/utils/rfSpectrum.ts); this only positions the result. -interface CellInfo { count: number; labels: string[] } +const OVERLAP_STYLE: Record = { + clear: { fill: 'var(--ok, #22c55e)', label: 'Clear' }, + 'co-channel': { fill: 'var(--warn, #f59e0b)', label: 'Shares a channel' }, + partial: { fill: 'var(--bad, #ef4444)', label: 'Overlapping carriers' }, +}; -function ChannelBandRow({ band, radios }: { band: RfBand; radios: RfChannelRow[] }) { - const channels = BAND_CHANNELS[band]; - const cells: CellInfo[] = channels.map(() => ({ count: 0, labels: [] })); +function SpectrumBandRow({ band, radios }: { band: RfBandRange; radios: RfChannelRow[] }) { + const span = band.endMhz - band.startMhz; + const pct = (mhz: number) => ((mhz - band.startMhz) / span) * 100; - 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` - ); + // Stack radios so overlapping blocks stay individually visible. + const lanes: RfChannelRow[][] = []; + for (const r of [...radios].sort((a, b) => a.low_mhz - b.low_mhz)) { + const lane = lanes.find((l) => l[l.length - 1].high_mhz <= r.low_mhz); + if (lane) lane.push(r); else lanes.push([r]); } - 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 + // Ticks every 20 MHz in 2.4 GHz, every 100 elsewhere — enough to orient without clutter. + const step = band.band === '2.4' ? 20 : 100; + const ticks: number[] = []; + for (let f = Math.ceil(band.startMhz / step) * step; f < band.endMhz; f += step) ticks.push(f); return (

-
{BAND_LABEL[band]}
-
- {cells.map((c, i) => ( -
- ))} +
+
{band.label}
+
+ {radios.length === 0 ? 'no radios' : `${radios.length} radio${radios.length !== 1 ? 's' : ''}`} +
-
- {channels.map(ch => ( -
{ch}
+ +
+ {lanes.map((lane, li) => lane.map((r) => ( +
` ${c.kind === 'partial' ? 'overlaps' : 'shares with'} ${c.device_name} ch ${c.channel} (${c.overlap_mhz} MHz)`), + ].join('\n')} + className="absolute rounded-sm border border-black/10 dark:border-white/10" + style={{ + left: `${pct(r.low_mhz)}%`, + width: `${Math.max(0.6, ((r.high_mhz - r.low_mhz) / span) * 100)}%`, + top: li * 16 + 2, + height: 12, + background: OVERLAP_STYLE[r.overlap].fill, + opacity: 0.85, + }} + /> + )))} +
+ +
+ {ticks.map((f) => ( + + {band.band === '2.4' ? (channelOf(f) ?? f) : f} + ))}
); } +/** 2.4 GHz tick labels read better as channel numbers than as megahertz. */ +function channelOf(freq: number): number | null { + const n = Math.round((freq - 2407) / 5); + return n >= 1 && n <= 14 ? n : null; +} + export function ChannelMap({ deviceId }: { deviceId?: number }) { - const { data: radios = [] } = useQuery({ + const { data } = 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); + const locations = data?.locations ?? []; + const [location, setLocation] = useState('all'); + + const radios = (data?.radios ?? []).filter( + r => location === 'all' || (r.controller_device_id != null ? `c:${r.controller_device_id}` : 'standalone') === location + ); + const bands = (data?.bands ?? []).filter(b => b.band !== '6' || radios.some(r => r.low_mhz >= 5925)); + + const partial = radios.filter(r => r.overlap === 'partial').length; + const coChannel = radios.filter(r => r.overlap === 'co-channel').length; return ( -
-
- -

Channel Usage

- - in use - overlap - +
+
+
+ +

Channel Usage

+
+ {/* Channel planning is per physical location — two APs on channel 6 in + different buildings are not a conflict (issue #97). */} + {locations.length > 1 && ( + + )}
+ {radios.length === 0 ? ( -
- No active radios reporting a channel yet. -
+
No active radios reporting a channel.
) : ( -
- {bands.map(b => )} -
+ <> +
+ {bands.map(b => ( + r.low_mhz < b.endMhz && r.high_mhz > b.startMhz)} /> + ))} +
+ +
+ {(['clear', 'co-channel', 'partial'] as RfOverlapKind[]).map(k => ( + + + {OVERLAP_STYLE[k].label} + + ))} +
+ +

+ {partial > 0 + ? `${partial} radio${partial !== 1 ? 's' : ''} overlap the edge of a neighbour's channel — the carriers collide rather than taking turns, which costs more than sharing a channel outright.` + : coChannel > 0 + ? `${coChannel} radio${coChannel !== 1 ? 's' : ''} share a channel exactly. They hear each other and take turns, so this is airtime cost rather than interference.` + : 'No overlapping carriers. Radios either sit clear of each other or share a channel cleanly.'} +

+ )}
); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index a0b8fb3..192f951 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1034,7 +1034,7 @@ export const wirelessApi = { // RF Health (fleet-wide, or scoped to one AP via deviceId) getChannelUsage: (deviceId?: number) => - api.get('/wireless/rf/channels', { params: { deviceId } }), + api.get('/wireless/rf/channels', { params: { deviceId } }), getClientSignals: (deviceId?: number) => api.get('/wireless/rf/signals', { params: { deviceId } }), getTxQuality: (deviceId?: number, range = '6h') => diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 075ef14..2f11888 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -400,6 +400,17 @@ export interface RogueApReport { // ─── RF Health ────────────────────────────────────────────────────────────── +/** How one radio's spectrum relates to every other radio at the same location. */ +export type RfOverlapKind = 'clear' | 'co-channel' | 'partial'; + +export interface RfClash { + kind: 'co-channel' | 'partial'; + overlap_mhz: number; + device_name: string | null; + interface_name: string | null; + channel: number | null; +} + export interface RfChannelRow { device_id: number; device_name: string; @@ -409,6 +420,36 @@ export interface RfChannelRow { frequency: number; channel_width?: string; registered_clients: number; + /** Spectrum actually occupied, computed and tested server-side. */ + low_mhz: number; + high_mhz: number; + width_mhz: number; + channel: number | null; + overlap: RfOverlapKind; + clashes: RfClash[]; + controller_device_id: number | null; + controller_name: string | null; +} + +export interface RfLocation { + key: string; + name: string; + controller_device_id: number | null; + radios: number; + summary: { radios: number; clear: number; coChannel: number; partial: number }; +} + +export interface RfBandRange { + band: '2.4' | '5' | '6'; + label: string; + startMhz: number; + endMhz: number; +} + +export interface RfChannelUsage { + radios: RfChannelRow[]; + locations: RfLocation[]; + bands: RfBandRange[]; } export interface RfSignalRow { diff --git a/frontend/src/version.ts b/frontend/src/version.ts index f597070..0546d8e 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.23.4 Beta'; +export const APP_VERSION = 'v0.23.5 Beta';