feat(wireless): CAPsMAN awareness and read-only controller view (v0.22.0-beta)
Addresses #94, phases A and B. Write operations are deliberately out of scope. An access point provisioned by CAPsMAN holds none of its own wireless configuration, so /interface/wifi/print returns rows with the configuration.* and security.* fields absent. normalizeWifiInterface mapped nothing, stored nulls, and the UI rendered an AP with no SSID, security or band — indistinguishable from one that is simply broken. Phase A — stop showing blanks Devices are classified standalone / cap / controller / controller_cap from /interface/wifi/capsman and /interface/wifi/cap. Both toggles can be on: MikroTik supports a controller that also runs its own radios under itself, and the issue calls that case out, so it gets its own role. Provisioned interfaces are marked and labelled in the Radios tab with the controller's MAC and an explanation that the blank fields live on the controller. SSID, mode and channel are recovered from RouterOS's status line where present, so the AP is described rather than blank. Role is the signal, not missing data: an AP whose SSID is empty because the controller owns it looks identical to an unconfigured one, and only the toggle distinguishes them. A CAP can still hold locally-configured interfaces alongside provisioned ones, so per-interface state is checked rather than assuming the whole device. Phase B — read-only controller view Controllers now collect the radios they manage, named configurations, and provisioning rules, surfaced as a CAPsMAN panel on the Wireless page grouped by the access point each radio physically lives on. CAPs are matched to managed devices by radio MAC from the controller's own /interface/wifi/radio list, not by resolving addresses through neighbours as the issue proposed. A MAC is hardware identity and globally unique, so unlike an address it cannot mean two devices on two segments — the failure fixed in v0.20.1. A radio MAC often differs from the interface MAC in the final octet, so lookup falls back to a five-octet prefix while keeping the OUI exact. A CAP that is not a managed device is surfaced rather than hidden; that is usually the reason an AP appears to be missing from the fleet. The wireless section required device_type = 'wireless_ap' in five places, which hid a dedicated controller — normally classified as a router — from every wireless view. Collection was gated the same way. Both now include CAPsMAN roles, and the collector probes an unknown device once, records the role, and skips cheaply forever after. Not included Provisioning, config edits and interface enable/disable. A CAPsMAN configuration applies to every AP bound to it simultaneously, which is the fleet-wide version of the failure Change Guard exists to prevent. It needs the same prediction and revert treatment rather than being bolted on. Legacy /caps-man (pre-RouterOS 7) is a separate API tree and is not covered. Verified: API shapes probed against RouterOS 7.23 rather than taken from docs. 24 unit tests on the pure parsing and matching. No CAPsMAN hardware here, so the read path was exercised against a seeded controller with a local radio, a matched two-radio CAP and an unmanaged CAP; grouping, provisioning and SSID resolution all came out correct. Confirmed no regression on a fleet with no controller: roles detected correctly, no interface falsely flagged, wireless views unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
19617e8357
commit
14bab24c7e
@@ -1034,6 +1034,14 @@ export default function RadiosTab({ deviceId, deviceStatus }: Props) {
|
||||
{iface.ssid}
|
||||
</span>
|
||||
)}
|
||||
{iface.managed_by_capsman && (
|
||||
<span
|
||||
className="px-1.5 py-0.5 bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-400 border border-amber-200 dark:border-amber-700/50 rounded text-xs font-medium"
|
||||
title="Configuration is held by a CAPsMAN controller, not on this device"
|
||||
>
|
||||
CAPsMAN
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className={clsx(
|
||||
@@ -1055,6 +1063,18 @@ export default function RadiosTab({ deviceId, deviceStatus }: Props) {
|
||||
{/* Expandable body */}
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 pt-1 border-t border-gray-100 dark:border-slate-700/60">
|
||||
{iface.managed_by_capsman && (
|
||||
<div className="mb-3 rounded-lg border border-amber-200 dark:border-amber-700/50 bg-amber-50 dark:bg-amber-900/20 px-3 py-2">
|
||||
<p className="text-xs text-amber-800 dark:text-amber-300">
|
||||
Provisioned by CAPsMAN
|
||||
{iface.capsman_controller_mac && (
|
||||
<> from controller <span className="font-mono font-medium">{iface.capsman_controller_mac}</span></>
|
||||
)}
|
||||
. This access point holds no local configuration for the interface, so any
|
||||
blank fields below live on the controller — edit them there.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isVirtual && iface.config_json?.['master-interface'] && (
|
||||
<p className="text-xs text-purple-600 dark:text-purple-400 mb-3">
|
||||
Virtual AP — shares radio with <span className="font-mono font-medium">{iface.config_json['master-interface']}</span>
|
||||
|
||||
@@ -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 (
|
||||
<div className="card overflow-hidden">
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-left border-b border-gray-200 dark:border-slate-700"
|
||||
>
|
||||
{open ? <ChevronDown className="w-4 h-4 text-gray-400" /> : <ChevronRight className="w-4 h-4 text-gray-400" />}
|
||||
<Server className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Link
|
||||
to={`/devices/${c.id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-sm font-semibold text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
{c.name}
|
||||
</Link>
|
||||
<span className="mono text-[11px] text-gray-400">{c.ip_address}</span>
|
||||
{c.wifi_role === 'controller_cap' && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 font-medium">
|
||||
controller + local radios
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<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.configurations.length} configuration{c.configurations.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="p-4 space-y-4">
|
||||
{unmanaged.length > 0 && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-amber-300 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-3">
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0 text-amber-500" />
|
||||
<p className="text-sm text-amber-800 dark:text-amber-300">
|
||||
{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.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Radios grouped by the AP they physically live on */}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-slate-400 mb-2">
|
||||
Access points
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{c.access_points.map((ap, i) => (
|
||||
<div key={ap.device_id ?? `u${i}`} className="rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Wifi className="w-3.5 h-3.5 text-gray-400" />
|
||||
{ap.device_id != null ? (
|
||||
<Link to={`/devices/${ap.device_id}`} className="text-sm font-medium text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400">
|
||||
{ap.device_name}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-slate-400">{ap.device_name}</span>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
</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" />
|
||||
<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>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provisioned SSIDs actually broadcasting */}
|
||||
{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
|
||||
</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) => (
|
||||
<th key={i} className="table-header px-3 py-2 text-left">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700">
|
||||
{c.ssids.map((s, i) => (
|
||||
<tr key={`${s.device_id}:${s.name}:${i}`}>
|
||||
<td className="px-3 py-2 text-xs text-gray-700 dark:text-slate-200">{s.device_name}</td>
|
||||
<td className="px-3 py-2 mono text-xs text-gray-500 dark:text-slate-400">{s.name}</td>
|
||||
<td className="px-3 py-2 text-xs font-medium text-gray-900 dark:text-white">{s.ssid || '—'}</td>
|
||||
<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',
|
||||
s.disabled ? 'bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400'
|
||||
: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
|
||||
)}>
|
||||
{s.disabled ? 'disabled' : 'active'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Configurations and the rules binding them to radios */}
|
||||
{c.configurations.length > 0 && (
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-slate-400 mb-2">
|
||||
Configurations
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{c.configurations.map((cfg) => (
|
||||
<div key={cfg.name} className="rounded-lg border border-gray-200 dark:border-slate-700 px-3 py-2">
|
||||
<div className="text-xs font-medium text-gray-900 dark:text-white">{cfg.name}</div>
|
||||
<div className="text-[11px] text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
{[cfg.ssid && `SSID ${cfg.ssid}`, cfg.band, cfg.mode, cfg.security]
|
||||
.filter(Boolean).join(' · ') || 'no details reported'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{c.provisioning.length > 0 && (
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-slate-400 mb-2">
|
||||
Provisioning rules
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{c.provisioning.map((p) => (
|
||||
<li key={p.ros_id} className="text-[11px] text-gray-600 dark:text-slate-300">
|
||||
<span className="mono text-gray-400">{p.action || 'rule'}</span>
|
||||
{p.master_configuration && <> → <span className="font-medium">{p.master_configuration}</span></>}
|
||||
{p.radio_mac && <span className="mono text-gray-400"> ({p.radio_mac})</span>}
|
||||
{p.disabled && <span className="ml-1 text-gray-400">· disabled</span>}
|
||||
{p.comment && <span className="text-gray-400"> — {p.comment}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 bg-blue-50 dark:bg-blue-900/20 rounded-lg flex items-center justify-center">
|
||||
<Server className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">CAPsMAN</h3>
|
||||
<span className="text-[11px] text-gray-500 dark:text-slate-400">
|
||||
{controllers.length} controller{controllers.length !== 1 ? 's' : ''} · read-only
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400">
|
||||
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.
|
||||
</p>
|
||||
{controllers.map((c) => <Controller key={c.id} c={c} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 && <RogueApsCard />}
|
||||
|
||||
<CapsmanPanel />
|
||||
|
||||
{/* AP table */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-gray-200 dark:border-slate-700">
|
||||
|
||||
@@ -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<string, unknown>) =>
|
||||
|
||||
@@ -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<string, string>;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = 'v0.21.0 Beta';
|
||||
export const APP_VERSION = 'v0.22.0 Beta';
|
||||
|
||||
Reference in New Issue
Block a user