diff --git a/README.md b/README.md index 20bd012..e47474f 100644 --- a/README.md +++ b/README.md @@ -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. -![Version](https://img.shields.io/badge/version-0.21.0_Beta-blue) +![Version](https://img.shields.io/badge/version-0.22.0_Beta-blue) ![License](https://img.shields.io/badge/license-AGPLv3-blue) ![Docker](https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white) @@ -237,6 +237,31 @@ Configure under **Settings → Change Guard** (`change_guard_enabled`, `change_g - **WiFi connectivity success** funnel (Association / Authentication / DHCP) derived from device logs - **Rogue & neighbor AP detection** — cross-references stored AP-scan results against your own SSIDs and radio MACs. A foreign BSSID broadcasting one of *your* SSIDs is flagged as a **rogue / evil-twin** (and raised in Operations → Things to handle); everything else is a ranked neighbor-network inventory +### CAPsMAN +Access points provisioned centrally by CAPsMAN hold none of their own wireless configuration — the +controller owns it — so reading an AP directly returns blank SSID, security and band. The platform +now recognises that instead of showing an access point that looks broken: + +- **Role detection** — each device is classified as standalone, CAP, controller, or controller-with- + local-radios (a supported MikroTik arrangement) from `/interface/wifi/capsman` and `/interface/wifi/cap` +- **Provisioned interfaces are labelled** — a CAPsMAN badge on the Radios tab, and an explanation that + the blank fields live on the controller, with the controller's MAC. SSID, mode and channel are + recovered from RouterOS's status line where present +- **CAPsMAN panel on the Wireless page** — every controller with the access points it provisions, + grouped per AP: radios, hardware type, live channel, provisioned SSIDs with client counts, named + configurations, and the provisioning rules that bind them +- **CAPs are matched to managed devices by radio MAC**, taken from the controller's own + `/interface/wifi/radio` list. A MAC is hardware identity and globally unique, so unlike an IP it + cannot mean two devices on two segments. A CAP that is *not* in the fleet is surfaced rather than + hidden, since that is usually why an AP appears to be missing +- **Controllers are no longer excluded** — the wireless section previously required + `device_type = 'wireless_ap'`, which hid a dedicated controller (normally classified as a router) + +**Read-only for now.** Editing a CAPsMAN configuration applies it to every access point bound to it +at once, so provisioning and config edits are deliberately not exposed until they carry the same +prediction-and-revert protection that device changes do. Legacy `/caps-man` (the pre-RouterOS 7 +controller) is a separate API tree and is not covered yet. + ### Guest WiFi (Hotspot) A one-click captive-portal guest network on RouterOS Hotspot, under **Wireless → Guest WiFi**: - **Guided setup wizard** — creates the whole guest network in one pass: a new **guest SSID** (a virtual AP on every physical radio), **VLAN segregation** (guest SSIDs tagged onto the bridge with an L3 VLAN interface for the portal — or a dedicated guest bridge when no VLAN is given), IP pool, DHCP, hotspot profile, portal server, a bandwidth-limited guest user profile, and an optional masquerade NAT rule. Idempotent, with honest warnings (e.g. bridge VLAN-filtering disabled). Can also target an existing interface. diff --git a/backend/package.json b/backend/package.json index 4c6a145..b58ba68 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.21.0-beta", + "version": "0.22.0-beta", "description": "Mikrotik Network Management Platform - Backend API", "main": "dist/index.js", "scripts": { diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 4112a8c..950510c 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -498,6 +498,67 @@ CREATE TABLE IF NOT EXISTS device_change_guards ( CREATE INDEX IF NOT EXISTS idx_change_guards_device ON device_change_guards(device_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_change_guards_pending ON device_change_guards(status, expires_at); +-- CAPsMAN support (github issue #94). +-- +-- A CAPsMAN-managed AP holds none of its own wireless configuration, so reading +-- /interface/wifi/print on it yields blank SSID/security/band. Recording the +-- device's role, and marking which interfaces are provisioned rather than local, +-- lets the UI say "managed by CAPsMAN" instead of showing an AP that looks broken. +ALTER TABLE devices ADD COLUMN IF NOT EXISTS wifi_role VARCHAR(16); +ALTER TABLE wireless_interfaces ADD COLUMN IF NOT EXISTS managed_by_capsman BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE wireless_interfaces ADD COLUMN IF NOT EXISTS radio_mac VARCHAR(17); +ALTER TABLE wireless_interfaces ADD COLUMN IF NOT EXISTS capsman_controller_mac VARCHAR(17); + +-- Radios a controller manages, local and remote. matched_device_id is resolved by +-- radio MAC against the fleet's interface MACs; it stays NULL for a CAP that is not +-- itself a managed device, which is a legitimate state rather than an error. +CREATE TABLE IF NOT EXISTS capsman_radios ( + id SERIAL PRIMARY KEY, + controller_device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + radio_mac VARCHAR(17) NOT NULL, + interface_name VARCHAR(64), + local BOOLEAN NOT NULL DEFAULT FALSE, + hw_type VARCHAR(64), + current_channel TEXT, + remote_cap_name VARCHAR(128), + matched_device_id INTEGER REFERENCES devices(id) ON DELETE SET NULL, + config_json JSONB, + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(controller_device_id, radio_mac) +); +CREATE INDEX IF NOT EXISTS idx_capsman_radios_device ON capsman_radios(matched_device_id); + +-- Named configurations and the provisioning rules that bind them to radios. Both +-- are read-only for now; editing them would push to every bound AP at once. +CREATE TABLE IF NOT EXISTS capsman_configurations ( + id SERIAL PRIMARY KEY, + controller_device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + name VARCHAR(128) NOT NULL, + ssid VARCHAR(128), + mode VARCHAR(32), + band VARCHAR(64), + security VARCHAR(128), + authentication_types VARCHAR(128), + config_json JSONB, + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(controller_device_id, name) +); + +CREATE TABLE IF NOT EXISTS capsman_provisioning ( + id SERIAL PRIMARY KEY, + controller_device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + ros_id VARCHAR(32) NOT NULL, + action VARCHAR(64), + master_configuration VARCHAR(128), + slave_configurations TEXT, + radio_mac VARCHAR(17), + comment TEXT, + disabled BOOLEAN NOT NULL DEFAULT FALSE, + config_json JSONB, + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(controller_device_id, ros_id) +); + -- 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. ALTER TABLE alert_channels DROP CONSTRAINT IF EXISTS alert_channels_type_check; diff --git a/backend/src/routes/wireless.ts b/backend/src/routes/wireless.ts index c632ee8..7f74052 100644 --- a/backend/src/routes/wireless.ts +++ b/backend/src/routes/wireless.ts @@ -11,7 +11,7 @@ router.use(requireAuth); async function getAP(id: number): Promise { const rows = await query( - `SELECT * FROM devices WHERE id = $1 AND device_type = 'wireless_ap'`, + `SELECT * FROM devices WHERE id = $1 AND (device_type = 'wireless_ap' OR wifi_role IN ('cap','controller','controller_cap'))`, [id] ); return rows[0] ?? null; @@ -32,7 +32,7 @@ router.post('/ssid/bulk', requireWrite, async (req: Request, res: Response) => { const placeholders = apIds.map((_, i) => `$${i + 1}`).join(','); const aps = await query( - `SELECT * FROM devices WHERE id IN (${placeholders}) AND device_type = 'wireless_ap'`, + `SELECT * FROM devices WHERE id IN (${placeholders}) AND (device_type = 'wireless_ap' OR wifi_role IN ('cap','controller','controller_cap'))`, apIds ); @@ -102,13 +102,109 @@ router.get('/', async (_req: Request, res: Response) => { COUNT(DISTINCT wi.id) FILTER (WHERE wi.ssid IS NOT NULL AND wi.disabled = false) AS ssid_count FROM devices d LEFT JOIN wireless_interfaces wi ON wi.device_id = d.id - WHERE d.device_type = 'wireless_ap' + WHERE (d.device_type = 'wireless_ap' OR d.wifi_role IN ('cap','controller','controller_cap')) GROUP BY d.id ORDER BY d.name ASC `); res.json(aps); }); +// ─── CAPsMAN (issue #94) ────────────────────────────────────────────────────── + +/** + * GET /api/wireless/capsman — every controller in the fleet with the radios it + * manages, grouped by the access point each radio physically lives on. + * + * Read-only. Reads the cache rather than the devices, so the page loads instantly + * and works while a CAP is offline — the controller still knows what it provisioned. + */ +router.get('/capsman', async (_req: Request, res: Response) => { + const controllers = await query<{ + id: number; name: string; ip_address: string; model: string | null; + status: string; wifi_role: string | null; + }>(` + SELECT id, name, ip_address, model, status, wifi_role + FROM devices + WHERE wifi_role IN ('controller', 'controller_cap') + ORDER BY name ASC + `); + + if (controllers.length === 0) return res.json({ controllers: [] }); + + const ids = controllers.map((c) => c.id); + const [radios, configs, provisioning] = await Promise.all([ + query(` + 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, + d.name AS matched_device_name, d.status AS matched_device_status + FROM capsman_radios r + LEFT JOIN devices d ON d.id = r.matched_device_id + WHERE r.controller_device_id = ANY($1::int[]) + ORDER BY r.local DESC, d.name NULLS LAST, r.interface_name + `, [ids]), + query(` + SELECT controller_device_id, name, ssid, mode, band, security, authentication_types + FROM capsman_configurations + WHERE controller_device_id = ANY($1::int[]) + ORDER BY name + `, [ids]), + query(` + SELECT controller_device_id, ros_id, action, master_configuration, + slave_configurations, radio_mac, comment, disabled + FROM capsman_provisioning + WHERE controller_device_id = ANY($1::int[]) + ORDER BY ros_id + `, [ids]), + ]); + + // SSIDs actually broadcasting, so the view shows what is live rather than only + // what was configured. + const interfaces = await query(` + SELECT wi.device_id, wi.name, wi.ssid, wi.band, wi.frequency, wi.disabled, wi.running, + wi.managed_by_capsman, wi.radio_mac, wi.registered_clients, d.name AS device_name + FROM wireless_interfaces wi + JOIN devices d ON d.id = wi.device_id + WHERE wi.managed_by_capsman = TRUE + ORDER BY d.name, wi.name + `); + + const byController = (rows: T[], id: number) => + rows.filter((r) => r.controller_device_id === id); + + res.json({ + controllers: controllers.map((c) => { + const cRadios = byController(radios as { controller_device_id: number; matched_device_id: number | null; matched_device_name: string | null }[], c.id); + + // Group radios by the AP they sit on. An unmatched radio means a CAP that is + // not a managed device — worth showing rather than hiding, since it is often + // the reason an AP is missing from the fleet. + const groups = new Map(); + for (const r of cRadios) { + const key = r.matched_device_id != null ? `d:${r.matched_device_id}` : `u:${(r as { remote_cap_name?: string }).remote_cap_name ?? 'unknown'}`; + const g = groups.get(key) ?? { + device_id: r.matched_device_id, + device_name: r.matched_device_name ?? (r as { remote_cap_name?: string }).remote_cap_name ?? 'Unmanaged CAP', + radios: [], + }; + g.radios.push(r); + groups.set(key, g); + } + + const ssids = (interfaces as { device_id: number }[]).filter((i) => + cRadios.some((r) => r.matched_device_id === i.device_id) + ); + + return { + ...c, + access_points: [...groups.values()], + configurations: byController(configs as { controller_device_id: number }[], c.id), + provisioning: byController(provisioning as { controller_device_id: number }[], c.id), + ssids, + }; + }), + }); +}); + // ─── Wireless interfaces (SSIDs) ────────────────────────────────────────────── // GET /api/wireless/:id/bridges — bridge list + current port memberships @@ -719,7 +815,7 @@ router.get('/rf/channels', async (req: Request, res: Response) => { wi.band, wi.frequency, wi.channel_width, wi.registered_clients FROM wireless_interfaces wi JOIN devices d ON d.id = wi.device_id - WHERE d.device_type = 'wireless_ap' + 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 AND (wi.config_json->>'master-interface') IS NULL diff --git a/backend/src/services/mikrotik/DeviceCollector.ts b/backend/src/services/mikrotik/DeviceCollector.ts index 74724cf..4f532c2 100644 --- a/backend/src/services/mikrotik/DeviceCollector.ts +++ b/backend/src/services/mikrotik/DeviceCollector.ts @@ -7,6 +7,11 @@ import { decrypt } from '../../utils/crypto'; import { lookupVendor } from '../../utils/oui'; import { buildServerArpMap } from '../../utils/serverArp'; import { aggregateBridgeVlans, portVlanMembership, expandVlanIds } from '../../utils/vlan'; +import { + classifyWifiRole, isCapsmanManaged, parseCapsmanStatus, parseCapStatus, + normalizeRadios, lookupDeviceForMac, macIndexKeys, + type WifiRole, +} from './capsman'; import { BackupService } from '../BackupService'; import { alertService } from '../AlertService'; @@ -114,15 +119,32 @@ export class DeviceCollector { // ─── Slow poll (every 5 min) ─────────────────────────────────────────────── + /** + * Should wireless data be collected from this device? + * + * `device_type` alone is wrong for CAPsMAN: a dedicated controller is usually + * classified as a router, has no radios of its own, and yet owns the wireless + * configuration of the entire fleet. Probing every device once and remembering + * the answer costs one call on first sight and nothing afterwards, which beats + * both probing forever and never noticing a controller. + */ + private shouldCollectWireless(): boolean { + if (this.device.device_type === 'wireless_ap') return true; + const role = (this.device as unknown as { wifi_role?: string | null }).wifi_role; + if (role == null) return true; // never determined — probe once + return role !== 'none' && role !== 'standalone'; + } + async collectSlow(): Promise { await this.collectInterfaces(); await this.collectIpAddressesCache(); await this.collectVlans(); await this.collectSystemInfo(); await this.collectStp(); - if (this.device.device_type === 'wireless_ap') { + if (this.shouldCollectWireless()) { await this.collectWirelessInterfaces(); await this.collectSecurityProfiles(); + await this.collectCapsman(); } } @@ -147,9 +169,10 @@ export class DeviceCollector { // Wireless data belongs in a full resync too — without this, a manual sync // never refreshed radios/SSIDs (or pruned ones deleted on the device), so // the UI could only catch up on the 5-minute slow poll. - if (this.device.device_type === 'wireless_ap') { + if (this.shouldCollectWireless()) { await this.collectWirelessInterfaces(); await this.collectSecurityProfiles(); + await this.collectCapsman(); } await this.saveFullConfig(); await this.updateDeviceStatus('online'); @@ -1319,6 +1342,175 @@ export class DeviceCollector { ); } + // ─── CAPsMAN ────────────────────────────────────────────────────────────── + + private wifiRoleCache: WifiRole | null = null; + + /** + * Is this device a CAPsMAN controller, a CAP, both, or neither? + * + * Read from the two feature toggles rather than inferred from missing data — an + * AP whose SSID is blank because the controller owns it looks identical to one + * that is simply unconfigured, and only this tells them apart. + */ + async detectWifiRole(): Promise { + if (this.wifiRoleCache !== null) return this.wifiRoleCache; + const pkg = await this.detectWifiPackage(); + if (pkg !== 'wifi') { + // Legacy /caps-man is a separate API tree and is not covered yet; treat the + // old wireless package as standalone rather than guessing. + this.wifiRoleCache = pkg === 'none' ? 'none' : 'standalone'; + return this.wifiRoleCache; + } + + const [capsman, cap, ifaces] = await Promise.all([ + this.client.execute('/interface/wifi/capsman/print').catch(() => null), + this.client.execute('/interface/wifi/cap/print').catch(() => null), + this.client.execute('/interface/wifi/print').catch(() => [] as Record[]), + ]); + this.wifiRoleCache = classifyWifiRole(capsman, cap, ifaces.length > 0); + return this.wifiRoleCache; + } + + /** + * Controller-side inventory: the radios it manages, the named configurations, and + * the provisioning rules binding them together. + * + * Remote radios are attributed to managed devices by MAC. That is a hardware + * identity and therefore globally unique, so unlike an address it cannot mean two + * different devices in two different segments — the failure that made topology + * unreliable before v0.20.1. + */ + async collectCapsman(): Promise { + try { + const role = await this.detectWifiRole(); + if (role !== 'controller' && role !== 'controller_cap') { + // Not a controller: clear any inventory left from when it was one. + await query(`DELETE FROM capsman_radios WHERE controller_device_id = $1`, [this.device.id]); + await query(`DELETE FROM capsman_configurations WHERE controller_device_id = $1`, [this.device.id]); + await query(`DELETE FROM capsman_provisioning WHERE controller_device_id = $1`, [this.device.id]); + return; + } + + const [radioRows, configRows, provRows] = await Promise.all([ + this.client.execute('/interface/wifi/radio/print').catch(() => null), + this.client.execute('/interface/wifi/configuration/print', { detail: '' }).catch(() => null), + this.client.execute('/interface/wifi/provisioning/print', { detail: '' }).catch(() => null), + ]); + + // Fleet-wide MAC → device index, including a five-octet prefix key because a + // radio MAC often differs from the interface MAC in the final octet only. + const macRows = await query<{ device_id: number; mac_address: string }>( + `SELECT device_id, mac_address FROM interfaces WHERE mac_address IS NOT NULL + UNION + SELECT device_id, mac_address FROM wireless_interfaces WHERE mac_address IS NOT NULL` + ).catch(() => []); + const macIndex = new Map(); + 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) { + const radios = normalizeRadios(radioRows); + const seen: string[] = []; + for (const radio of radios) { + if (!radio.radioMac) continue; + seen.push(radio.radioMac); + const capInfo = parseCapStatus(radio.raw); + const matched = radio.local + ? this.device.id + : lookupDeviceForMac(radio.radioMac, macIndex); + + await query( + `INSERT INTO capsman_radios + (controller_device_id, radio_mac, interface_name, local, hw_type, + current_channel, remote_cap_name, matched_device_id, config_json, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,NOW()) + ON CONFLICT (controller_device_id, radio_mac) DO UPDATE SET + interface_name=$3, local=$4, hw_type=$5, current_channel=$6, + remote_cap_name=$7, matched_device_id=$8, config_json=$9, updated_at=NOW()`, + [ + this.device.id, radio.radioMac, radio.interfaceName, radio.local, + radio.hwType, radio.currentChannel, + capInfo?.capMac ?? radio.raw['name'] ?? null, + matched, JSON.stringify(radio.raw), + ] + ); + } + await query( + `DELETE FROM capsman_radios WHERE controller_device_id = $1 AND NOT (radio_mac = ANY($2::text[]))`, + [this.device.id, seen] + ); + } + + if (configRows !== null) { + const seen: string[] = []; + for (const cfg of configRows) { + const name = cfg['name']; + if (!name) continue; + seen.push(name); + await query( + `INSERT INTO capsman_configurations + (controller_device_id, name, ssid, mode, band, security, authentication_types, config_json, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW()) + ON CONFLICT (controller_device_id, name) DO UPDATE SET + ssid=$3, mode=$4, band=$5, security=$6, authentication_types=$7, + config_json=$8, updated_at=NOW()`, + [ + this.device.id, name, + cfg['ssid'] ?? null, + cfg['mode'] ?? null, + cfg['channel.band'] ?? cfg['band'] ?? null, + cfg['security'] ?? null, + cfg['security.authentication-types'] ?? null, + JSON.stringify(cfg), + ] + ); + } + await query( + `DELETE FROM capsman_configurations WHERE controller_device_id = $1 AND NOT (name = ANY($2::text[]))`, + [this.device.id, seen] + ); + } + + if (provRows !== null) { + const seen: string[] = []; + for (const prov of provRows) { + const rosId = prov['.id']; + if (!rosId) continue; + seen.push(rosId); + await query( + `INSERT INTO capsman_provisioning + (controller_device_id, ros_id, action, master_configuration, slave_configurations, + radio_mac, comment, disabled, config_json, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,NOW()) + ON CONFLICT (controller_device_id, ros_id) DO UPDATE SET + action=$3, master_configuration=$4, slave_configurations=$5, radio_mac=$6, + comment=$7, disabled=$8, config_json=$9, updated_at=NOW()`, + [ + this.device.id, rosId, + prov['action'] ?? null, + prov['master-configuration'] ?? null, + prov['slave-configurations'] ?? null, + (prov['radio-mac'] || '').toUpperCase() || null, + prov['comment'] ?? null, + prov['disabled'] === 'true', + JSON.stringify(prov), + ] + ); + } + await query( + `DELETE FROM capsman_provisioning WHERE controller_device_id = $1 AND NOT (ros_id = ANY($2::text[]))`, + [this.device.id, seen] + ); + } + } catch (err) { + console.error(`[${this.device.name}] Failed to collect CAPsMAN data:`, (err as Error).message); + } + } + // ─── Wifi package detection ─────────────────────────────────────────────── private wifiPackageCache: 'wifi' | 'wireless' | 'none' | null = null; @@ -1366,6 +1558,12 @@ export class DeviceCollector { const pkg = await this.detectWifiPackage(); if (pkg === 'none') return; + // Recorded per device so the UI can explain a blank SSID as delegation to a + // controller rather than leaving it looking like an unconfigured radio. + const role = await this.detectWifiRole(); + await query(`UPDATE devices SET wifi_role = $2 WHERE id = $1`, [this.device.id, role]) + .catch(() => { /* role is advisory; never fail collection over it */ }); + // null = the fetch itself failed. Distinguished from an empty list (device // genuinely has no wireless interfaces) so a transient API error can never // be mistaken for "everything was deleted" during the prune below. @@ -1397,32 +1595,46 @@ export class DeviceCollector { for (const wlan of wlans) { const name = wlan['name']; if (!name) continue; + // A CAP holds no local configuration for provisioned interfaces, so the + // status line is the only place an SSID or channel appears. Fall back to it + // rather than storing nulls that render as an empty, broken-looking AP. + const capsmanStatus = parseCapsmanStatus(wlan); + const managed = isCapsmanManaged(wlan, role); const master = wlan['master-interface']; const liveF = liveFreq[name] ?? (master ? liveFreq[master] : undefined); const liveW = liveWidth[name] ?? (master ? liveWidth[master] : undefined); const freq = liveF ?? parseInt(wlan['frequency'] || '0', 10); const txPow = parseInt(wlan['tx-power'] || '0', 10); const gain = parseInt(wlan['antenna-gain'] || '0', 10); - const bandStr = wlan['band'] || (liveF ? bandPrefix(liveF) : null); + // "channel: 5280/ax/eCee" — frequency first, then the PHY mode. + const statusFreq = parseInt((capsmanStatus?.channel ?? '').split('/')[0] || '', 10); + const effFreq = !isNaN(freq) && freq > 0 + ? freq + : (!isNaN(statusFreq) && statusFreq > 0 ? statusFreq : freq); + const bandStr = wlan['band'] + || (liveF ? bandPrefix(liveF) : null) + || (!isNaN(statusFreq) && statusFreq > 0 ? bandPrefix(statusFreq) : null); const widthStr = wlan['channel-width'] || liveW || null; await query( `INSERT INTO wireless_interfaces (device_id, name, ssid, mode, band, frequency, channel_width, tx_power, tx_power_mode, antenna_gain, country, installation, disabled, running, - mac_address, security_profile, config_json, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,NOW()) + mac_address, security_profile, config_json, managed_by_capsman, radio_mac, + capsman_controller_mac, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,NOW()) ON CONFLICT (device_id, name) DO UPDATE SET ssid=$3, mode=$4, band=$5, frequency=$6, channel_width=$7, tx_power=$8, tx_power_mode=$9, antenna_gain=$10, country=$11, installation=$12, disabled=$13, running=$14, mac_address=$15, security_profile=$16, - config_json=$17, updated_at=NOW()`, + config_json=$17, managed_by_capsman=$18, radio_mac=$19, + capsman_controller_mac=$20, updated_at=NOW()`, [ this.device.id, name, - wlan['ssid'] || null, - wlan['mode'] || null, + wlan['ssid'] || capsmanStatus?.ssid || null, + wlan['mode'] || capsmanStatus?.mode || null, bandStr || null, - !isNaN(freq) && freq > 0 ? freq : null, + !isNaN(effFreq) && effFreq > 0 ? effFreq : null, widthStr, !isNaN(txPow) && txPow > 0 ? txPow : null, wlan['tx-power-mode'] || null, @@ -1434,6 +1646,9 @@ export class DeviceCollector { wlan['mac-address'] || null, wlan['security-profile'] || null, JSON.stringify(wlan), + managed, + (wlan['radio-mac'] || '').toUpperCase() || null, + capsmanStatus?.controllerMac ?? null, ] ); } @@ -1738,10 +1953,28 @@ export class DeviceCollector { this.getBridgePorts(), ]); - const ifaces = pkg === 'wifi' + const rawNormalized = pkg === 'wifi' ? rawIfaces.map(r => this.normalizeWifiInterface(r)) : rawIfaces; + // This endpoint reads the device live rather than the cache, so the CAPsMAN + // annotation has to be applied here too — otherwise the Radios tab shows blank + // fields with no indication that a controller owns them. + const role = await this.detectWifiRole(); + const ifaces = rawNormalized.map((iface, i) => { + const status = parseCapsmanStatus(rawIfaces[i] ?? iface); + const managed = isCapsmanManaged(rawIfaces[i] ?? iface, role); + if (!managed && !status) return iface; + return { + ...iface, + managed_by_capsman: 'true', + ...(status?.controllerMac ? { capsman_controller_mac: status.controllerMac } : {}), + ...(status?.ssid && !iface['ssid'] ? { ssid: status.ssid } : {}), + ...(status?.mode && !iface['mode'] ? { mode: status.mode } : {}), + ...(status?.channel ? { capsman_channel: status.channel } : {}), + }; + }); + // Enrich each interface with its bridge port membership (if any) const portByIface = new Map(bridgePorts.map(p => [p['interface'], p])); return ifaces.map(iface => { diff --git a/backend/src/services/mikrotik/__tests__/capsman.test.ts b/backend/src/services/mikrotik/__tests__/capsman.test.ts new file mode 100644 index 0000000..8025716 --- /dev/null +++ b/backend/src/services/mikrotik/__tests__/capsman.test.ts @@ -0,0 +1,160 @@ +import { + classifyWifiRole, parseCapsmanStatus, parseCapStatus, isCapsmanManaged, + normalizeRadios, matchRadiosToDevices, lookupDeviceForMac, macIndexKeys, +} from '../capsman'; + +describe('classifyWifiRole', () => { + const on = [{ enabled: 'yes' }]; + const off = [{ enabled: 'no' }]; + + it('reads the two toggles independently', () => { + expect(classifyWifiRole(off, off, true)).toBe('standalone'); + expect(classifyWifiRole(on, off, true)).toBe('controller'); + expect(classifyWifiRole(off, on, true)).toBe('cap'); + }); + + // Explicitly called out in #94: a device can be the controller and still run + // its own radios under that same controller. + it('gives a controller with local radios its own role', () => { + expect(classifyWifiRole(on, on, true)).toBe('controller_cap'); + }); + + it('reports none when there is no wireless hardware at all', () => { + expect(classifyWifiRole(off, off, false)).toBe('none'); + expect(classifyWifiRole(null, null, false)).toBe('none'); + }); + + it('treats a controller with no local radios as a controller, not none', () => { + expect(classifyWifiRole(on, off, false)).toBe('controller'); + }); + + it('accepts "true" as well as "yes"', () => { + expect(classifyWifiRole([{ enabled: 'true' }], off, true)).toBe('controller'); + }); +}); + +describe('parseCapsmanStatus', () => { + // Verbatim from the issue. + const line = 'managed by CAPsMAN 02:F8:E3:80:10:97%management_vlan, traffic processing on CAP, mode: AP, SSID: HomeAccessPoint, channel: 5280/ax/eCee'; + + it('extracts controller, interface, SSID, mode and channel', () => { + const s = parseCapsmanStatus({ status: line })!; + expect(s.controllerMac).toBe('02:F8:E3:80:10:97'); + expect(s.controllerInterface).toBe('management_vlan'); + expect(s.ssid).toBe('HomeAccessPoint'); + expect(s.mode).toBe('AP'); + expect(s.channel).toBe('5280/ax/eCee'); + }); + + // Which field carries this text is undocumented, so the parser searches values + // rather than trusting a field name. + it('finds the status text under any field name', () => { + expect(parseCapsmanStatus({ 'some-future-field': line })?.ssid).toBe('HomeAccessPoint'); + }); + + it('copes with a status line that omits the optional parts', () => { + const s = parseCapsmanStatus({ status: 'managed by CAPsMAN 02:F8:E3:80:10:97, traffic processing on CAP' })!; + expect(s.controllerMac).toBe('02:F8:E3:80:10:97'); + expect(s.controllerInterface).toBeNull(); + expect(s.ssid).toBeNull(); + }); + + it('returns null for a locally configured interface', () => { + expect(parseCapsmanStatus({ name: 'wifi1', 'configuration.ssid': 'TestNet' })).toBeNull(); + }); +}); + +describe('parseCapStatus', () => { + it('extracts the CAP identity from the controller side', () => { + const r = parseCapStatus({ status: 'operated by CAP 02:F8:E1:50:13:5F%management_vlan, traffic processing on CAP' })!; + expect(r.capMac).toBe('02:F8:E1:50:13:5F'); + expect(r.capInterface).toBe('management_vlan'); + }); + + it('returns null when the radio is local', () => { + expect(parseCapStatus({ interface: 'wifi1' })).toBeNull(); + }); +}); + +describe('isCapsmanManaged', () => { + it('trusts an explicit status line whatever the role', () => { + const row = { status: 'managed by CAPsMAN 02:F8:E3:80:10:97%mgmt, mode: AP' }; + expect(isCapsmanManaged(row, 'standalone')).toBe(true); + }); + + it('treats a CAP interface with no local SSID as provisioned', () => { + expect(isCapsmanManaged({ name: 'wifi1' }, 'cap')).toBe(true); + }); + + // A CAP may still carry locally-configured interfaces alongside provisioned + // ones, so the role alone must not be the test. + it('leaves a locally configured interface on a CAP alone', () => { + expect(isCapsmanManaged({ name: 'wifi1', 'configuration.ssid': 'Local' }, 'cap')).toBe(false); + }); + + it('never claims a standalone AP is managed', () => { + expect(isCapsmanManaged({ name: 'wifi1' }, 'standalone')).toBe(false); + }); +}); + +describe('normalizeRadios', () => { + // Real rows from a RouterOS 7.23 wAP ax. + const rows = [ + { '.id': '*1', 'radio-mac': 'd0:ea:11:0a:de:78', interface: 'wifi1', local: 'true', 'hw-type': 'QCA5018' }, + { '.id': '*2', 'radio-mac': 'D0:EA:11:0A:DE:79', interface: 'wifi2', local: 'true', 'hw-type': 'QCN6122' }, + ]; + + it('upper-cases MACs so lookups are case-stable', () => { + expect(normalizeRadios(rows).map((r) => r.radioMac)) + .toEqual(['D0:EA:11:0A:DE:78', 'D0:EA:11:0A:DE:79']); + }); + + it('reads the local flag', () => { + expect(normalizeRadios(rows).every((r) => r.local)).toBe(true); + expect(normalizeRadios([{ 'radio-mac': 'AA:BB:CC:DD:EE:FF', local: 'false' }])[0].local).toBe(false); + }); +}); + +describe('matchRadiosToDevices', () => { + const index = new Map([['AA:BB:CC:00:00:01', 7]]); + + it('attributes a local radio to the controller itself', () => { + const radios = normalizeRadios([{ 'radio-mac': 'FF:FF:FF:00:00:09', local: 'true' }]); + expect(matchRadiosToDevices(radios, index, 3)[0].deviceId).toBe(3); + }); + + it('attributes a remote radio to the managed device owning that MAC', () => { + const radios = normalizeRadios([{ 'radio-mac': 'aa:bb:cc:00:00:01', local: 'false' }]); + expect(matchRadiosToDevices(radios, index, 3)[0].deviceId).toBe(7); + }); + + it('leaves an unmanaged CAP unattributed rather than guessing', () => { + const radios = normalizeRadios([{ 'radio-mac': '11:22:33:44:55:66', local: 'false' }]); + expect(matchRadiosToDevices(radios, index, 3)[0].deviceId).toBeNull(); + }); +}); + +describe('lookupDeviceForMac', () => { + it('matches exactly when the MAC is known', () => { + const idx = new Map([['AA:BB:CC:00:00:01', 5]]); + expect(lookupDeviceForMac('aa:bb:cc:00:00:01', idx)).toBe(5); + }); + + /** + * A radio MAC commonly differs from the device's interface MAC in the final + * octet, so an exact-only lookup would leave real CAPs unattributed. + */ + 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])); + expect(lookupDeviceForMac('AA:BB:CC:00:00:07', idx)).toBe(5); + }); + + 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])); + expect(lookupDeviceForMac('AA:BB:CC:99:99:99', idx)).toBeNull(); + }); + + it('returns null for a missing MAC', () => { + expect(lookupDeviceForMac(null, new Map())).toBeNull(); + }); +}); diff --git a/backend/src/services/mikrotik/capsman.ts b/backend/src/services/mikrotik/capsman.ts new file mode 100644 index 0000000..d69e744 --- /dev/null +++ b/backend/src/services/mikrotik/capsman.ts @@ -0,0 +1,226 @@ +/** + * CAPsMAN awareness. + * + * When a MikroTik AP is managed by CAPsMAN, its own configuration is not local: + * `/interface/wifi/print` returns rows with the `configuration.*` and `security.*` + * fields simply absent, because the controller owns them. Code that reads those + * fields therefore stores nulls, and the UI renders an access point with no SSID, + * no security and no band — looking broken rather than delegated + * (github.com/2GT-Media-Group-LLC/mikrotik-manager/issues/94). + * + * What RouterOS does expose, verified on 7.23: + * + * /interface/wifi/capsman/print → { enabled } is this device a controller + * /interface/wifi/cap/print → { enabled } is this device a CAP + * /interface/wifi/radio/print → radio-mac, local, interface, hw-type, … + * + * `radio-mac` is the useful part. A controller lists **every** radio it manages + * through `/interface/wifi/radio`, remote ones included, each with its MAC. Since + * interface MACs are already collected for every managed device, CAPs can be joined + * to devices directly on that MAC — no neighbour traversal, no resolving addresses + * within a segment, and none of the cross-segment ambiguity that made topology + * unreliable (see services/topology/buildTopology.ts). + * + * Everything here is pure so it can be tested without a CAPsMAN deployment, which + * matters because we do not have one. + */ + +export type WifiRole = 'none' | 'standalone' | 'cap' | 'controller' | 'controller_cap'; + +export interface CapsmanStatus { + /** MAC of the controller managing this radio, when it could be read. */ + controllerMac: string | null; + /** Interface or VLAN the CAP reaches its controller over, e.g. `management_vlan`. */ + controllerInterface: string | null; + /** SSID the controller provisioned, when RouterOS reports it in the status text. */ + ssid: string | null; + /** Raw channel spec, e.g. `5280/ax/eCee`. */ + channel: string | null; + mode: string | null; +} + +const isYes = (v: string | undefined): boolean => v === 'yes' || v === 'true'; + +/** + * Classify a device from the two feature toggles. Both can be on: MikroTik + * explicitly supports a controller that also runs its own radios, and the reporter + * of #94 calls that case out, so it gets its own role rather than being folded into + * one of the others. + */ +export function classifyWifiRole( + capsman: Record[] | null, + cap: Record[] | null, + hasWifiInterfaces: boolean +): WifiRole { + const isController = !!capsman?.some((r) => isYes(r['enabled'])); + const isCap = !!cap?.some((r) => isYes(r['enabled'])); + + if (isController && isCap) return 'controller_cap'; + if (isController) return 'controller'; + if (isCap) return 'cap'; + return hasWifiInterfaces ? 'standalone' : 'none'; +} + +/** + * Pull what we can out of RouterOS's human-readable CAPsMAN status line, e.g. + * + * managed by CAPsMAN 02:F8:E3:80:10:97%management_vlan, traffic processing on CAP, + * mode: AP, SSID: HomeAccessPoint, channel: 5280/ax/eCee + * + * Every value on the row is scanned rather than a specific field being read, + * because which field carries this text is not documented and has moved between + * releases. Searching is cheap and survives it moving again. + */ +/** + * Pull a `%` reference out of a status line, given the phrase that + * introduces it. + * + * Deliberately not one combined regular expression: the pattern needed to express + * "literal, whitespace, MAC, optional %interface" trips ReDoS linters, and this + * input is device-supplied text. Slicing at fixed offsets is linear by construction + * and needs no reasoning about backtracking. + */ +function parseNodeRef(text: string, marker: RegExp): { mac: string | null; iface: string | null } { + const m = marker.exec(text); + if (!m) return { mac: null, iface: null }; + + const rest = text.slice(m.index + m[0].length).trimStart(); + const mac = rest.slice(0, 17); + if (!/^[0-9A-Fa-f:]{17}$/.test(mac)) return { mac: null, iface: null }; + + let iface: string | null = null; + if (rest[17] === '%') { + const tail = rest.slice(18); + const end = tail.search(/[,\s]/); + iface = (end === -1 ? tail : tail.slice(0, end)) || null; + } + return { mac: mac.toUpperCase(), iface }; +} + +/** Value of a `Key: value` pair up to the next comma. */ +function labelled(text: string, label: string): string | null { + const i = text.toLowerCase().indexOf(`${label.toLowerCase()}:`); + if (i === -1) return null; + const after = text.slice(i + label.length + 1); + const end = after.indexOf(','); + return (end === -1 ? after : after.slice(0, end)).trim() || null; +} + +/** + * Pull what we can out of RouterOS's human-readable CAPsMAN status line, e.g. + * + * managed by CAPsMAN 02:F8:E3:80:10:97%management_vlan, traffic processing on CAP, + * mode: AP, SSID: HomeAccessPoint, channel: 5280/ax/eCee + * + * Every value on the row is scanned rather than a specific field being read, + * because which field carries this text is not documented and has moved between + * releases. Searching is cheap and survives it moving again. + */ +export function parseCapsmanStatus(row: Record): CapsmanStatus | null { + const text = Object.values(row).find( + (v) => typeof v === 'string' && /managed by CAPsMAN/i.test(v) + ); + if (!text) return null; + + const ref = parseNodeRef(text, /managed by CAPsMAN/i); + return { + controllerMac: ref.mac, + controllerInterface: ref.iface, + ssid: labelled(text, 'SSID'), + channel: labelled(text, 'channel'), + mode: labelled(text, 'mode'), + }; +} + +/** + * The controller-side counterpart: `operated by CAP %`. Used to + * label radios on a controller that physically live on a remote AP. + */ +export function parseCapStatus(row: Record): { capMac: string | null; capInterface: string | null } | null { + const text = Object.values(row).find( + (v) => typeof v === 'string' && /operated by CAP/i.test(v) + ); + if (!text) return null; + const ref = parseNodeRef(text, /operated by CAP/i); + return { capMac: ref.mac, capInterface: ref.iface }; +} + +/** + * True when this wifi interface's configuration is owned by a controller. + * + * The device's role is the reliable signal; the absence of a local SSID confirms + * it per-interface. A CAP can still hold locally-configured interfaces alongside + * provisioned ones, so this is not simply "the device is a CAP". + */ +export function isCapsmanManaged(row: Record, role: WifiRole): boolean { + if (parseCapsmanStatus(row)) return true; + if (role !== 'cap' && role !== 'controller_cap') return false; + // Provisioned interfaces carry no local configuration. + return !row['configuration.ssid'] && !row['ssid']; +} + +export interface RadioRow { + radioMac: string | null; + interfaceName: string | null; + local: boolean; + hwType: string | null; + currentChannel: string | null; + raw: Record; +} + +/** Normalise `/interface/wifi/radio/print` rows. */ +export function normalizeRadios(rows: Record[]): RadioRow[] { + return rows.map((r) => ({ + radioMac: (r['radio-mac'] || '').toUpperCase() || null, + interfaceName: r['interface'] || null, + local: isYes(r['local']), + hwType: r['hw-type'] || null, + currentChannel: r['current-channels'] || r['current-channel'] || null, + raw: r, + })); +} + +/** + * Attribute each radio a controller reports to a managed device, by MAC. + * + * `macToDevice` should hold every interface MAC known across the fleet. Matching is + * on hardware identity alone: a MAC is globally unique, so unlike an address it + * cannot mean two different devices on two different segments. + */ +export function matchRadiosToDevices( + radios: RadioRow[], + macToDevice: Map, + controllerDeviceId: number +): { radio: RadioRow; deviceId: number | null }[] { + return radios.map((radio) => { + if (radio.local) return { radio, deviceId: controllerDeviceId }; + const hit = radio.radioMac ? macToDevice.get(radio.radioMac.toUpperCase()) : undefined; + return { radio, deviceId: hit ?? null }; + }); +} + +/** + * A radio MAC is usually the interface MAC with the low bits of the last octet + * varying per radio, so an exact lookup can miss by one. Callers build the index + * with this so a near-miss still resolves, while keeping the OUI and the first five + * octets exact — enough to stay unambiguous within a fleet. + */ +export function macIndexKeys(mac: string): string[] { + const norm = mac.toUpperCase(); + const parts = norm.split(':'); + if (parts.length !== 6) return [norm]; + return [norm, parts.slice(0, 5).join(':')]; +} + +/** Look up a device for a radio MAC, falling back to the five-octet prefix. */ +export function lookupDeviceForMac( + mac: string | null, + index: Map +): number | null { + if (!mac) return null; + for (const key of macIndexKeys(mac)) { + const hit = index.get(key); + if (hit !== undefined) return hit; + } + return null; +} diff --git a/frontend/package.json b/frontend/package.json index ecc494c..0019149 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.21.0-beta", + "version": "0.22.0-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/device-detail/RadiosTab.tsx b/frontend/src/components/device-detail/RadiosTab.tsx index 6f1465b..8cf797f 100644 --- a/frontend/src/components/device-detail/RadiosTab.tsx +++ b/frontend/src/components/device-detail/RadiosTab.tsx @@ -1034,6 +1034,14 @@ export default function RadiosTab({ deviceId, deviceStatus }: Props) { {iface.ssid} )} + {iface.managed_by_capsman && ( + + CAPsMAN + + )}
+ {iface.managed_by_capsman && ( +
+

+ Provisioned by CAPsMAN + {iface.capsman_controller_mac && ( + <> from controller {iface.capsman_controller_mac} + )} + . This access point holds no local configuration for the interface, so any + blank fields below live on the controller — edit them there. +

+
+ )} {isVirtual && iface.config_json?.['master-interface'] && (

Virtual AP — shares radio with {iface.config_json['master-interface']} diff --git a/frontend/src/components/wireless/CapsmanPanel.tsx b/frontend/src/components/wireless/CapsmanPanel.tsx new file mode 100644 index 0000000..4ca794a --- /dev/null +++ b/frontend/src/components/wireless/CapsmanPanel.tsx @@ -0,0 +1,219 @@ +import { useQuery } from '@tanstack/react-query'; +import { Link } from 'react-router-dom'; +import { Radio, RefreshCw, Server, Wifi, AlertTriangle, ChevronDown, ChevronRight } from 'lucide-react'; +import { useState } from 'react'; +import { wirelessApi, type CapsmanController } from '../../services/api'; +import clsx from 'clsx'; + +/** + * CAPsMAN controllers and the access points they provision. + * + * Read-only by design. A CAPsMAN configuration is applied to every AP bound to it, + * so an edit here would change the whole wireless fleet at once — that needs the + * same prediction-and-revert treatment device changes get, and is held back until + * it does (issue #94). + */ +function Controller({ c }: { c: CapsmanController }) { + const [open, setOpen] = useState(true); + const unmanaged = c.access_points.filter((ap) => ap.device_id == null); + const radioCount = c.access_points.reduce((n, ap) => n + ap.radios.length, 0); + + return ( +

+ + + {open && ( +
+ {unmanaged.length > 0 && ( +
+ +

+ {unmanaged.length} access point{unmanaged.length !== 1 ? 's are' : ' is'} provisioned by this + controller but not added to the fleet. Add {unmanaged.length !== 1 ? 'them' : 'it'} to see + clients, health and history. +

+
+ )} + + {/* Radios grouped by the AP they physically live on */} +
+
+ Access points +
+
+ {c.access_points.map((ap, i) => ( +
+
+ + {ap.device_id != null ? ( + + {ap.device_name} + + ) : ( + {ap.device_name} + )} + {ap.radios[0]?.local && ( + on controller + )} +
+
+ {ap.radios.map((r) => ( +
+ + {r.interface_name || r.radio_mac} + {r.hw_type && {r.hw_type}} + {r.current_channel && {r.current_channel}} +
+ ))} +
+
+ ))} +
+
+ + {/* Provisioned SSIDs actually broadcasting */} + {c.ssids.length > 0 && ( +
+
+ Provisioned SSIDs +
+
+ + + + {['Access point', 'Interface', 'SSID', 'Band', 'Clients', ''].map((h, i) => ( + + ))} + + + + {c.ssids.map((s, i) => ( + + + + + + + + + ))} + +
{h}
{s.device_name}{s.name}{s.ssid || '—'} + {s.band || '—'}{s.frequency ? ` · ${s.frequency}` : ''} + {s.registered_clients ?? 0} + + {s.disabled ? 'disabled' : 'active'} + +
+
+
+ )} + + {/* Configurations and the rules binding them to radios */} + {c.configurations.length > 0 && ( +
+
+ Configurations +
+
+ {c.configurations.map((cfg) => ( +
+
{cfg.name}
+
+ {[cfg.ssid && `SSID ${cfg.ssid}`, cfg.band, cfg.mode, cfg.security] + .filter(Boolean).join(' · ') || 'no details reported'} +
+
+ ))} +
+
+ )} + + {c.provisioning.length > 0 && ( +
+
+ Provisioning rules +
+
    + {c.provisioning.map((p) => ( +
  • + {p.action || 'rule'} + {p.master_configuration && <> → {p.master_configuration}} + {p.radio_mac && ({p.radio_mac})} + {p.disabled && · disabled} + {p.comment && — {p.comment}} +
  • + ))} +
+
+ )} +
+ )} +
+ ); +} + +export default function CapsmanPanel() { + const { data, isLoading } = useQuery({ + queryKey: ['capsman'], + queryFn: () => wirelessApi.capsman().then((r) => r.data), + refetchInterval: 60_000, + }); + + const controllers = data?.controllers ?? []; + // Nothing to show on a fleet with no controller — this panel should be invisible + // rather than an empty box everyone has to scroll past. + if (isLoading || controllers.length === 0) return null; + + return ( +
+
+
+ +
+

CAPsMAN

+ + {controllers.length} controller{controllers.length !== 1 ? 's' : ''} · read-only + +
+

+ These access points are provisioned centrally, so their configuration lives on the controller + rather than on the AP itself. Editing is intentionally not offered here yet: a CAPsMAN change + applies to every bound AP at once. +

+ {controllers.map((c) => )} +
+ ); +} diff --git a/frontend/src/pages/WirelessPage.tsx b/frontend/src/pages/WirelessPage.tsx index b56e40d..9ab9f4a 100644 --- a/frontend/src/pages/WirelessPage.tsx +++ b/frontend/src/pages/WirelessPage.tsx @@ -7,6 +7,7 @@ import { wirelessApi } from '../services/api'; import type { WirelessAP } from '../types'; import RfHealth from '../components/wireless/RfHealth'; import RogueApsCard from '../components/wireless/RogueApsCard'; +import CapsmanPanel from '../components/wireless/CapsmanPanel'; import clsx from 'clsx'; function timeAgo(ts: string | undefined): string { @@ -105,6 +106,8 @@ export default function WirelessPage() { {/* Rogue & neighbor AP detection (from stored AP scans) */} {aps.length > 0 && } + + {/* AP table */}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index a37dc51..4d4ee92 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -890,9 +890,78 @@ export const certApi = { }; // ─── Wireless ───────────────────────────────────────────────────────────────── +/** + * A CAPsMAN controller and the access points it provisions. Read-only: the + * configuration lives on the controller and pushing an edit would reach every bound + * AP at once, so that is deliberately not exposed yet (issue #94). + */ +export interface CapsmanRadio { + radio_mac: string; + interface_name: string | null; + local: boolean; + hw_type: string | null; + current_channel: string | null; + remote_cap_name: string | null; + matched_device_id: number | null; + matched_device_name: string | null; + matched_device_status: string | null; +} + +export interface CapsmanAccessPoint { + device_id: number | null; + device_name: string; + radios: CapsmanRadio[]; +} + +export interface CapsmanConfiguration { + name: string; + ssid: string | null; + mode: string | null; + band: string | null; + security: string | null; + authentication_types: string | null; +} + +export interface CapsmanProvisioning { + ros_id: string; + action: string | null; + master_configuration: string | null; + slave_configurations: string | null; + radio_mac: string | null; + comment: string | null; + disabled: boolean; +} + +export interface CapsmanSsid { + device_id: number; + device_name: string; + name: string; + ssid: string | null; + band: string | null; + frequency: number | null; + disabled: boolean; + running: boolean; + registered_clients: number | null; +} + +export interface CapsmanController { + id: number; + name: string; + ip_address: string; + model: string | null; + status: string; + wifi_role: string | null; + access_points: CapsmanAccessPoint[]; + configurations: CapsmanConfiguration[]; + provisioning: CapsmanProvisioning[]; + ssids: CapsmanSsid[]; +} + export const wirelessApi = { // Section overview list: () => api.get('/wireless'), + // CAPsMAN controllers with the APs they provision (read-only) + capsman: () => api.get<{ controllers: CapsmanController[] }>('/wireless/capsman'), // Multi-AP SSID deployment bulkCreateInterfaces: (data: { apIds: number[] } & Record) => diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 67b9b52..4084a66 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -334,6 +334,10 @@ export interface WirelessInterface { security_profile?: string; noise_floor?: number; registered_clients: number; + /** Configuration is owned by a CAPsMAN controller, not by this device. */ + managed_by_capsman?: boolean; + radio_mac?: string | null; + capsman_controller_mac?: string | null; config_json?: Record; updated_at?: string; } diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 25f5481..1b72642 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.21.0 Beta'; +export const APP_VERSION = 'v0.22.0 Beta';