From e15556b601254d929e118c210c7d62162785b0dc Mon Sep 17 00:00:00 2001 From: 2GT-Rich <107219997+2GT-Rich@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:11:43 -0700 Subject: [PATCH] fix(wireless): CAPsMAN panel corrections from field testing (v0.22.1-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 2 +- backend/package.json | 2 +- backend/src/db/migrate.ts | 12 ++ backend/src/routes/wireless.ts | 13 ++- .../src/services/mikrotik/DeviceCollector.ts | 91 +++++++++++++-- .../mikrotik/__tests__/capsman.test.ts | 109 ++++++++++++++++++ backend/src/services/mikrotik/capsman.ts | 106 +++++++++++++++++ frontend/package.json | 2 +- .../src/components/wireless/CapsmanPanel.tsx | 17 ++- frontend/src/pages/WirelessSettingsPage.tsx | 23 +++- frontend/src/services/api.ts | 8 ++ frontend/src/version.ts | 2 +- 12 files changed, 362 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index e47474f..4737062 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.22.0_Beta-blue) +![Version](https://img.shields.io/badge/version-0.22.1_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) diff --git a/backend/package.json b/backend/package.json index b58ba68..f9d741d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.22.0-beta", + "version": "0.22.1-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 950510c..0607fa3 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -559,6 +559,18 @@ CREATE TABLE IF NOT EXISTS capsman_provisioning ( UNIQUE(controller_device_id, ros_id) ); +-- CAPsMAN radio live state (issue #94 follow-up). current-channels from +-- /interface/wifi/radio is the list of channels the radio *supports* — kilobytes of +-- text — not the one it is operating on. The operating channel, link state, peer +-- counts and TX power come from /interface/wifi/monitor instead. +ALTER TABLE capsman_radios ADD COLUMN IF NOT EXISTS state VARCHAR(32); +ALTER TABLE capsman_radios ADD COLUMN IF NOT EXISTS registered_peers INTEGER; +ALTER TABLE capsman_radios ADD COLUMN IF NOT EXISTS authorized_peers INTEGER; +ALTER TABLE capsman_radios ADD COLUMN IF NOT EXISTS tx_power INTEGER; +-- Stored channel is now the operating channel; drop the capability lists already +-- collected so no one is left looking at two kilobytes of text. +UPDATE capsman_radios SET current_channel = NULL WHERE length(current_channel) > 64; + -- 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 7f74052..5d55eb4 100644 --- a/backend/src/routes/wireless.ts +++ b/backend/src/routes/wireless.ts @@ -136,6 +136,7 @@ router.get('/capsman', async (_req: Request, res: Response) => { 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, + r.state, r.registered_peers, r.authorized_peers, r.tx_power, 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 @@ -194,9 +195,19 @@ router.get('/capsman', async (_req: Request, res: Response) => { cRadios.some((r) => r.matched_device_id === i.device_id) ); + // Peer counts come from the controller's own monitor output. The CAP's local + // registration table is empty whenever traffic is processed centrally, which + // is why every row previously read zero. + const peers = (rows: { registered_peers?: number | null }[]) => + rows.reduce((n, r) => n + (r.registered_peers ?? 0), 0); + return { ...c, - access_points: [...groups.values()], + client_count: peers(cRadios as { registered_peers?: number | null }[]), + access_points: [...groups.values()].map((g) => ({ + ...g, + client_count: peers(g.radios as { registered_peers?: number | null }[]), + })), configurations: byController(configs as { controller_device_id: number }[], c.id), provisioning: byController(provisioning as { controller_device_id: number }[], c.id), ssids, diff --git a/backend/src/services/mikrotik/DeviceCollector.ts b/backend/src/services/mikrotik/DeviceCollector.ts index 4f532c2..788ea9e 100644 --- a/backend/src/services/mikrotik/DeviceCollector.ts +++ b/backend/src/services/mikrotik/DeviceCollector.ts @@ -9,7 +9,8 @@ import { buildServerArpMap } from '../../utils/serverArp'; import { aggregateBridgeVlans, portVlanMembership, expandVlanIds } from '../../utils/vlan'; import { classifyWifiRole, isCapsmanManaged, parseCapsmanStatus, parseCapStatus, - normalizeRadios, lookupDeviceForMac, macIndexKeys, + normalizeRadios, lookupDeviceForMac, macIndexKeys, parseRadioMonitor, resolveDatapath, + clientsPerRadio, type WifiRole, } from './capsman'; import { BackupService } from '../BackupService'; @@ -1392,12 +1393,21 @@ export class DeviceCollector { return; } - const [radioRows, configRows, provRows] = await Promise.all([ + const [radioRows, configRows, provRows, ifaceRows, regRows] = 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), + // `detail` is required: master-interface, which links a virtual AP to its + // radio, is absent from the plain listing. + this.client.execute('/interface/wifi/print', { detail: '' }).catch(() => [] as Record[]), + this.client.execute('/interface/wifi/registration-table/print').catch(() => [] as Record[]), ]); + // Clients register on the virtual AP, not the physical radio, so monitor's + // registered-peers reads zero on a busy access point. Count from the + // registration table and attribute each entry to its owning radio instead. + const clientCounts = clientsPerRadio(ifaceRows, regRows); + // 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 }>( @@ -1414,11 +1424,28 @@ export class DeviceCollector { if (radioRows !== null) { const radios = normalizeRadios(radioRows); + + // `current-channels` from /interface/wifi/radio lists every channel the + // radio *supports* — kilobytes of text on a multi-band radio — not the one + // it is on. Monitor reports the operating channel, and it carries the peer + // counts too, which under CAPsMAN only the controller knows: the CAP's own + // registration table is empty when traffic is processed centrally. + const live = new Map>(); + await Promise.all(radios.map(async (radio) => { + const iface = radio.interfaceName; + if (!iface) return; + const mon = await this.client + .execute('/interface/wifi/monitor', { '.id': iface, once: '' }) + .catch(() => [] as Record[]); + live.set(iface, parseRadioMonitor(mon[0])); + })); + const seen: string[] = []; for (const radio of radios) { if (!radio.radioMac) continue; seen.push(radio.radioMac); const capInfo = parseCapStatus(radio.raw); + const st = radio.interfaceName ? live.get(radio.interfaceName) : undefined; const matched = radio.local ? this.device.id : lookupDeviceForMac(radio.radioMac, macIndex); @@ -1426,16 +1453,22 @@ export class DeviceCollector { 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()) + current_channel, remote_cap_name, matched_device_id, config_json, + state, registered_peers, authorized_peers, tx_power, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,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()`, + remote_cap_name=$7, matched_device_id=$8, config_json=$9, + state=$10, registered_peers=$11, authorized_peers=$12, tx_power=$13, + updated_at=NOW()`, [ this.device.id, radio.radioMac, radio.interfaceName, radio.local, - radio.hwType, radio.currentChannel, + radio.hwType, st?.channel ?? null, capInfo?.capMac ?? radio.raw['name'] ?? null, matched, JSON.stringify(radio.raw), + st?.state ?? null, + clientCounts.get(radio.radioMac) ?? st?.registeredPeers ?? null, + st?.authorizedPeers ?? null, st?.txPower ?? null, ] ); } @@ -1946,11 +1979,14 @@ export class DeviceCollector { const pkg = await this.detectWifiPackage(); if (pkg === 'none') return []; - const [rawIfaces, bridgePorts] = await Promise.all([ + const [rawIfaces, bridgePorts, datapaths] = await Promise.all([ pkg === 'wifi' ? this.client.execute('/interface/wifi/print').catch(() => [] as Record[]) : this.client.execute('/interface/wireless/print', { detail: '' }).catch(() => [] as Record[]), this.getBridgePorts(), + pkg === 'wifi' + ? this.client.execute('/interface/wifi/datapath/print', { detail: '' }).catch(() => [] as Record[]) + : Promise.resolve([] as Record[]), ]); const rawNormalized = pkg === 'wifi' @@ -1977,9 +2013,21 @@ export class DeviceCollector { // Enrich each interface with its bridge port membership (if any) const portByIface = new Map(bridgePorts.map(p => [p['interface'], p])); - return ifaces.map(iface => { + return ifaces.map((iface, i) => { const port = portByIface.get(iface['name']); - if (!port) return iface; + if (!port) { + // A CAPsMAN-provisioned interface is not a local bridge port, so the port + // table has nothing for it and the UI would claim it has no network at all. + // The bridge and VLAN it actually lands on come from the datapath. + const dp = resolveDatapath(rawIfaces[i] ?? iface, datapaths); + if (!dp.bridge && !dp.vlanId) return iface; + return { + ...iface, + ...(dp.bridge ? { bridge: dp.bridge } : {}), + ...(dp.vlanId ? { 'bridge-pvid': dp.vlanId } : {}), + network_source: 'capsman-datapath', + }; + } return { ...iface, bridge: port['bridge'] || '', @@ -2383,9 +2431,32 @@ export class DeviceCollector { const raw = pkg === 'wifi' ? await this.client.execute('/interface/wifi/print').catch(() => [] as Record[]) : await this.client.execute('/interface/wireless/print').catch(() => [] as Record[]); - const physicals = raw.filter(r => !r['master-interface'] && r['name']); + let physicals = raw.filter(r => !r['master-interface'] && r['name']); if (physicals.length === 0) throw new Error('No physical radios found to attach the guest SSID to'); + // A CAPsMAN-provisioned radio is owned by the controller. Adding a local + // virtual AP to it does not reliably take effect and can be wiped or + // contradicted the next time the controller provisions the CAP — so skip + // those radios and say so, rather than reporting success for something that + // silently will not work. Raised by the reporter of issue #94. + const role = await this.detectWifiRole(); + const managedRadios = physicals.filter(r => isCapsmanManaged(r, role)); + if (managedRadios.length > 0) { + physicals = physicals.filter(r => !isCapsmanManaged(r, role)); + const names = managedRadios.map(r => r['name']).join(', '); + if (physicals.length === 0) { + throw new Error( + `Every radio on this device (${names}) is provisioned by CAPsMAN, so a guest SSID ` + + `created here would be overwritten by the controller. Create the guest network on the ` + + `CAPsMAN controller instead, so it is provisioned to the access points.` + ); + } + warnings.push( + `Skipped ${names}: provisioned by CAPsMAN, so a locally created SSID would be ` + + `overwritten by the controller. Add it to the controller's configuration instead.` + ); + } + const existingBySsid = raw.filter(r => (r['ssid'] || r['configuration.ssid'] || '') === ssid.ssid && r['master-interface']); if (existingBySsid.length > 0) { diff --git a/backend/src/services/mikrotik/__tests__/capsman.test.ts b/backend/src/services/mikrotik/__tests__/capsman.test.ts index 8025716..ece3f81 100644 --- a/backend/src/services/mikrotik/__tests__/capsman.test.ts +++ b/backend/src/services/mikrotik/__tests__/capsman.test.ts @@ -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[] = [ + { '.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[] = [ + { 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[] = [ + { 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); + }); +}); diff --git a/backend/src/services/mikrotik/capsman.ts b/backend/src/services/mikrotik/capsman.ts index d69e744..a8fe370 100644 --- a/backend/src/services/mikrotik/capsman.ts +++ b/backend/src/services/mikrotik/capsman.ts @@ -224,3 +224,109 @@ export function lookupDeviceForMac( } return null; } + +export interface RadioLiveState { + state: string | null; + /** Operating channel, e.g. `5500/ax/Ceee/D`. */ + channel: string | null; + registeredPeers: number | null; + authorizedPeers: number | null; + txPower: number | null; +} + +/** + * Read live radio state from a `/interface/wifi/monitor ... once` row. + * + * This exists because `/interface/wifi/radio`'s `current-channels` is the list of + * channels the radio *supports* — kilobytes of text on a multi-band radio — not the + * channel it is using. Monitor reports the operating channel, and it also carries + * the peer counts, which under CAPsMAN the controller knows and the CAP does not. + */ +export function parseRadioMonitor(row: Record | undefined): RadioLiveState { + const num = (v: string | undefined): number | null => { + if (v == null || v === '') return null; + const n = parseInt(v, 10); + return isNaN(n) ? null : n; + }; + return { + state: row?.['state'] || null, + channel: row?.['channel'] || null, + registeredPeers: num(row?.['registered-peers']), + authorizedPeers: num(row?.['authorized-peers']), + txPower: num(row?.['tx-power']), + }; +} + +/** + * Resolve which bridge and VLAN a wifi interface lands on. + * + * A CAPsMAN-provisioned interface is not a local bridge port, so looking it up in + * the AP's own bridge port table finds nothing and the UI ends up claiming the + * interface has no network at all. The answer lives in the datapath — either inline + * on the interface row or in a named datapath the interface references. + */ +export function resolveDatapath( + iface: Record, + datapaths: Record[] +): { bridge: string | null; vlanId: string | null } { + const inlineBridge = iface['datapath.bridge']; + const inlineVlan = iface['datapath.vlan-id']; + if (inlineBridge || inlineVlan) { + return { bridge: inlineBridge || null, vlanId: inlineVlan || null }; + } + + const name = iface['datapath']; + if (name && !name.startsWith('*')) { + const dp = datapaths.find((d) => d['name'] === name); + if (dp) return { bridge: dp['bridge'] || null, vlanId: dp['vlan-id'] || null }; + } + // `.id` reference rather than a name. + if (name) { + const dp = datapaths.find((d) => d['.id'] === name); + if (dp) return { bridge: dp['bridge'] || null, vlanId: dp['vlan-id'] || null }; + } + return { bridge: null, vlanId: null }; +} + +/** + * Count registered clients per radio. + * + * Clients do not register on the physical radio — they register on the virtual AP + * carrying the SSID. `/interface/wifi/monitor` on a physical radio therefore reports + * zero even when the access point is busy, and `/interface/wifi/radio` lists only the + * physical radios. Measured on a wAP ax with ten clients: wifi1 and wifi2 reported + * `registered-peers=0`, while wifi3–wifi6 held all ten. + * + * So the registration table is the source of truth, and each entry is attributed to + * its radio by following `master-interface` up to the interface that owns a + * `radio-mac`. Requires `/interface/wifi/print detail` — the field is absent without it. + */ +export function clientsPerRadio( + interfaces: Record[], + registrations: Record[] +): Map { + const byName = new Map(interfaces.filter((i) => i['name']).map((i) => [i['name'], i])); + + /** Walk up to the interface that owns a radio, guarding against a cyclic chain. */ + const radioOf = (name: string): string | null => { + let cur = byName.get(name); + for (let hops = 0; cur && hops < 8; hops++) { + const mac = cur['radio-mac']; + if (mac) return mac.toUpperCase(); + const parent = cur['master-interface']; + if (!parent || parent === cur['name']) return null; + cur = byName.get(parent); + } + return null; + }; + + const counts = new Map(); + for (const reg of registrations) { + const iface = reg['interface']; + if (!iface) continue; + const mac = radioOf(iface); + if (!mac) continue; + counts.set(mac, (counts.get(mac) ?? 0) + 1); + } + return counts; +} diff --git a/frontend/package.json b/frontend/package.json index 0019149..969894f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.22.0-beta", + "version": "0.22.1-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/wireless/CapsmanPanel.tsx b/frontend/src/components/wireless/CapsmanPanel.tsx index 4ca794a..23c5bbf 100644 --- a/frontend/src/components/wireless/CapsmanPanel.tsx +++ b/frontend/src/components/wireless/CapsmanPanel.tsx @@ -45,6 +45,7 @@ function Controller({ c }: { c: CapsmanController }) {
{c.access_points.length} access point{c.access_points.length !== 1 ? 's' : ''} ·{' '} {radioCount} radio{radioCount !== 1 ? 's' : ''} ·{' '} + {c.client_count} client{c.client_count !== 1 ? 's' : ''} ·{' '} {c.configurations.length} configuration{c.configurations.length !== 1 ? 's' : ''}
@@ -83,14 +84,21 @@ function Controller({ c }: { c: CapsmanController }) { {ap.radios[0]?.local && ( on controller )} + + {ap.client_count} client{ap.client_count !== 1 ? 's' : ''} +
{ap.radios.map((r) => (
- + {r.interface_name || r.radio_mac} - {r.hw_type && {r.hw_type}} {r.current_channel && {r.current_channel}} + {r.registered_peers != null && ( + {r.registered_peers} client{r.registered_peers !== 1 ? 's' : ''} + )} + {r.tx_power != null && {r.tx_power} dBm} + {r.hw_type && {r.hw_type}}
))}
@@ -103,13 +111,13 @@ function Controller({ c }: { c: CapsmanController }) { {c.ssids.length > 0 && (
- Provisioned SSIDs + Provisioned interfaces
- {['Access point', 'Interface', 'SSID', 'Band', 'Clients', ''].map((h, i) => ( + {['Access point', 'Interface', 'SSID', 'Band', ''].map((h, i) => ( ))} @@ -123,7 +131,6 @@ function Controller({ c }: { c: CapsmanController }) { - diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 4d4ee92..d983b99 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -900,7 +900,12 @@ export interface CapsmanRadio { interface_name: string | null; local: boolean; hw_type: string | null; + /** Operating channel from /interface/wifi/monitor, not the supported-channel list. */ current_channel: string | null; + state: string | null; + registered_peers: number | null; + authorized_peers: number | null; + tx_power: number | null; remote_cap_name: string | null; matched_device_id: number | null; matched_device_name: string | null; @@ -911,6 +916,8 @@ export interface CapsmanAccessPoint { device_id: number | null; device_name: string; radios: CapsmanRadio[]; + /** Clients as the controller sees them; a CAP's own table is empty under central forwarding. */ + client_count: number; } export interface CapsmanConfiguration { @@ -951,6 +958,7 @@ export interface CapsmanController { model: string | null; status: string; wifi_role: string | null; + client_count: number; access_points: CapsmanAccessPoint[]; configurations: CapsmanConfiguration[]; provisioning: CapsmanProvisioning[]; diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 1b72642..32bc3ba 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.22.0 Beta'; +export const APP_VERSION = 'v0.22.1 Beta';
{h}
{s.band || '—'}{s.frequency ? ` · ${s.frequency}` : ''} {s.registered_clients ?? 0}

- SSIDs / Wireless Interfaces + Wireless Interfaces

{iface.bridge - ? {iface.bridge}{iface['bridge-pvid'] ? VLAN {iface['bridge-pvid']} : ''} - : no network} + ? ( + + {iface.bridge} + {iface['bridge-pvid'] ? VLAN {iface['bridge-pvid']} : ''} + {iface['network_source'] === 'capsman-datapath' && (datapath)} + + ) + : iface['managed_by_capsman'] + ? via controller + : no network} {iface.band || '—'} @@ -997,11 +1010,11 @@ export default function WirelessSettingsPage() { ) : iface.running === 'true' ? ( - Serving Clients + Running ) : ( - Enabled + Enabled, not running )}