diff --git a/README.md b/README.md
index 6448570..802d612 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
-
+
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 && (
+
setLocation(e.target.value)}
+ className="input text-xs py-1 max-w-[220px]">
+ All locations
+ {locations.map(l => (
+ {l.name} ({l.radios})
+ ))}
+
+ )}
+
{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';