fix(wireless): attribute CAP radios to the access point, not the controller (v0.23.1-beta)
Third round of #94 field testing. The placement bug survived v0.23.0 because the previous fix addressed the wrong mechanism. Cause A CAPsMAN controller mirrors every CAP interface locally, so a CAP's radio MAC legitimately appears on two devices: the CAP that owns the hardware, and the controller that mirrors it. The MAC index was single-valued and first-wins over an unordered UNION, so each radio landed on whichever row the database happened to return first. On the reporter's fleet six radios attached to a router with no radios at all, while their siblings attached correctly — the same access point appearing in two places, and liable to move between polls. v0.23.0 excluded the controller from *prefix* matches only. These are exact matches, so the exclusion never applied. Consecutive MACs on one physical AP resolving to different devices is what gave it away. Fix The index is multi-valued: a MAC maps to every device carrying it. A radio the controller reports as local:false lives on a CAP by definition, so the controller is rejected as a candidate at exact strength as well as prefix — an exact match against it only ever means the mirror. Attribution now runs through matchRadiosToDevices, which was previously tested but unused while the collector kept its own copy of the logic. With no radios left attributed to it, a controller that has none of its own stops appearing in the access point list, which was the reporter's follow-up point. SSID on provisioned interfaces A CAP holds no configuration, so its interface rows carry a generic name and a null SSID. The controller knows it, on its mirror of the same interface. Radios now record the provisioned SSID from there, and the table is driven by the controller's radio list rather than the CAPs' own rows — the controller is the only side that knows the SSID, channel and client count together. Band and frequency columns removed Both were empty while the channel column beside them read 5680/ax/eCee/D. The channel string already carries the band and the extension layout; parsing it properly is its own task, and half-parsing it produced empty columns next to populated ones. Requested by the reporter. 75 tests across the CAPsMAN and topology modules, including fixtures built from the reporter's MAC set: both radios of one AP resolve to that AP, order-independently, with a genuinely local radio still landing on the controller and an unknown CAP left unattributed rather than absorbed. Reproduced the mirror scenario against the local fleet before and after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
82efcaa371
commit
0258ee47dc
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"editor.fontSize": 22,
|
||||||
|
"workbench.colorTheme": "Visual Studio Light",
|
||||||
|
"window.autoDetectColorScheme": true
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A self-hosted, full-stack network management platform for MikroTik devices. Monitor, configure, and manage your entire MikroTik infrastructure — routers, switches, and wireless access points — from a single web interface.
|
A self-hosted, full-stack network management platform for MikroTik devices. Monitor, configure, and manage your entire MikroTik infrastructure — routers, switches, and wireless access points — from a single web interface.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mikrotik-manager-backend",
|
"name": "mikrotik-manager-backend",
|
||||||
"version": "0.23.0-beta",
|
"version": "0.23.1-beta",
|
||||||
"description": "Mikrotik Network Management Platform - Backend API",
|
"description": "Mikrotik Network Management Platform - Backend API",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -571,6 +571,10 @@ ALTER TABLE capsman_radios ADD COLUMN IF NOT EXISTS tx_power INTEGER;
|
|||||||
-- collected so no one is left looking at two kilobytes of text.
|
-- collected so no one is left looking at two kilobytes of text.
|
||||||
UPDATE capsman_radios SET current_channel = NULL WHERE length(current_channel) > 64;
|
UPDATE capsman_radios SET current_channel = NULL WHERE length(current_channel) > 64;
|
||||||
|
|
||||||
|
-- A CAP holds no local configuration, so its interface rows carry no SSID. The
|
||||||
|
-- controller does know it, on its own mirror of the CAP interface (#94).
|
||||||
|
ALTER TABLE capsman_radios ADD COLUMN IF NOT EXISTS ssid VARCHAR(128);
|
||||||
|
|
||||||
-- ntfy.sh notification channel (github issue #93). The channel type is a CHECK
|
-- ntfy.sh notification channel (github issue #93). The channel type is a CHECK
|
||||||
-- constraint rather than a lookup table, so widening it means replacing it.
|
-- constraint rather than a lookup table, so widening it means replacing it.
|
||||||
ALTER TABLE alert_channels DROP CONSTRAINT IF EXISTS alert_channels_type_check;
|
ALTER TABLE alert_channels DROP CONSTRAINT IF EXISTS alert_channels_type_check;
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ router.get('/capsman', async (_req: Request, res: Response) => {
|
|||||||
query(`
|
query(`
|
||||||
SELECT r.controller_device_id, r.radio_mac, r.interface_name, r.local, r.hw_type,
|
SELECT r.controller_device_id, r.radio_mac, r.interface_name, r.local, r.hw_type,
|
||||||
r.current_channel, r.remote_cap_name, r.matched_device_id,
|
r.current_channel, r.remote_cap_name, r.matched_device_id,
|
||||||
r.state, r.registered_peers, r.authorized_peers, r.tx_power,
|
r.state, r.registered_peers, r.authorized_peers, r.tx_power, r.ssid,
|
||||||
d.name AS matched_device_name, d.status AS matched_device_status
|
d.name AS matched_device_name, d.status AS matched_device_status
|
||||||
FROM capsman_radios r
|
FROM capsman_radios r
|
||||||
LEFT JOIN devices d ON d.id = r.matched_device_id
|
LEFT JOIN devices d ON d.id = r.matched_device_id
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import { buildServerArpMap } from '../../utils/serverArp';
|
|||||||
import { aggregateBridgeVlans, portVlanMembership, expandVlanIds } from '../../utils/vlan';
|
import { aggregateBridgeVlans, portVlanMembership, expandVlanIds } from '../../utils/vlan';
|
||||||
import {
|
import {
|
||||||
classifyWifiRole, isCapsmanManaged, parseCapsmanStatus, parseCapStatus,
|
classifyWifiRole, isCapsmanManaged, parseCapsmanStatus, parseCapStatus,
|
||||||
normalizeRadios, lookupDeviceForMac, macIndexKeys, parseRadioMonitor, resolveDatapath,
|
normalizeRadios, macIndexKeys, parseRadioMonitor, resolveDatapath,
|
||||||
clientsPerRadio,
|
clientsPerRadio, buildMacIndex, matchRadiosToDevices,
|
||||||
type WifiRole,
|
type WifiRole,
|
||||||
} from './capsman';
|
} from './capsman';
|
||||||
import { BackupService } from '../BackupService';
|
import { BackupService } from '../BackupService';
|
||||||
@@ -1472,12 +1472,7 @@ export class DeviceCollector {
|
|||||||
UNION
|
UNION
|
||||||
SELECT device_id, mac_address FROM wireless_interfaces WHERE mac_address IS NOT NULL`
|
SELECT device_id, mac_address FROM wireless_interfaces WHERE mac_address IS NOT NULL`
|
||||||
).catch(() => []);
|
).catch(() => []);
|
||||||
const macIndex = new Map<string, number>();
|
const macIndex = buildMacIndex(macRows);
|
||||||
for (const r of macRows) {
|
|
||||||
for (const key of macIndexKeys(r.mac_address)) {
|
|
||||||
if (!macIndex.has(key)) macIndex.set(key, r.device_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (radioRows !== null) {
|
if (radioRows !== null) {
|
||||||
const radios = normalizeRadios(radioRows);
|
const radios = normalizeRadios(radioRows);
|
||||||
@@ -1497,30 +1492,39 @@ export class DeviceCollector {
|
|||||||
live.set(iface, parseRadioMonitor(mon[0]));
|
live.set(iface, parseRadioMonitor(mon[0]));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// The controller mirrors each CAP interface locally, which is also where the
|
||||||
|
// provisioned SSID is visible — the CAP's own row has none.
|
||||||
|
const ssidByRadioMac = new Map<string, string>();
|
||||||
|
for (const iface of ifaceRows) {
|
||||||
|
const mac = (iface['radio-mac'] || '').toUpperCase();
|
||||||
|
const ssid = iface['configuration.ssid'] || iface['ssid'];
|
||||||
|
if (mac && ssid && !ssidByRadioMac.has(mac)) ssidByRadioMac.set(mac, ssid);
|
||||||
|
}
|
||||||
|
|
||||||
|
const attributed = new Map(
|
||||||
|
matchRadiosToDevices(radios, macIndex, this.device.id)
|
||||||
|
.map((m) => [m.radio.radioMac ?? '', m.deviceId])
|
||||||
|
);
|
||||||
|
|
||||||
const seen: string[] = [];
|
const seen: string[] = [];
|
||||||
for (const radio of radios) {
|
for (const radio of radios) {
|
||||||
if (!radio.radioMac) continue;
|
if (!radio.radioMac) continue;
|
||||||
seen.push(radio.radioMac);
|
seen.push(radio.radioMac);
|
||||||
const capInfo = parseCapStatus(radio.raw);
|
const capInfo = parseCapStatus(radio.raw);
|
||||||
const st = radio.interfaceName ? live.get(radio.interfaceName) : undefined;
|
const st = radio.interfaceName ? live.get(radio.interfaceName) : undefined;
|
||||||
// A remote radio belongs to a CAP, never to the controller — so a
|
const matched = attributed.get(radio.radioMac) ?? null;
|
||||||
// near-miss prefix match against the controller's own MAC block must not
|
|
||||||
// pull it back onto the controller.
|
|
||||||
const matched = radio.local
|
|
||||||
? this.device.id
|
|
||||||
: lookupDeviceForMac(radio.radioMac, macIndex, this.device.id);
|
|
||||||
|
|
||||||
await query(
|
await query(
|
||||||
`INSERT INTO capsman_radios
|
`INSERT INTO capsman_radios
|
||||||
(controller_device_id, radio_mac, interface_name, local, hw_type,
|
(controller_device_id, radio_mac, interface_name, local, hw_type,
|
||||||
current_channel, remote_cap_name, matched_device_id, config_json,
|
current_channel, remote_cap_name, matched_device_id, config_json,
|
||||||
state, registered_peers, authorized_peers, tx_power, updated_at)
|
state, registered_peers, authorized_peers, tx_power, ssid, updated_at)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,NOW())
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,NOW())
|
||||||
ON CONFLICT (controller_device_id, radio_mac) DO UPDATE SET
|
ON CONFLICT (controller_device_id, radio_mac) DO UPDATE SET
|
||||||
interface_name=$3, local=$4, hw_type=$5, current_channel=$6,
|
interface_name=$3, local=$4, hw_type=$5, current_channel=$6,
|
||||||
remote_cap_name=$7, matched_device_id=$8, config_json=$9,
|
remote_cap_name=$7, matched_device_id=$8, config_json=$9,
|
||||||
state=$10, registered_peers=$11, authorized_peers=$12, tx_power=$13,
|
state=$10, registered_peers=$11, authorized_peers=$12, tx_power=$13,
|
||||||
updated_at=NOW()`,
|
ssid=$14, updated_at=NOW()`,
|
||||||
[
|
[
|
||||||
this.device.id, radio.radioMac, radio.interfaceName, radio.local,
|
this.device.id, radio.radioMac, radio.interfaceName, radio.local,
|
||||||
radio.hwType, st?.channel ?? null,
|
radio.hwType, st?.channel ?? null,
|
||||||
@@ -1529,6 +1533,7 @@ export class DeviceCollector {
|
|||||||
st?.state ?? null,
|
st?.state ?? null,
|
||||||
clientCounts.get(radio.radioMac) ?? st?.registeredPeers ?? null,
|
clientCounts.get(radio.radioMac) ?? st?.registeredPeers ?? null,
|
||||||
st?.authorizedPeers ?? null, st?.txPower ?? null,
|
st?.authorizedPeers ?? null, st?.txPower ?? null,
|
||||||
|
ssidByRadioMac.get(radio.radioMac) ?? null,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
classifyWifiRole, parseCapsmanStatus, parseCapStatus, isCapsmanManaged,
|
classifyWifiRole, parseCapsmanStatus, parseCapStatus, isCapsmanManaged,
|
||||||
normalizeRadios, matchRadiosToDevices, lookupDeviceForMac, macIndexKeys,
|
normalizeRadios, matchRadiosToDevices, lookupDeviceForMac, macIndexKeys, buildMacIndex,
|
||||||
} from '../capsman';
|
} from '../capsman';
|
||||||
|
|
||||||
describe('classifyWifiRole', () => {
|
describe('classifyWifiRole', () => {
|
||||||
@@ -116,7 +116,7 @@ describe('normalizeRadios', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('matchRadiosToDevices', () => {
|
describe('matchRadiosToDevices', () => {
|
||||||
const index = new Map<string, number>([['AA:BB:CC:00:00:01', 7]]);
|
const index = buildMacIndex([{ device_id: 7, mac_address: 'AA:BB:CC:00:00:01' }]);
|
||||||
|
|
||||||
it('attributes a local radio to the controller itself', () => {
|
it('attributes a local radio to the controller itself', () => {
|
||||||
const radios = normalizeRadios([{ 'radio-mac': 'FF:FF:FF:00:00:09', local: 'true' }]);
|
const radios = normalizeRadios([{ 'radio-mac': 'FF:FF:FF:00:00:09', local: 'true' }]);
|
||||||
@@ -136,7 +136,7 @@ describe('matchRadiosToDevices', () => {
|
|||||||
|
|
||||||
describe('lookupDeviceForMac', () => {
|
describe('lookupDeviceForMac', () => {
|
||||||
it('matches exactly when the MAC is known', () => {
|
it('matches exactly when the MAC is known', () => {
|
||||||
const idx = new Map([['AA:BB:CC:00:00:01', 5]]);
|
const idx = buildMacIndex([{ device_id: 5, mac_address: 'AA:BB:CC:00:00:01' }]);
|
||||||
expect(lookupDeviceForMac('aa:bb:cc:00:00:01', idx)).toBe(5);
|
expect(lookupDeviceForMac('aa:bb:cc:00:00:01', idx)).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -145,17 +145,17 @@ describe('lookupDeviceForMac', () => {
|
|||||||
* octet, so an exact-only lookup would leave real CAPs unattributed.
|
* octet, so an exact-only lookup would leave real CAPs unattributed.
|
||||||
*/
|
*/
|
||||||
it('falls back to the five-octet prefix when the last octet differs', () => {
|
it('falls back to the five-octet prefix when the last octet differs', () => {
|
||||||
const idx = new Map(macIndexKeys('AA:BB:CC:00:00:01').map((k) => [k, 5] as [string, number]));
|
const idx = buildMacIndex([{ device_id: 5, mac_address: 'AA:BB:CC:00:00:01' }]);
|
||||||
expect(lookupDeviceForMac('AA:BB:CC:00:00:07', idx)).toBe(5);
|
expect(lookupDeviceForMac('AA:BB:CC:00:00:07', idx)).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not match a different device sharing only an OUI', () => {
|
it('does not match a different device sharing only an OUI', () => {
|
||||||
const idx = new Map(macIndexKeys('AA:BB:CC:00:00:01').map((k) => [k, 5] as [string, number]));
|
const idx = buildMacIndex([{ device_id: 5, mac_address: 'AA:BB:CC:00:00:01' }]);
|
||||||
expect(lookupDeviceForMac('AA:BB:CC:99:99:99', idx)).toBeNull();
|
expect(lookupDeviceForMac('AA:BB:CC:99:99:99', idx)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null for a missing MAC', () => {
|
it('returns null for a missing MAC', () => {
|
||||||
expect(lookupDeviceForMac(null, new Map())).toBeNull();
|
expect(lookupDeviceForMac(null, new Map<string, number[]>())).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -268,29 +268,83 @@ describe('clientsPerRadio', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('lookupDeviceForMac — prefix must not steal a CAP for the controller (#94)', () => {
|
describe('lookupDeviceForMac — a remote radio never belongs to the controller (#94)', () => {
|
||||||
it('refuses a prefix match against the excluded device', () => {
|
it('refuses a prefix match against the excluded device', () => {
|
||||||
// Controller (id 3) owns AA:BB:CC:00:00:01; a CAP radio one octet away must not
|
const idx = buildMacIndex([{ device_id: 3, mac_address: 'AA:BB:CC:00:00:01' }]);
|
||||||
// be attributed to it.
|
|
||||||
const idx = new Map(macIndexKeys('AA:BB:CC:00:00:01').map((k) => [k, 3] as [string, number]));
|
|
||||||
expect(lookupDeviceForMac('AA:BB:CC:00:00:09', idx, 3)).toBeNull();
|
expect(lookupDeviceForMac('AA:BB:CC:00:00:09', idx, 3)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('still honours an exact match even against the excluded device', () => {
|
/**
|
||||||
const idx = new Map(macIndexKeys('AA:BB:CC:00:00:01').map((k) => [k, 3] as [string, number]));
|
* The case that survived the first fix. A controller mirrors each CAP interface
|
||||||
expect(lookupDeviceForMac('AA:BB:CC:00:00:01', idx, 3)).toBe(3);
|
* locally, so the CAP's radio MAC is an *exact* match against the controller too.
|
||||||
|
* Excluding only prefix matches left half a fleet's radios on the controller.
|
||||||
|
*/
|
||||||
|
it('refuses an exact match against the excluded device', () => {
|
||||||
|
const idx = buildMacIndex([{ device_id: 12, mac_address: '04:F4:1C:A2:C4:66' }]);
|
||||||
|
expect(lookupDeviceForMac('04:F4:1C:A2:C4:66', idx, 12)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks the real access point when both it and the controller carry the MAC', () => {
|
||||||
|
// Exactly the shape reported: the controller mirrors the CAP's interface, so
|
||||||
|
// both devices index the same radio MAC.
|
||||||
|
const idx = buildMacIndex([
|
||||||
|
{ device_id: 12, mac_address: '04:F4:1C:A2:C4:66' }, // controller's mirror
|
||||||
|
{ device_id: 22, mac_address: '04:F4:1C:A2:C4:66' }, // the CAP itself
|
||||||
|
]);
|
||||||
|
expect(lookupDeviceForMac('04:F4:1C:A2:C4:66', idx, 12)).toBe(22);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is order-independent — the CAP wins whichever row was indexed first', () => {
|
||||||
|
const idx = buildMacIndex([
|
||||||
|
{ device_id: 22, mac_address: '04:F4:1C:A2:C4:66' },
|
||||||
|
{ device_id: 12, mac_address: '04:F4:1C:A2:C4:66' },
|
||||||
|
]);
|
||||||
|
expect(lookupDeviceForMac('04:F4:1C:A2:C4:66', idx, 12)).toBe(22);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('still prefix-matches a different device', () => {
|
it('still prefix-matches a different device', () => {
|
||||||
const idx = new Map(macIndexKeys('AA:BB:CC:00:00:01').map((k) => [k, 7] as [string, number]));
|
const idx = buildMacIndex([{ device_id: 7, mac_address: 'AA:BB:CC:00:00:01' }]);
|
||||||
expect(lookupDeviceForMac('AA:BB:CC:00:00:09', idx, 3)).toBe(7);
|
expect(lookupDeviceForMac('AA:BB:CC:00:00:09', idx, 3)).toBe(7);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prefers an exact match over a prefix match', () => {
|
it('prefers an exact match over a prefix match', () => {
|
||||||
const idx = new Map<string, number>([
|
const idx = buildMacIndex([
|
||||||
['AA:BB:CC:00:00', 7], // prefix → device 7
|
{ device_id: 7, mac_address: 'AA:BB:CC:00:00:01' },
|
||||||
['AA:BB:CC:00:00:09', 9], // exact → device 9
|
{ device_id: 9, mac_address: 'AA:BB:CC:00:00:09' },
|
||||||
]);
|
]);
|
||||||
expect(lookupDeviceForMac('AA:BB:CC:00:00:09', idx)).toBe(9);
|
expect(lookupDeviceForMac('AA:BB:CC:00:00:09', idx)).toBe(9);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reporter's fleet: a router acting purely as controller, mirroring six CAP
|
||||||
|
* interfaces. Before the fix these resolved to the controller or the CAP depending
|
||||||
|
* on database row order, so the same AP appeared in both places between polls.
|
||||||
|
*/
|
||||||
|
describe('matchRadiosToDevices — controller mirrors (#94 field report)', () => {
|
||||||
|
const CONTROLLER = 12;
|
||||||
|
const index = buildMacIndex([
|
||||||
|
{ device_id: CONTROLLER, mac_address: '04:F4:1C:A2:C4:65' }, // mirrors
|
||||||
|
{ device_id: CONTROLLER, mac_address: '04:F4:1C:A2:C4:66' },
|
||||||
|
{ device_id: 22, mac_address: '04:F4:1C:A2:C4:65' }, // bathroom AP
|
||||||
|
{ device_id: 22, mac_address: '04:F4:1C:A2:C4:66' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('puts both radios of one AP on that AP, not the controller', () => {
|
||||||
|
const radios = normalizeRadios([
|
||||||
|
{ 'radio-mac': '04:F4:1C:A2:C4:65', interface: 'wifi-legacy-bathroom-ap-001-main-5g', local: 'false' },
|
||||||
|
{ 'radio-mac': '04:F4:1C:A2:C4:66', interface: 'wifi-legacy-bathroom-ap-001-main-2g', local: 'false' },
|
||||||
|
]);
|
||||||
|
expect(matchRadiosToDevices(radios, index, CONTROLLER).map((m) => m.deviceId)).toEqual([22, 22]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still keeps a genuinely local radio on the controller', () => {
|
||||||
|
const radios = normalizeRadios([{ 'radio-mac': 'FF:FF:FF:00:00:01', local: 'true' }]);
|
||||||
|
expect(matchRadiosToDevices(radios, index, CONTROLLER)[0].deviceId).toBe(CONTROLLER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a CAP that is not in the fleet unattributed rather than on the controller', () => {
|
||||||
|
const radios = normalizeRadios([{ 'radio-mac': '04:F4:1C:99:99:99', local: 'false' }]);
|
||||||
|
expect(matchRadiosToDevices(radios, index, CONTROLLER)[0].deviceId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -187,15 +187,46 @@ export function normalizeRadios(rows: Record<string, string>[]): RadioRow[] {
|
|||||||
* on hardware identity alone: a MAC is globally unique, so unlike an address it
|
* on hardware identity alone: a MAC is globally unique, so unlike an address it
|
||||||
* cannot mean two different devices on two different segments.
|
* cannot mean two different devices on two different segments.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Index every known interface MAC to the device(s) carrying it.
|
||||||
|
*
|
||||||
|
* Multi-valued deliberately. A CAPsMAN controller mirrors each CAP's interfaces
|
||||||
|
* locally, so a CAP's radio MAC legitimately appears under both the CAP and the
|
||||||
|
* controller. A single-valued first-wins index picked whichever row the database
|
||||||
|
* returned first, which attributed half a fleet's radios to the controller and
|
||||||
|
* shuffled between polls (#94).
|
||||||
|
*/
|
||||||
|
export function buildMacIndex(
|
||||||
|
rows: { device_id: number; mac_address: string | null }[]
|
||||||
|
): Map<string, number[]> {
|
||||||
|
const index = new Map<string, number[]>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row.mac_address) continue;
|
||||||
|
for (const key of macIndexKeys(row.mac_address)) {
|
||||||
|
const list = index.get(key) ?? [];
|
||||||
|
if (!list.includes(row.device_id)) list.push(row.device_id);
|
||||||
|
index.set(key, list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attribute each radio a controller reports to a managed device, by MAC.
|
||||||
|
*
|
||||||
|
* A radio the controller flags `local: false` physically lives on a CAP, so the
|
||||||
|
* controller is never the answer — even when its mirror of that interface makes the
|
||||||
|
* MAC look like its own. Matching is on hardware identity: a MAC is globally unique,
|
||||||
|
* so unlike an address it cannot mean two devices on two segments.
|
||||||
|
*/
|
||||||
export function matchRadiosToDevices(
|
export function matchRadiosToDevices(
|
||||||
radios: RadioRow[],
|
radios: RadioRow[],
|
||||||
macToDevice: Map<string, number>,
|
macToDevice: Map<string, number[]>,
|
||||||
controllerDeviceId: number
|
controllerDeviceId: number
|
||||||
): { radio: RadioRow; deviceId: number | null }[] {
|
): { radio: RadioRow; deviceId: number | null }[] {
|
||||||
return radios.map((radio) => {
|
return radios.map((radio) => {
|
||||||
if (radio.local) return { radio, deviceId: controllerDeviceId };
|
if (radio.local) return { radio, deviceId: controllerDeviceId };
|
||||||
const hit = radio.radioMac ? macToDevice.get(radio.radioMac.toUpperCase()) : undefined;
|
return { radio, deviceId: lookupDeviceForMac(radio.radioMac, macToDevice, controllerDeviceId) };
|
||||||
return { radio, deviceId: hit ?? null };
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,28 +246,22 @@ export function macIndexKeys(mac: string): string[] {
|
|||||||
/**
|
/**
|
||||||
* Look up a device for a radio MAC, falling back to the five-octet prefix.
|
* Look up a device for a radio MAC, falling back to the five-octet prefix.
|
||||||
*
|
*
|
||||||
* `excludeOnPrefix` refuses a *prefix* match against a given device — used to stop a
|
* `excludeDeviceId` is rejected at both exact and prefix strength. That is not a
|
||||||
* remote CAP being attributed to the controller when the two happen to share the
|
* tie-break heuristic: the caller passes the controller for a radio the controller
|
||||||
* first five octets. An exact match is still honoured, since that is hardware
|
* itself reported as remote, so the controller is known to be the wrong answer, and
|
||||||
* identity rather than a near-miss. Reported on #94, where a CAP's radio was grouped
|
* an exact match against it only means the controller mirrors that CAP's interface.
|
||||||
* under the controller instead of its access point.
|
|
||||||
*/
|
*/
|
||||||
export function lookupDeviceForMac(
|
export function lookupDeviceForMac(
|
||||||
mac: string | null,
|
mac: string | null,
|
||||||
index: Map<string, number>,
|
index: Map<string, number[]>,
|
||||||
excludeOnPrefix?: number
|
excludeDeviceId?: number
|
||||||
): number | null {
|
): number | null {
|
||||||
if (!mac) return null;
|
if (!mac) return null;
|
||||||
const keys = macIndexKeys(mac);
|
for (const key of macIndexKeys(mac)) {
|
||||||
|
const candidates = index.get(key);
|
||||||
const exact = index.get(keys[0]);
|
if (!candidates) continue;
|
||||||
if (exact !== undefined) return exact;
|
const hit = candidates.find((id) => id !== excludeDeviceId);
|
||||||
|
if (hit !== undefined) return hit;
|
||||||
for (const key of keys.slice(1)) {
|
|
||||||
const hit = index.get(key);
|
|
||||||
if (hit === undefined) continue;
|
|
||||||
if (excludeOnPrefix !== undefined && hit === excludeOnPrefix) continue;
|
|
||||||
return hit;
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mikrotik-manager-frontend",
|
"name": "mikrotik-manager-frontend",
|
||||||
"version": "0.23.0-beta",
|
"version": "0.23.1-beta",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Server, AlertTriangle, ChevronDown, ChevronRight } from 'lucide-react';
|
import { Server, AlertTriangle, ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { wirelessApi, type CapsmanController, type CapsmanRadio } from '../../services/api';
|
import { wirelessApi, type CapsmanController } from '../../services/api';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
/** One row of the unified radio/interface table. */
|
/** One row of the unified radio/interface table. */
|
||||||
@@ -13,55 +13,33 @@ interface Row {
|
|||||||
local: boolean;
|
local: boolean;
|
||||||
interfaceName: string;
|
interfaceName: string;
|
||||||
ssid: string | null;
|
ssid: string | null;
|
||||||
band: string | null;
|
|
||||||
frequency: number | null;
|
|
||||||
channel: string | null;
|
channel: string | null;
|
||||||
clients: number | null;
|
clients: number | null;
|
||||||
running: boolean | null;
|
running: boolean;
|
||||||
disabled: boolean | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flatten a controller into one row per interface.
|
* Flatten a controller into one row per radio.
|
||||||
*
|
*
|
||||||
* Radios and provisioned interfaces were two separate tables describing the same
|
* Driven by the controller's radio list rather than by the CAPs' own interface rows.
|
||||||
* things from different angles, which left the reader joining them by eye. Each
|
* A CAP holds no configuration — its rows report a generic name and null SSID, band
|
||||||
* interface is emitted against the access point it lives on; a radio with no
|
* and frequency — whereas the controller knows the provisioned SSID, the operating
|
||||||
* provisioned interface still appears, so a CAP that is up but carrying nothing is
|
* channel and the client count for every radio it manages.
|
||||||
* visible rather than absent.
|
|
||||||
*/
|
*/
|
||||||
function toRows(c: CapsmanController): Row[] {
|
function toRows(c: CapsmanController): Row[] {
|
||||||
const rows: Row[] = [];
|
const rows: Row[] = [];
|
||||||
const radioOf = new Map<string, CapsmanRadio>();
|
|
||||||
for (const ap of c.access_points) for (const r of ap.radios) radioOf.set(r.radio_mac, r);
|
|
||||||
|
|
||||||
for (const ap of c.access_points) {
|
for (const ap of c.access_points) {
|
||||||
const ifaces = c.ssids.filter((s) => s.device_id === ap.device_id);
|
|
||||||
if (ifaces.length > 0) {
|
|
||||||
for (const s of ifaces) {
|
|
||||||
const radio = s.radio_mac ? radioOf.get(s.radio_mac) : undefined;
|
|
||||||
rows.push({
|
|
||||||
key: `${ap.device_id ?? ap.device_name}:${s.name}`,
|
|
||||||
deviceId: ap.device_id, deviceName: ap.device_name,
|
|
||||||
local: ap.radios[0]?.local ?? false,
|
|
||||||
interfaceName: s.name, ssid: s.ssid,
|
|
||||||
band: s.band, frequency: s.frequency,
|
|
||||||
channel: radio?.current_channel ?? null,
|
|
||||||
clients: radio?.registered_peers ?? null,
|
|
||||||
running: s.running, disabled: s.disabled,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// No provisioned interface reported — still show the radio itself.
|
|
||||||
for (const r of ap.radios) {
|
for (const r of ap.radios) {
|
||||||
rows.push({
|
rows.push({
|
||||||
key: `${ap.device_id ?? ap.device_name}:${r.radio_mac}`,
|
key: `${ap.device_id ?? ap.device_name}:${r.radio_mac}`,
|
||||||
deviceId: ap.device_id, deviceName: ap.device_name, local: r.local,
|
deviceId: ap.device_id,
|
||||||
|
deviceName: ap.device_name,
|
||||||
|
local: r.local,
|
||||||
interfaceName: r.interface_name || r.radio_mac,
|
interfaceName: r.interface_name || r.radio_mac,
|
||||||
ssid: null, band: null, frequency: null,
|
ssid: r.ssid,
|
||||||
channel: r.current_channel, clients: r.registered_peers,
|
channel: r.current_channel,
|
||||||
running: r.state === 'running', disabled: null,
|
clients: r.registered_peers,
|
||||||
|
running: r.state === 'running',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,7 +77,7 @@ function Controller({ c }: { c: CapsmanController }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-gray-500 dark:text-slate-400 mt-0.5">
|
<div className="text-[11px] text-gray-500 dark:text-slate-400 mt-0.5">
|
||||||
{c.access_points.length} access point{c.access_points.length !== 1 ? 's' : ''} ·{' '}
|
{c.access_points.length} access point{c.access_points.length !== 1 ? 's' : ''} ·{' '}
|
||||||
{rows.length} interface{rows.length !== 1 ? 's' : ''} ·{' '}
|
{rows.length} radio{rows.length !== 1 ? 's' : ''} ·{' '}
|
||||||
{c.client_count} client{c.client_count !== 1 ? 's' : ''}
|
{c.client_count} client{c.client_count !== 1 ? 's' : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,7 +100,7 @@ function Controller({ c }: { c: CapsmanController }) {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-200 dark:border-slate-700">
|
<tr className="border-b border-gray-200 dark:border-slate-700">
|
||||||
{['Access point', 'Interface', 'SSID', 'Band', 'Frequency', 'Channel', 'Clients', 'Status'].map((h) => (
|
{['Access point', 'Interface', 'SSID', 'Channel', 'Clients', 'Status'].map((h) => (
|
||||||
<th key={h} className="table-header px-3 py-2 text-left whitespace-nowrap">{h}</th>
|
<th key={h} className="table-header px-3 py-2 text-left whitespace-nowrap">{h}</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -146,8 +124,6 @@ function Controller({ c }: { c: CapsmanController }) {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 mono text-xs text-gray-600 dark:text-slate-300 whitespace-nowrap">{r.interfaceName}</td>
|
<td className="px-3 py-2 mono text-xs text-gray-600 dark:text-slate-300 whitespace-nowrap">{r.interfaceName}</td>
|
||||||
<td className="px-3 py-2 text-xs font-medium text-gray-900 dark:text-white">{r.ssid || <span className="text-gray-400">—</span>}</td>
|
<td className="px-3 py-2 text-xs font-medium text-gray-900 dark:text-white">{r.ssid || <span className="text-gray-400">—</span>}</td>
|
||||||
<td className="px-3 py-2 mono text-xs text-gray-500 dark:text-slate-400 whitespace-nowrap">{r.band || <span className="text-gray-400">—</span>}</td>
|
|
||||||
<td className="px-3 py-2 mono num-tab text-xs text-gray-500 dark:text-slate-400">{r.frequency ?? <span className="text-gray-400">—</span>}</td>
|
|
||||||
<td className="px-3 py-2 mono text-xs text-gray-500 dark:text-slate-400 whitespace-nowrap">{r.channel || <span className="text-gray-400">—</span>}</td>
|
<td className="px-3 py-2 mono text-xs text-gray-500 dark:text-slate-400 whitespace-nowrap">{r.channel || <span className="text-gray-400">—</span>}</td>
|
||||||
<td className="px-3 py-2 mono num-tab text-xs text-gray-600 dark:text-slate-300">
|
<td className="px-3 py-2 mono num-tab text-xs text-gray-600 dark:text-slate-300">
|
||||||
{r.clients ?? <span className="text-gray-400">—</span>}
|
{r.clients ?? <span className="text-gray-400">—</span>}
|
||||||
@@ -155,11 +131,10 @@ function Controller({ c }: { c: CapsmanController }) {
|
|||||||
<td className="px-3 py-2 whitespace-nowrap">
|
<td className="px-3 py-2 whitespace-nowrap">
|
||||||
<span className={clsx(
|
<span className={clsx(
|
||||||
'text-[10px] px-1.5 py-0.5 rounded font-medium',
|
'text-[10px] px-1.5 py-0.5 rounded font-medium',
|
||||||
r.disabled ? 'bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400'
|
r.running ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
|
||||||
: r.running ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
|
: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400'
|
||||||
: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400'
|
|
||||||
)}>
|
)}>
|
||||||
{r.disabled ? 'disabled' : r.running ? 'running' : 'enabled'}
|
{r.running ? 'running' : 'idle'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -903,6 +903,8 @@ export interface CapsmanRadio {
|
|||||||
/** Operating channel from /interface/wifi/monitor, not the supported-channel list. */
|
/** Operating channel from /interface/wifi/monitor, not the supported-channel list. */
|
||||||
current_channel: string | null;
|
current_channel: string | null;
|
||||||
state: string | null;
|
state: string | null;
|
||||||
|
/** SSID the controller provisioned onto this radio; the CAP itself does not know it. */
|
||||||
|
ssid: string | null;
|
||||||
registered_peers: number | null;
|
registered_peers: number | null;
|
||||||
authorized_peers: number | null;
|
authorized_peers: number | null;
|
||||||
tx_power: number | null;
|
tx_power: number | null;
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = 'v0.23.0 Beta';
|
export const APP_VERSION = 'v0.23.1 Beta';
|
||||||
|
|||||||
Reference in New Issue
Block a user