fix(wireless): CAPsMAN panel corrections from field testing (v0.22.1-beta)

All five points raised on #94 after the reporter tested v0.22.0 against a live
CAPsMAN deployment. Two were regressions in that release.

Operating channel instead of a capability list (mine)
  current_channel was stored from /interface/wifi/radio's current-channels, which
  is every channel the radio supports rather than the one it is using. Measured on
  a wAP ax: 535 characters on the 2.4 GHz radio and 3037 on the 5 GHz one — the
  "two kilobytes of text" reported. /interface/wifi/monitor gives the operating
  channel (5500/ax/Ceeeeeee/D), and carries state, peer counts and TX power too,
  all now shown on the radio chip.

Client counts that were zero everywhere (mine)
  The panel read registered_clients from the CAP's own wireless_interfaces rows,
  which are empty when traffic is processed centrally by the controller.

  Monitor's registered-peers is not the fix either, and testing caught that before
  it shipped: clients register on the virtual AP carrying the SSID, not on the
  physical radio, so monitor on a physical radio reports zero on a busy access
  point. On the test AP, wifi1 and wifi2 both read 0 while wifi3-wifi6 held all ten
  clients.

  Counts now come from the registration table, with each entry attributed to its
  radio by following master-interface up to the interface owning a radio-mac. That
  field requires /interface/wifi/print detail — it is absent from the plain
  listing. Verified against real hardware: ten registrations, attributed 8 and 2
  across the two radios, none dropped.

"no network" on remote interfaces
  The Network column resolved from the AP's own bridge ports. A CAPsMAN-provisioned
  interface is not a local bridge port, so nothing matched and the UI asserted the
  interface had no network at all. It now falls back to the datapath — inline
  fields or a named/.id reference — and labels the source. Where the datapath names
  no bridge it says "via controller" rather than claiming no network.

Status labels
  "Serving Clients" claimed something the underlying field does not say: running
  means the radio is up, not that clients are connected. Now "Running" and
  "Enabled, not running".

Table title
  "SSIDs / Wireless Interfaces" conflated two different things; the table lists
  interfaces, virtual and physical. Now "Wireless Interfaces".

Guest WiFi on CAPsMAN-provisioned radios
  Raised by the reporter as an aside, and it was a real hazard already shipping.
  setupGuestNetwork created virtual APs locally on whatever physical radios it
  found, with no awareness that a controller might own them — the SSID would be
  ignored or overwritten on the next provision while the UI reported success.
  Provisioned radios are now skipped with an explanation, and if every radio is
  provisioned the setup is refused, pointing at the controller instead.

37 unit tests on the CAPsMAN module. No regression on a fleet without a
controller: local bridge-port resolution unchanged, roles still correct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-08-20 11:11:43 -07:00
co-authored by Claude Opus 5
parent 14bab24c7e
commit e15556b601
12 changed files with 362 additions and 25 deletions
@@ -45,6 +45,7 @@ function Controller({ c }: { c: CapsmanController }) {
<div className="text-[11px] text-gray-500 dark:text-slate-400 mt-0.5">
{c.access_points.length} access point{c.access_points.length !== 1 ? 's' : ''} ·{' '}
{radioCount} radio{radioCount !== 1 ? 's' : ''} ·{' '}
{c.client_count} client{c.client_count !== 1 ? 's' : ''} ·{' '}
{c.configurations.length} configuration{c.configurations.length !== 1 ? 's' : ''}
</div>
</div>
@@ -83,14 +84,21 @@ function Controller({ c }: { c: CapsmanController }) {
{ap.radios[0]?.local && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300">on controller</span>
)}
<span className="ml-auto text-[11px] text-gray-500 dark:text-slate-400">
{ap.client_count} client{ap.client_count !== 1 ? 's' : ''}
</span>
</div>
<div className="mt-2 flex flex-wrap gap-2">
{ap.radios.map((r) => (
<div key={r.radio_mac} className="flex items-center gap-1.5 text-[11px] rounded px-2 py-1 bg-gray-50 dark:bg-slate-800">
<Radio className="w-3 h-3 text-gray-400" />
<Radio className={clsx('w-3 h-3', r.state === 'running' ? 'text-green-500' : 'text-gray-400')} />
<span className="font-medium text-gray-700 dark:text-slate-200">{r.interface_name || r.radio_mac}</span>
{r.hw_type && <span className="text-gray-400">{r.hw_type}</span>}
{r.current_channel && <span className="mono text-gray-500 dark:text-slate-400">{r.current_channel}</span>}
{r.registered_peers != null && (
<span className="text-gray-500 dark:text-slate-400">{r.registered_peers} client{r.registered_peers !== 1 ? 's' : ''}</span>
)}
{r.tx_power != null && <span className="text-gray-400">{r.tx_power} dBm</span>}
{r.hw_type && <span className="text-gray-400 hidden sm:inline">{r.hw_type}</span>}
</div>
))}
</div>
@@ -103,13 +111,13 @@ function Controller({ c }: { c: CapsmanController }) {
{c.ssids.length > 0 && (
<div>
<div className="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-slate-400 mb-2">
Provisioned SSIDs
Provisioned interfaces
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-slate-700">
{['Access point', 'Interface', 'SSID', 'Band', 'Clients', ''].map((h, i) => (
{['Access point', 'Interface', 'SSID', 'Band', ''].map((h, i) => (
<th key={i} className="table-header px-3 py-2 text-left">{h}</th>
))}
</tr>
@@ -123,7 +131,6 @@ function Controller({ c }: { c: CapsmanController }) {
<td className="px-3 py-2 mono text-xs text-gray-500 dark:text-slate-400">
{s.band || '—'}{s.frequency ? ` · ${s.frequency}` : ''}
</td>
<td className="px-3 py-2 mono num-tab text-xs text-gray-600 dark:text-slate-300">{s.registered_clients ?? 0}</td>
<td className="px-3 py-2">
<span className={clsx(
'text-[10px] px-1.5 py-0.5 rounded font-medium',
+18 -5
View File
@@ -916,7 +916,7 @@ export default function WirelessSettingsPage() {
<div className="card overflow-hidden">
<div className="px-5 py-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
SSIDs / Wireless Interfaces
Wireless Interfaces
</h2>
<div className="flex items-center gap-2">
<button
@@ -975,8 +975,21 @@ export default function WirelessSettingsPage() {
</td>
<td className="px-4 py-3 text-xs text-gray-600 dark:text-slate-400">
{iface.bridge
? <span className="font-mono">{iface.bridge}{iface['bridge-pvid'] ? <span className="text-gray-400"> VLAN {iface['bridge-pvid']}</span> : ''}</span>
: <span className="text-amber-500 font-medium">no network</span>}
? (
<span
className="font-mono"
title={iface['network_source'] === 'capsman-datapath'
? 'From the CAPsMAN datapath, not a local bridge port'
: undefined}
>
{iface.bridge}
{iface['bridge-pvid'] ? <span className="text-gray-400"> VLAN {iface['bridge-pvid']}</span> : ''}
{iface['network_source'] === 'capsman-datapath' && <span className="text-gray-400"> (datapath)</span>}
</span>
)
: iface['managed_by_capsman']
? <span className="text-gray-400" title="This interface is provisioned by CAPsMAN and its datapath does not name a bridge">via controller</span>
: <span className="text-amber-500 font-medium">no network</span>}
</td>
<td className="px-4 py-3 text-xs text-gray-600 dark:text-slate-400">
{iface.band || '—'}
@@ -997,11 +1010,11 @@ export default function WirelessSettingsPage() {
</span>
) : iface.running === 'true' ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400">
<CheckCircle className="w-3 h-3" />Serving Clients
<CheckCircle className="w-3 h-3" />Running
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400">
<Power className="w-3 h-3" />Enabled
<Power className="w-3 h-3" />Enabled, not running
</span>
)}
</td>
+8
View File
@@ -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[];
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = 'v0.22.0 Beta';
export const APP_VERSION = 'v0.22.1 Beta';