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 )}