fix(wireless): model channel usage as spectrum, not channel numbers (v0.23.5-beta)

Closes #98, #97.

The Channel Usage panel treated a radio as occupying a channel *number* and
coloured a channel amber when two radios shared it. Both halves are wrong, and the
result is not merely incomplete — it gives actively bad planning advice.

Channels are not slots. In 2.4 GHz the centres are 5 MHz apart while a channel is
20 MHz wide, so an AP on channel 1 occupies about 2402-2422 MHz and pollutes
channels 1 through 5. Showing 2 through 5 as free is why 1/6/11 exists as the only
three-way non-overlapping plan, and a model built on channel indices cannot express
that.

Sharing a channel is also 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, transmit over one another,
and the cost is corruption. Partial overlap has to rank above co-channel, and it
was not represented at all.

Occupancy is now modelled in megahertz and overlap classified by how two ranges
relate. On the local fleet, a 40 MHz radio on channel 1 is correctly shown bleeding
5 MHz into two channel-6 radios — which are themselves correctly identified as
co-channel with each other rather than as the worst problem in the band.

The model lives in the backend (utils/rfSpectrum.ts) with 26 tests rather than in
the browser, because it is correctness-critical and the frontend has no test runner.
Tests pin the arithmetic that is easy to get wrong: 1/6/11 is clear, 1/3/5 is three
partial overlaps, channels 4 and 8 abut exactly rather than overlapping, a 20 MHz
radio nested inside an 80 MHz one is partial rather than co-channel, and a lone
radio is clear rather than co-channel with itself.

The panel now draws real spectrum to scale, stacking overlapping radios so each
stays visible, with the clash list on hover naming the other radio and how many
megahertz they share.

Width is taken as the widest entry of a RouterOS capability list such as
20/40/80mhz, since that is the spectrum the radio may occupy and therefore what a
planner must treat as unavailable.

Per-location scoping (#97): analysis runs per CAPsMAN controller, with a selector,
because two APs on channel 6 in different buildings are not a conflict. Radios with
no controller are analysed together as standalone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-08-22 14:06:57 -07:00
co-authored by Claude Opus 5
parent e10f7ba568
commit 4cc8c84ea6
10 changed files with 632 additions and 58 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-0.23.4_Beta-blue" alt="Version" />
<img src="https://img.shields.io/badge/version-0.23.5_Beta-blue" alt="Version" />
<img src="https://img.shields.io/badge/license-AGPLv3-blue" alt="License" />
<img src="https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white" alt="Docker" />
<img src="https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white" alt="TypeScript" />
+1 -1
View File
@@ -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": {
+67 -4
View File
@@ -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<string, typeof rows>();
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<string, unknown>[] = [];
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)
@@ -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 }]);
});
});
+205
View File
@@ -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<OverlapKind, number> = { clear: 0, 'co-channel': 1, partial: 2 };
export interface Clash {
/** Index into the input array of the other radio. */
with: number;
kind: Exclude<OverlapKind, 'clear'>;
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;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-frontend",
"version": "0.23.4-beta",
"version": "0.23.5-beta",
"private": true,
"type": "module",
"scripts": {
+122 -49
View File
@@ -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<RfOverlapKind, { fill: string; label: string }> = {
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 (
<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 className="flex items-baseline justify-between mb-1">
<div className="text-xs font-medium text-gray-500 dark:text-slate-400">{band.label}</div>
<div className="text-[10px] text-gray-400">
{radios.length === 0 ? 'no radios' : `${radios.length} radio${radios.length !== 1 ? 's' : ''}`}
</div>
</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 className="relative rounded" style={{ background: 'var(--surface-3, #e5e7eb)', minHeight: lanes.length ? lanes.length * 16 + 4 : 12 }}>
{lanes.map((lane, li) => lane.map((r) => (
<div
key={`${r.device_id}:${r.name}`}
title={[
`${r.device_name} · ${r.name}${r.ssid ? ` (${r.ssid})` : ''}`,
`ch ${r.channel ?? '?'} — ${r.low_mhz}–${r.high_mhz} MHz, ${r.width_mhz} MHz wide`,
`${r.registered_clients ?? 0} clients`,
OVERLAP_STYLE[r.overlap].label,
...r.clashes.map((c) => ` ${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,
}}
/>
)))}
</div>
<div className="relative h-3 mt-0.5">
{ticks.map((f) => (
<span key={f} className="absolute text-[9px] leading-none text-gray-400 dark:text-slate-500"
style={{ left: `${pct(f)}%`, transform: 'translateX(-50%)' }}>
{band.band === '2.4' ? (channelOf(f) ?? f) : f}
</span>
))}
</div>
</div>
);
}
/** 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<string>('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 (
<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 className="card p-4">
<div className="flex items-center justify-between gap-3 flex-wrap mb-3">
<div className="flex items-center gap-2">
<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>
</div>
{/* Channel planning is per physical location — two APs on channel 6 in
different buildings are not a conflict (issue #97). */}
{locations.length > 1 && (
<select value={location} onChange={(e) => setLocation(e.target.value)}
className="input text-xs py-1 max-w-[220px]">
<option value="all">All locations</option>
{locations.map(l => (
<option key={l.key} value={l.key}>{l.name} ({l.radios})</option>
))}
</select>
)}
</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="py-6 text-center text-xs text-gray-400">No active radios reporting a channel.</div>
) : (
<div className="space-y-4">
{bands.map(b => <ChannelBandRow key={b} band={b} radios={byBand(b)} />)}
</div>
<>
<div className="space-y-3">
{bands.map(b => (
<SpectrumBandRow key={b.band} band={b}
radios={radios.filter(r => r.low_mhz < b.endMhz && r.high_mhz > b.startMhz)} />
))}
</div>
<div className="mt-3 flex items-center gap-4 flex-wrap text-[11px] text-gray-500 dark:text-slate-400">
{(['clear', 'co-channel', 'partial'] as RfOverlapKind[]).map(k => (
<span key={k} className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-sm" style={{ background: OVERLAP_STYLE[k].fill }} />
{OVERLAP_STYLE[k].label}
</span>
))}
</div>
<p className="mt-2 text-[11px] text-gray-500 dark:text-slate-400">
{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.'}
</p>
</>
)}
</div>
);
+1 -1
View File
@@ -1034,7 +1034,7 @@ export const wirelessApi = {
// RF Health (fleet-wide, or scoped to one AP via deviceId)
getChannelUsage: (deviceId?: number) =>
api.get<import('../types').RfChannelRow[]>('/wireless/rf/channels', { params: { deviceId } }),
api.get<import('../types').RfChannelUsage>('/wireless/rf/channels', { params: { deviceId } }),
getClientSignals: (deviceId?: number) =>
api.get<import('../types').RfSignalRow[]>('/wireless/rf/signals', { params: { deviceId } }),
getTxQuality: (deviceId?: number, range = '6h') =>
+41
View File
@@ -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 {
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = 'v0.23.4 Beta';
export const APP_VERSION = 'v0.23.5 Beta';