fix(wireless): CAPsMAN panel corrections from field testing (v0.22.1-beta)
All five points raised on #94 after the reporter tested v0.22.0 against a live CAPsMAN deployment. Two were regressions in that release. Operating channel instead of a capability list (mine) current_channel was stored from /interface/wifi/radio's current-channels, which is every channel the radio supports rather than the one it is using. Measured on a wAP ax: 535 characters on the 2.4 GHz radio and 3037 on the 5 GHz one — the "two kilobytes of text" reported. /interface/wifi/monitor gives the operating channel (5500/ax/Ceeeeeee/D), and carries state, peer counts and TX power too, all now shown on the radio chip. Client counts that were zero everywhere (mine) The panel read registered_clients from the CAP's own wireless_interfaces rows, which are empty when traffic is processed centrally by the controller. Monitor's registered-peers is not the fix either, and testing caught that before it shipped: clients register on the virtual AP carrying the SSID, not on the physical radio, so monitor on a physical radio reports zero on a busy access point. On the test AP, wifi1 and wifi2 both read 0 while wifi3-wifi6 held all ten clients. Counts now come from the registration table, with each entry attributed to its radio by following master-interface up to the interface owning a radio-mac. That field requires /interface/wifi/print detail — it is absent from the plain listing. Verified against real hardware: ten registrations, attributed 8 and 2 across the two radios, none dropped. "no network" on remote interfaces The Network column resolved from the AP's own bridge ports. A CAPsMAN-provisioned interface is not a local bridge port, so nothing matched and the UI asserted the interface had no network at all. It now falls back to the datapath — inline fields or a named/.id reference — and labels the source. Where the datapath names no bridge it says "via controller" rather than claiming no network. Status labels "Serving Clients" claimed something the underlying field does not say: running means the radio is up, not that clients are connected. Now "Running" and "Enabled, not running". Table title "SSIDs / Wireless Interfaces" conflated two different things; the table lists interfaces, virtual and physical. Now "Wireless Interfaces". Guest WiFi on CAPsMAN-provisioned radios Raised by the reporter as an aside, and it was a real hazard already shipping. setupGuestNetwork created virtual APs locally on whatever physical radios it found, with no awareness that a controller might own them — the SSID would be ignored or overwritten on the next provision while the UI reported success. Provisioned radios are now skipped with an explanation, and if every radio is provisioned the setup is refused, pointing at the controller instead. 37 unit tests on the CAPsMAN module. No regression on a fleet without a controller: local bridge-port resolution unchanged, roles still correct. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
14bab24c7e
commit
e15556b601
@@ -158,3 +158,112 @@ describe('lookupDeviceForMac', () => {
|
||||
expect(lookupDeviceForMac(null, new Map())).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── live radio state and datapath (issue #94 follow-up) ──────────────────────
|
||||
|
||||
import { parseRadioMonitor, resolveDatapath, clientsPerRadio } from '../capsman';
|
||||
|
||||
describe('parseRadioMonitor', () => {
|
||||
// Shape reported by the issue author from `/interface/wifi/monitor wifi1 once`.
|
||||
const row = {
|
||||
state: 'running',
|
||||
channel: '5500/ax/Ceee/D',
|
||||
'registered-peers': '10',
|
||||
'authorized-peers': '10',
|
||||
'tx-power': '24',
|
||||
};
|
||||
|
||||
it('reads the operating channel and peer counts', () => {
|
||||
const s = parseRadioMonitor(row);
|
||||
expect(s.state).toBe('running');
|
||||
expect(s.channel).toBe('5500/ax/Ceee/D');
|
||||
expect(s.registeredPeers).toBe(10);
|
||||
expect(s.authorizedPeers).toBe(10);
|
||||
expect(s.txPower).toBe(24);
|
||||
});
|
||||
|
||||
it('returns nulls rather than zeros when monitor gave nothing', () => {
|
||||
const s = parseRadioMonitor(undefined);
|
||||
expect(s.channel).toBeNull();
|
||||
expect(s.registeredPeers).toBeNull(); // absent must not read as "0 clients"
|
||||
});
|
||||
|
||||
it('treats an empty peer count as unknown, not zero', () => {
|
||||
expect(parseRadioMonitor({ 'registered-peers': '' }).registeredPeers).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDatapath', () => {
|
||||
const datapaths: Record<string, string>[] = [
|
||||
{ '.id': '*3', name: 'guest-dp', bridge: 'bridge1', 'vlan-id': '20' },
|
||||
{ '.id': '*4', name: 'mgmt-dp', bridge: 'bridge1' },
|
||||
];
|
||||
|
||||
it('prefers inline datapath fields on the interface', () => {
|
||||
expect(resolveDatapath({ 'datapath.bridge': 'bridge9', 'datapath.vlan-id': '99' }, datapaths))
|
||||
.toEqual({ bridge: 'bridge9', vlanId: '99' });
|
||||
});
|
||||
|
||||
it('follows a named datapath reference', () => {
|
||||
expect(resolveDatapath({ datapath: 'guest-dp' }, datapaths))
|
||||
.toEqual({ bridge: 'bridge1', vlanId: '20' });
|
||||
});
|
||||
|
||||
it('follows an .id datapath reference', () => {
|
||||
expect(resolveDatapath({ datapath: '*3' }, datapaths))
|
||||
.toEqual({ bridge: 'bridge1', vlanId: '20' });
|
||||
});
|
||||
|
||||
it('reports a datapath with no VLAN as untagged rather than inventing one', () => {
|
||||
expect(resolveDatapath({ datapath: 'mgmt-dp' }, datapaths))
|
||||
.toEqual({ bridge: 'bridge1', vlanId: null });
|
||||
});
|
||||
|
||||
it('returns nulls when the interface has no datapath at all', () => {
|
||||
expect(resolveDatapath({ name: 'wifi1' }, datapaths))
|
||||
.toEqual({ bridge: null, vlanId: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('clientsPerRadio', () => {
|
||||
// Shape taken from a live wAP ax: virtual APs carry the clients and reference
|
||||
// their physical radio through master-interface.
|
||||
const interfaces: Record<string, string>[] = [
|
||||
{ name: 'wifi1', 'radio-mac': 'D0:EA:11:0A:DE:78', master: 'true' },
|
||||
{ name: 'wifi2', 'radio-mac': 'D0:EA:11:0A:DE:79', master: 'true' },
|
||||
{ name: 'wifi3', 'master-interface': 'wifi1', master: 'false' },
|
||||
{ name: 'wifi5', 'master-interface': 'wifi1', master: 'false' },
|
||||
{ name: 'wifi6', 'master-interface': 'wifi2', master: 'false' },
|
||||
];
|
||||
|
||||
it('attributes clients on a virtual AP to its physical radio', () => {
|
||||
const counts = clientsPerRadio(interfaces, [
|
||||
{ interface: 'wifi3' }, { interface: 'wifi3' }, { interface: 'wifi3' },
|
||||
{ interface: 'wifi5' }, { interface: 'wifi5' },
|
||||
{ interface: 'wifi6' },
|
||||
]);
|
||||
expect(counts.get('D0:EA:11:0A:DE:78')).toBe(5); // wifi3 + wifi5
|
||||
expect(counts.get('D0:EA:11:0A:DE:79')).toBe(1); // wifi6
|
||||
});
|
||||
|
||||
it('counts clients registered directly on a physical radio', () => {
|
||||
const counts = clientsPerRadio(interfaces, [{ interface: 'wifi1' }]);
|
||||
expect(counts.get('D0:EA:11:0A:DE:78')).toBe(1);
|
||||
});
|
||||
|
||||
it('ignores registrations on an interface it cannot resolve', () => {
|
||||
expect(clientsPerRadio(interfaces, [{ interface: 'ghost' }]).size).toBe(0);
|
||||
});
|
||||
|
||||
it('does not hang on a cyclic master-interface chain', () => {
|
||||
const cyclic: Record<string, string>[] = [
|
||||
{ name: 'a', 'master-interface': 'b' },
|
||||
{ name: 'b', 'master-interface': 'a' },
|
||||
];
|
||||
expect(clientsPerRadio(cyclic, [{ interface: 'a' }]).size).toBe(0);
|
||||
});
|
||||
|
||||
it('returns an empty map when nothing is connected', () => {
|
||||
expect(clientsPerRadio(interfaces, []).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user