feat(wireless+ports): RF Health panels + physical per-port clients (v0.16.1-beta)

Wireless RF Health (fleet Wireless page + per-AP Radios tab):
- Channel usage map across 2.4/5/6 GHz with co-channel overlap; operating
  channel resolved live from /interface/wifi/monitor on the wifi package
  (auto-channel radios report no frequency in /print).
- AP deployment density: client RSSI distribution on the -90..-30 dBm scale
  with a coverage-gap warning.
- AP radio TX retries histogram (0%->35%+) with band selector, derived from
  per-client CCQ (new wireless_radio_quality InfluxDB series).
- WiFi connectivity success funnel (Association/Auth/DHCP) derived from logs.

Per-port connected clients (Ports tab):
- New card lists clients physically connected to a selected port (host/MAC/
  IP/VLAN/connection time, linked to client pages).
- Distinguishes physical clients from the raw MAC table: uplink/trunk ports
  are auto-detected (topology neighbor, multi-VLAN MAC spread, or high MAC
  count) and show an uplink explainer + "view MAC table" disclosure instead
  of every reachable MAC.
- Added clients.first_seen ("connected since"); set once, preserved across polls.
This commit is contained in:
2GT-Rich
2026-06-25 15:37:08 -07:00
parent 1be161e67a
commit 4e6c9f481e
17 changed files with 994 additions and 35 deletions
+7 -1
View File
@@ -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. 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.16.0_Beta-blue) ![Version](https://img.shields.io/badge/version-0.16.1_Beta-blue)
![License](https://img.shields.io/badge/license-AGPLv3-blue) ![License](https://img.shields.io/badge/license-AGPLv3-blue)
![Docker](https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white) ![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) ![TypeScript](https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white)
@@ -134,6 +134,7 @@ A Meraki/UniFi-grade firewall experience built on the full RouterOS feature set:
- VLAN management (create, edit, delete VLANs) - VLAN management (create, edit, delete VLANs)
- Per-port configuration and VLAN membership - Per-port configuration and VLAN membership
- Switch overview with port status - Switch overview with port status
- **Per-port connected clients** — selecting a port shows the clients actually connected to it (hostname, MAC, IP, VLAN, connection time) with links to each client's page. Crucially, it distinguishes *physically connected* clients from the raw MAC table: **uplink/trunk ports are auto-detected** (via an LLDP/MNDP topology neighbor, MACs spanning multiple VLANs, or a high MAC count) and show an "uplink" explainer instead of every MAC reachable through the port — with a one-click disclosure to view the full MAC table when you need it
- **Copy VLANs from another switch** — 3-step wizard that copies VLAN IDs and names from any other managed switch onto the current device, with manual per-VLAN port assignment (tagged/untagged) using a click-to-cycle interface chip grid, conflict detection with per-VLAN skip/overwrite control, and a review summary before any changes are applied - **Copy VLANs from another switch** — 3-step wizard that copies VLAN IDs and names from any other managed switch onto the current device, with manual per-VLAN port assignment (tagged/untagged) using a click-to-cycle interface chip grid, conflict detection with per-VLAN skip/overwrite control, and a review summary before any changes are applied
### Wireless ### Wireless
@@ -145,6 +146,11 @@ A Meraki/UniFi-grade firewall experience built on the full RouterOS feature set:
- Scheduled and on-demand **AP scans** (nearby access point discovery) - Scheduled and on-demand **AP scans** (nearby access point discovery)
- Real-time radio monitoring - Real-time radio monitoring
- Wireless client tracking with vendor lookup - Wireless client tracking with vendor lookup
- **RF Health** (fleet-wide on the Wireless page and per-AP on the Radios tab):
- **Channel usage map** across 2.4 / 5 / 6 GHz, computed from each radio's operating channel (live-resolved from `monitor` on the wifi package when channels are auto), highlighting in-use channels and **co-channel overlap**
- **AP deployment density** — connected clients plotted across the −90…−30 dBm signal scale, with a coverage-gap warning when too many clients connect at weak RSSI
- **AP radio TX retries** histogram (0%→35%+) with a band selector, derived from per-client CCQ (legacy `wireless` driver)
- **WiFi connectivity success** funnel (Association / Authentication / DHCP) derived from device logs
### Network Services ### Network Services
Each service supports multi-device management with conflict detection: Each service supports multi-device management with conflict detection:
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mikrotik-manager-backend", "name": "mikrotik-manager-backend",
"version": "0.16.0-beta", "version": "0.16.1-beta",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mikrotik-manager-backend", "name": "mikrotik-manager-backend",
"version": "0.16.0-beta", "version": "0.16.1-beta",
"dependencies": { "dependencies": {
"@influxdata/influxdb-client": "^1.33.2", "@influxdata/influxdb-client": "^1.33.2",
"@influxdata/influxdb-client-apis": "^1.35.0", "@influxdata/influxdb-client-apis": "^1.35.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mikrotik-manager-backend", "name": "mikrotik-manager-backend",
"version": "0.16.0-beta", "version": "0.16.1-beta",
"description": "Mikrotik Network Management Platform - Backend API", "description": "Mikrotik Network Management Platform - Backend API",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
+3
View File
@@ -160,6 +160,9 @@ CREATE TABLE IF NOT EXISTS app_settings (
ALTER TABLE clients ADD COLUMN IF NOT EXISTS vendor VARCHAR(255); ALTER TABLE clients ADD COLUMN IF NOT EXISTS vendor VARCHAR(255);
ALTER TABLE clients ADD COLUMN IF NOT EXISTS vlan_id INTEGER; ALTER TABLE clients ADD COLUMN IF NOT EXISTS vlan_id INTEGER;
ALTER TABLE clients ADD COLUMN IF NOT EXISTS custom_name VARCHAR(255); ALTER TABLE clients ADD COLUMN IF NOT EXISTS custom_name VARCHAR(255);
-- first_seen: when this client was first discovered ("connected since"); set once, preserved across polls
ALTER TABLE clients ADD COLUMN IF NOT EXISTS first_seen TIMESTAMPTZ;
UPDATE clients SET first_seen = last_seen WHERE first_seen IS NULL;
ALTER TABLE events ADD COLUMN IF NOT EXISTS log_id VARCHAR(20); ALTER TABLE events ADD COLUMN IF NOT EXISTS log_id VARCHAR(20);
CREATE UNIQUE INDEX IF NOT EXISTS idx_events_device_log_id ON events(device_id, log_id); CREATE UNIQUE INDEX IF NOT EXISTS idx_events_device_log_id ON events(device_id, log_id);
ALTER TABLE topology_links ADD COLUMN IF NOT EXISTS neighbor_mac VARCHAR(17); ALTER TABLE topology_links ADD COLUMN IF NOT EXISTS neighbor_mac VARCHAR(17);
+60
View File
@@ -569,6 +569,66 @@ router.get('/:id/ports/:name/monitor', async (req: Request, res: Response) => {
} }
}); });
// GET /api/devices/:id/ports/:name/clients — clients on this port.
//
// The bridge FDB lists every MAC *reachable through* a port, so on an
// uplink/trunk it shows the whole network. We classify the port and, by
// default, only return clients that are genuinely *physically* connected
// (a single access VLAN, no infra neighbour, not an aggregation point).
// Pass ?all=true to get the raw FDB list regardless (the "view MAC table"
// disclosure in the UI).
const UPLINK_MAC_THRESHOLD = 8;
router.get('/:id/ports/:name/clients', async (req: Request, res: Response) => {
const all = req.query.all === 'true';
const rows = await query<{
mac_address: string; hostname: string | null; custom_name: string | null;
vendor: string | null; ip_address: string | null; client_type: string;
vlan_id: number | null; signal_strength: number | null;
first_seen: string | null; last_seen: string | null;
}>(`
SELECT mac_address, hostname, custom_name, vendor, ip_address, client_type,
vlan_id, signal_strength, first_seen, last_seen
FROM clients
WHERE device_id = $1 AND interface_name = $2 AND active = TRUE
ORDER BY COALESCE(NULLIF(custom_name, ''), hostname, mac_address) ASC
`, [req.params.id, req.params.name]);
// A discovered LLDP/MNDP neighbour on this interface means it links to
// another managed device → definitively an uplink.
const neighbor = await queryOne<{ neighbor_identity: string | null; neighbor_platform: string | null }>(
`SELECT neighbor_identity, neighbor_platform FROM topology_links
WHERE from_device_id = $1 AND from_interface = $2
ORDER BY (neighbor_identity IS NOT NULL) DESC LIMIT 1`,
[req.params.id, req.params.name]
);
const macCount = rows.length;
const vlanCount = new Set(rows.filter(r => r.vlan_id != null).map(r => r.vlan_id)).size;
let classification: 'access' | 'uplink' = 'access';
let reason = '';
if (neighbor) {
classification = 'uplink';
reason = `Links to ${neighbor.neighbor_identity || 'another device'}`;
} else if (vlanCount > 1) {
classification = 'uplink';
reason = `MACs seen across ${vlanCount} VLANs — carrying tagged/trunk traffic`;
} else if (macCount > UPLINK_MAC_THRESHOLD) {
classification = 'uplink';
reason = `${macCount} MAC addresses reachable through this port`;
}
res.json({
classification,
reason,
mac_count: macCount,
vlan_count: vlanCount,
neighbor: neighbor || null,
// Physically-connected view hides everything on an uplink; ?all=true returns the raw FDB.
clients: all || classification === 'access' ? rows : [],
});
});
// GET /api/devices/:id/ports (switch port layout with VLAN info) // GET /api/devices/:id/ports (switch port layout with VLAN info)
router.get('/:id/ports', async (req: Request, res: Response) => { router.get('/:id/ports', async (req: Request, res: Response) => {
const [ifaces, bridgePorts, vlans] = await Promise.all([ const [ifaces, bridgePorts, vlans] = await Promise.all([
+145
View File
@@ -701,4 +701,149 @@ router.get('/:id/ap-scan-history', async (req: Request, res: Response) => {
return res.json(scans); return res.json(scans);
}); });
// ─── RF Health (channel map, RSSI density, TX retries, connectivity) ──────────
// Optional ?deviceId= scopes any of the RF endpoints to a single AP.
function deviceScope(req: Request): number | null {
const raw = req.query.deviceId;
if (raw === undefined || raw === '') return null;
const id = parseInt(String(raw), 10);
return Number.isFinite(id) ? id : null;
}
// GET /api/wireless/rf/channels — active physical radios with band/frequency/width
router.get('/rf/channels', async (req: Request, res: Response) => {
const deviceId = deviceScope(req);
const rows = await query(`
SELECT wi.device_id, d.name AS device_name, wi.name, wi.ssid,
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'
AND wi.disabled = FALSE
AND wi.frequency IS NOT NULL AND wi.frequency > 0
AND (wi.config_json->>'master-interface') IS NULL
${deviceId ? 'AND wi.device_id = $1' : ''}
ORDER BY wi.frequency ASC, d.name ASC
`, deviceId ? [deviceId] : []);
res.json(rows);
});
// GET /api/wireless/rf/signals — active wireless clients' RSSI (for density view)
router.get('/rf/signals', async (req: Request, res: Response) => {
const deviceId = deviceScope(req);
const rows = await query(`
SELECT c.mac_address, c.signal_strength, c.device_id, d.name AS device_name,
c.custom_name, c.hostname
FROM clients c
JOIN devices d ON d.id = c.device_id
WHERE c.active = TRUE
AND c.client_type = 'wireless'
AND c.signal_strength IS NOT NULL
AND c.signal_strength < 0
${deviceId ? 'AND c.device_id = $1' : ''}
ORDER BY c.signal_strength DESC
`, deviceId ? [deviceId] : []);
res.json(rows);
});
// GET /api/wireless/rf/tx-quality?range=6h — latest per-radio TX retry % (from CCQ)
router.get('/rf/tx-quality', async (req: Request, res: Response) => {
const deviceId = deviceScope(req);
const ranges: Record<string, string> = { '1h': '1h', '3h': '3h', '6h': '6h', '12h': '12h', '24h': '24h' };
const fluxRange = ranges[String(req.query.range || '6h')] || '6h';
const { getQueryApi } = await import('../config/influxdb');
const queryApi = getQueryApi();
const bucket = process.env.INFLUXDB_BUCKET || 'mikrotik';
const deviceFilter = deviceId ? `|> filter(fn: (r) => r["device_id"] == "${deviceId}")` : '';
const flux = `
from(bucket: "${bucket}")
|> range(start: -${fluxRange})
|> filter(fn: (r) => r["_measurement"] == "wireless_radio_quality")
|> filter(fn: (r) => r["_field"] == "tx_retry_pct")
${deviceFilter}
|> group(columns: ["device_id", "device_name", "interface", "band"])
|> last()
`;
try {
const rows: { device_id: string; device_name?: string; interface: string; band: string; tx_retry_pct: number }[] = [];
await new Promise<void>((resolve, reject) => {
queryApi.queryRows(flux, {
next(row, tableMeta) {
const obj = tableMeta.toObject(row) as Record<string, unknown>;
rows.push({
device_id: String(obj['device_id'] || ''),
device_name: obj['device_name'] ? String(obj['device_name']) : undefined,
interface: String(obj['interface'] || ''),
band: String(obj['band'] || 'unknown'),
tx_retry_pct: obj['_value'] != null ? Number(obj['_value']) : 0,
});
},
error: reject,
complete: resolve,
});
});
return res.json(rows);
} catch (err) {
console.error('TX-quality InfluxDB error:', err);
return res.json([]);
}
});
// Classification of wireless/DHCP log lines into the connectivity funnel. These
// are best-effort regexes over RouterOS log messages; the panel is explicitly
// labelled as log-derived and approximate.
const RE_ASSOC_OK = /connected|associated/i;
const RE_ASSOC_FAIL = /reject|deauth|disassoc|connection lost|connect failed/i;
const RE_AUTH_FAIL = /key exchange|handshake|authentication failed|auth failed|eap|radius.*(timeout|fail)/i;
const RE_DHCP_OK = /assigned|bound|leased|offering/i;
const RE_DHCP_FAIL = /no.*address|declined|nak|offer.*fail|pool.*exhaust/i;
// GET /api/wireless/rf/connectivity?range=24h — Association/Auth/DHCP success funnel
router.get('/rf/connectivity', async (req: Request, res: Response) => {
const deviceId = deviceScope(req);
const intervals: Record<string, string> = { '1h': '1 hour', '6h': '6 hours', '24h': '24 hours', '7d': '7 days' };
const interval = intervals[String(req.query.range || '24h')] || '24 hours';
const rows = await query<{ topic: string | null; message: string }>(`
SELECT topic, message FROM events
WHERE event_time > NOW() - INTERVAL '${interval}'
${deviceId ? 'AND device_id = $1' : ''}
AND (topic ILIKE '%wireless%' OR topic ILIKE '%wifi%' OR topic ILIKE '%dhcp%')
`, deviceId ? [deviceId] : []);
let assocOk = 0, assocFail = 0, authFail = 0, dhcpOk = 0, dhcpFail = 0;
for (const r of rows) {
const topic = (r.topic || '').toLowerCase();
const msg = r.message || '';
const isDhcp = topic.includes('dhcp');
if (isDhcp) {
if (RE_DHCP_FAIL.test(msg)) dhcpFail++;
else if (RE_DHCP_OK.test(msg)) dhcpOk++;
} else {
// wireless / wifi
if (RE_AUTH_FAIL.test(msg)) authFail++;
else if (RE_ASSOC_FAIL.test(msg)) assocFail++;
else if (RE_ASSOC_OK.test(msg)) assocOk++;
}
}
const rate = (ok: number, fail: number): number | null =>
ok + fail > 0 ? Math.round((ok / (ok + fail)) * 1000) / 10 : null;
res.json({
range: String(req.query.range || '24h'),
log_derived: true,
stages: {
association: { success: assocOk, failure: assocFail, pct: rate(assocOk, assocFail) },
// a successful association implies it passed the auth handshake
authentication: { success: assocOk, failure: authFail, pct: rate(assocOk, authFail) },
dhcp: { success: dhcpOk, failure: dhcpFail, pct: rate(dhcpOk, dhcpFail) },
},
});
});
export default router; export default router;
@@ -30,6 +30,36 @@ function topologyNeighborOversized(
return value.length > limit ? { limit, len: value.length } : null; return value.length > limit ? { limit, len: value.length } : null;
} }
/** Normalise a RouterOS band string + frequency (MHz) to a coarse RF band tag. */
function rfBand(band: string | undefined, frequency: number): '2.4' | '5' | '6' | 'unknown' {
const b = (band || '').toLowerCase();
if (b.includes('6ghz') || (frequency >= 5925 && frequency <= 7125)) return '6';
if (b.includes('5ghz') || (frequency >= 4900 && frequency < 5925)) return '5';
if (b.includes('2ghz') || (frequency >= 2400 && frequency < 2500)) return '2.4';
return 'unknown';
}
/** A coarse RF band tag → the RouterOS-style band prefix we store for display. */
function bandPrefix(frequency: number): string | null {
const b = rfBand(undefined, frequency);
return b === '2.4' ? '2ghz' : b === '5' ? '5ghz' : b === '6' ? '6ghz' : null;
}
/**
* Parse the new wifi package's monitor "channel" string into frequency + width.
* Format: "<freq>/<phy>[/<control-positions>]" e.g. "2412/ax/Ce", "5180/ax/Ceee".
* The control-position letters encode width: 1→20, 2→40, 4→80, 8→160 MHz.
*/
function parseWifiMonitorChannel(channel: string): { frequency: number; width: string } | null {
if (!channel) return null;
const segs = channel.split('/');
const frequency = parseInt(segs[0], 10);
if (!frequency || isNaN(frequency)) return null;
const letters = (segs[2] || '').replace(/[^a-zA-Z]/g, '').length;
const width = letters >= 8 ? '160mhz' : letters >= 4 ? '80mhz' : letters >= 2 ? '40mhz' : '20mhz';
return { frequency, width };
}
export interface DeviceRow { export interface DeviceRow {
id: number; id: number;
name: string; name: string;
@@ -513,8 +543,8 @@ export class DeviceCollector {
const vlanId = entry?.vid ?? null; const vlanId = entry?.vid ?? null;
await query( await query(
`INSERT INTO clients (device_id, mac_address, hostname, ip_address, interface_name, vlan_id, tx_bytes, rx_bytes, signal_strength, client_type, active, last_seen) `INSERT INTO clients (device_id, mac_address, hostname, ip_address, interface_name, vlan_id, tx_bytes, rx_bytes, signal_strength, client_type, active, last_seen, first_seen)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,TRUE,NOW()) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,TRUE,NOW(),NOW())
ON CONFLICT (device_id, mac_address) DO UPDATE SET ON CONFLICT (device_id, mac_address) DO UPDATE SET
hostname=COALESCE($3, clients.hostname), hostname=COALESCE($3, clients.hostname),
ip_address=COALESCE(NULLIF($4,''), clients.ip_address), ip_address=COALESCE(NULLIF($4,''), clients.ip_address),
@@ -525,7 +555,8 @@ export class DeviceCollector {
signal_strength=$9, signal_strength=$9,
client_type=$10, client_type=$10,
active=TRUE, active=TRUE,
last_seen=NOW()`, last_seen=NOW(),
first_seen=COALESCE(clients.first_seen, NOW())`,
[ [
this.device.id, this.device.id,
mac, mac,
@@ -1269,12 +1300,35 @@ export class DeviceCollector {
const wlans = pkg === 'wifi' ? rawList.map(r => this.normalizeWifiInterface(r)) : rawList; const wlans = pkg === 'wifi' ? rawList.map(r => this.normalizeWifiInterface(r)) : rawList;
if (wlans.length === 0) return; if (wlans.length === 0) return;
// New wifi package: the configured channel is usually "auto", so
// /interface/wifi/print returns no frequency. Pull the live operating
// channel from monitor for each physical radio; virtual APs inherit their
// master's channel. (Legacy "wireless" print already includes frequency.)
const liveFreq: Record<string, number> = {};
const liveWidth: Record<string, string> = {};
if (pkg === 'wifi') {
const physical = wlans.filter(w => w['name'] && !w['master-interface']);
await Promise.all(physical.map(async (w) => {
const rname = w['name'];
try {
const mon = await this.client.execute('/interface/wifi/monitor', { '.id': rname, once: '' });
const parsed = parseWifiMonitorChannel(mon[0]?.['channel'] || '');
if (parsed) { liveFreq[rname] = parsed.frequency; liveWidth[rname] = parsed.width; }
} catch { /* ignore per-radio monitor failures */ }
}));
}
for (const wlan of wlans) { for (const wlan of wlans) {
const name = wlan['name']; const name = wlan['name'];
if (!name) continue; if (!name) continue;
const freq = parseInt(wlan['frequency'] || '0', 10); 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 txPow = parseInt(wlan['tx-power'] || '0', 10);
const gain = parseInt(wlan['antenna-gain'] || '0', 10); const gain = parseInt(wlan['antenna-gain'] || '0', 10);
const bandStr = wlan['band'] || (liveF ? bandPrefix(liveF) : null);
const widthStr = wlan['channel-width'] || liveW || null;
await query( await query(
`INSERT INTO wireless_interfaces `INSERT INTO wireless_interfaces
@@ -1291,9 +1345,9 @@ export class DeviceCollector {
this.device.id, name, this.device.id, name,
wlan['ssid'] || null, wlan['ssid'] || null,
wlan['mode'] || null, wlan['mode'] || null,
wlan['band'] || null, bandStr || null,
!isNaN(freq) && freq > 0 ? freq : null, !isNaN(freq) && freq > 0 ? freq : null,
wlan['channel-width'] || null, widthStr,
!isNaN(txPow) && txPow > 0 ? txPow : null, !isNaN(txPow) && txPow > 0 ? txPow : null,
wlan['tx-power-mode'] || null, wlan['tx-power-mode'] || null,
!isNaN(gain) ? gain : null, !isNaN(gain) ? gain : null,
@@ -1332,9 +1386,24 @@ export class DeviceCollector {
if (wlans.length === 0) return; if (wlans.length === 0) return;
const clientsByIface: Record<string, number> = {}; const clientsByIface: Record<string, number> = {};
// Per-radio transmit link quality. Legacy "wireless" registration tables
// expose tx-ccq (0-100, higher = better); we derive a retry % as 100-CCQ
// and average it per radio. The new "wifi" package omits CCQ, so this is
// simply left empty there (the quality panel shows a clean empty state).
const ccqSumByIface: Record<string, number> = {};
const ccqCountByIface: Record<string, number> = {};
for (const r of regTable) { for (const r of regTable) {
const iface = r['interface']; const iface = r['interface'];
if (iface) clientsByIface[iface] = (clientsByIface[iface] || 0) + 1; if (!iface) continue;
clientsByIface[iface] = (clientsByIface[iface] || 0) + 1;
const ccqRaw = r['tx-ccq'];
if (ccqRaw != null && ccqRaw !== '') {
const ccq = parseInt(ccqRaw, 10);
if (!isNaN(ccq)) {
ccqSumByIface[iface] = (ccqSumByIface[iface] || 0) + ccq;
ccqCountByIface[iface] = (ccqCountByIface[iface] || 0) + 1;
}
}
} }
const writeApi = getWriteApi(); const writeApi = getWriteApi();
@@ -1357,6 +1426,23 @@ export class DeviceCollector {
} }
writeApi.writePoint(point); writeApi.writePoint(point);
// Per-radio TX retry % time series (only when CCQ data is available)
const ccqCount = ccqCountByIface[name] || 0;
if (ccqCount > 0) {
const avgCcq = ccqSumByIface[name] / ccqCount;
const retryPct = Math.max(0, Math.min(100, 100 - avgCcq));
writeApi.writePoint(
new Point('wireless_radio_quality')
.tag('device_id', String(this.device.id))
.tag('device_name', this.device.name)
.tag('interface', name)
.tag('band', rfBand(wlan['band'], parseInt(wlan['frequency'] || '0', 10)))
.floatField('tx_retry_pct', Math.round(retryPct * 10) / 10)
.intField('avg_ccq', Math.round(avgCcq))
.timestamp(new Date())
);
}
await query( await query(
`UPDATE wireless_interfaces SET registered_clients=$1, updated_at=NOW() `UPDATE wireless_interfaces SET registered_clients=$1, updated_at=NOW()
WHERE device_id=$2 AND name=$3`, WHERE device_id=$2 AND name=$3`,
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mikrotik-manager-frontend", "name": "mikrotik-manager-frontend",
"version": "0.16.0-beta", "version": "0.16.1-beta",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mikrotik-manager-frontend", "name": "mikrotik-manager-frontend",
"version": "0.16.0-beta", "version": "0.16.1-beta",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.0",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mikrotik-manager-frontend", "name": "mikrotik-manager-frontend",
"version": "0.16.0-beta", "version": "0.16.1-beta",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -10,6 +10,7 @@ import {
} from 'recharts'; } from 'recharts';
import { wirelessApi } from '../../services/api'; import { wirelessApi } from '../../services/api';
import { useCanWrite } from '../../hooks/useCanWrite'; import { useCanWrite } from '../../hooks/useCanWrite';
import RfHealth from '../wireless/RfHealth';
import type { WirelessInterface, WirelessMetricPoint } from '../../types'; import type { WirelessInterface, WirelessMetricPoint } from '../../types';
import clsx from 'clsx'; import clsx from 'clsx';
import { format } from 'date-fns'; import { format } from 'date-fns';
@@ -1103,6 +1104,15 @@ export default function RadiosTab({ deviceId, deviceStatus }: Props) {
</div> </div>
</div> </div>
{/* RF Health — channel map, client density, TX retries, connectivity */}
<div>
<h2 className="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-2 mb-3">
<Signal className="w-4 h-4 text-blue-500" />
RF Health
</h2>
<RfHealth deviceId={deviceId} />
</div>
{/* Performance Charts */} {/* Performance Charts */}
<div> <div>
<h2 className="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-2 mb-3"> <h2 className="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-2 mb-3">
@@ -1,13 +1,15 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { RefreshCw, X, Check, AlertCircle, Activity, Cpu, Link2, Trash2, Plus, Network } from 'lucide-react'; import { RefreshCw, X, Check, AlertCircle, Activity, Cpu, Link2, Trash2, Plus, Network, Users, Wifi } from 'lucide-react';
import { devicesApi, metricsApi } from '../../services/api'; import { devicesApi, metricsApi } from '../../services/api';
import { useCanWrite } from '../../hooks/useCanWrite'; import { useCanWrite } from '../../hooks/useCanWrite';
import type { SwitchPort, Vlan, TrafficPoint, PortMonitorData } from '../../types'; import type { SwitchPort, Vlan, TrafficPoint, PortMonitorData, PortClient } from '../../types';
import { import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, Legend, ResponsiveContainer, Legend,
} from 'recharts'; } from 'recharts';
import { formatDistanceToNow } from 'date-fns';
import clsx from 'clsx'; import clsx from 'clsx';
interface Props { interface Props {
@@ -453,6 +455,136 @@ function PortInfoCard({ deviceId, portName }: { deviceId: number; portName: stri
); );
} }
// Table of client rows, reused for the connected list and the "view MAC table" disclosure.
function PortClientRows({ clients }: { clients: PortClient[] }) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/40">
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Host / Vendor</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">MAC</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">IP</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">VLAN</th>
<th className="px-4 py-2.5 text-right text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Connected</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-slate-700/50">
{clients.map((c, i) => {
const name = c.custom_name || c.hostname;
return (
<tr key={c.mac_address}
className={clsx('transition-colors hover:bg-blue-50/40 dark:hover:bg-slate-700/30',
i % 2 === 0 ? 'bg-white dark:bg-slate-900/20' : 'bg-gray-50 dark:bg-slate-800/40')}>
<td className="px-4 py-2.5">
<Link to={`/clients/${encodeURIComponent(c.mac_address)}`}
className="font-medium text-blue-600 dark:text-blue-400 hover:underline inline-flex items-center gap-1.5">
{c.client_type === 'wireless' && <Wifi className="w-3 h-3 flex-shrink-0" />}
{name || <span className="text-gray-400 dark:text-slate-500 italic">No hostname</span>}
</Link>
{c.vendor && <div className="text-xs text-gray-400 dark:text-slate-500 truncate">{c.vendor}</div>}
</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-600 dark:text-slate-400">{c.mac_address}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-600 dark:text-slate-400">{c.ip_address || '—'}</td>
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400">
{c.vlan_id != null
? <span className="px-1.5 py-0.5 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded font-mono">{c.vlan_id}</span>
: '—'}
</td>
<td className="px-4 py-2.5 text-right text-xs text-gray-500 dark:text-slate-400"
title={c.first_seen ? new Date(c.first_seen).toLocaleString() : undefined}>
{c.first_seen ? formatDistanceToNow(new Date(c.first_seen), { addSuffix: false }) : '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
// Clients on a single port. The bridge FDB lists every MAC reachable *through*
// a port, so on an uplink/trunk that's the whole network — not what's physically
// plugged in. We classify the port and only show genuinely-connected clients by
// default, with a disclosure to view the full MAC table.
function PortClientsCard({ deviceId, portName }: { deviceId: number; portName: string }) {
const [showMacTable, setShowMacTable] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ['port-clients', deviceId, portName],
queryFn: () => devicesApi.getPortClients(deviceId, portName).then((r) => r.data),
refetchInterval: 30_000,
});
const { data: macTable } = useQuery({
queryKey: ['port-clients-all', deviceId, portName],
queryFn: () => devicesApi.getPortClients(deviceId, portName, true).then((r) => r.data),
enabled: showMacTable,
});
const isUplink = data?.classification === 'uplink';
const connected = data?.clients ?? [];
return (
<div className="card overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
<Users className="w-4 h-4 text-blue-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
Connected Clients
<span className="ml-2 text-xs font-normal text-gray-400 dark:text-slate-500 font-mono">{portName}</span>
</h3>
{!isLoading && !isUplink && (
<span className="ml-auto text-xs text-gray-400 dark:text-slate-500">
{connected.length} device{connected.length !== 1 ? 's' : ''}
</span>
)}
</div>
{isLoading ? (
<div className="p-6 text-center text-sm text-gray-400 dark:text-slate-500">
<RefreshCw className="w-4 h-4 animate-spin inline mr-1" />Loading…
</div>
) : isUplink ? (
<div className="p-5 space-y-3">
<div className="flex items-start gap-2.5">
<Link2 className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" />
<div className="text-sm">
<div className="font-medium text-gray-800 dark:text-slate-200">This looks like an uplink / trunk port</div>
<p className="text-gray-500 dark:text-slate-400 mt-0.5">
{data?.reason}. The {data?.mac_count} MAC address{data?.mac_count !== 1 ? 'es' : ''} learned here are
reachable <em>through</em> this port, not physically connected to it.
</p>
</div>
</div>
<button
onClick={() => setShowMacTable(v => !v)}
className="text-xs font-medium text-blue-600 dark:text-blue-400 hover:underline"
>
{showMacTable ? 'Hide' : 'View'} MAC table ({data?.mac_count})
</button>
{showMacTable && (
macTable
? <div className="-mx-5 -mb-5 border-t border-gray-100 dark:border-slate-700/60">
<div className="px-5 py-2 text-xs text-gray-400 dark:text-slate-500 bg-gray-50 dark:bg-slate-800/40">
Reachable via this port — not necessarily physically connected.
</div>
<PortClientRows clients={macTable.clients} />
</div>
: <div className="text-xs text-gray-400"><RefreshCw className="w-3 h-3 animate-spin inline mr-1" />Loading…</div>
)}
</div>
) : connected.length === 0 ? (
<div className="p-6 text-center text-sm text-gray-400 dark:text-slate-500">
No clients currently connected to this port.
</div>
) : (
<PortClientRows clients={connected} />
)}
</div>
);
}
export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOpened }: Props) { export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOpened }: Props) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const canWrite = useCanWrite(); const canWrite = useCanWrite();
@@ -988,25 +1120,28 @@ export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOp
{selectedPorts.size === 1 && (() => { {selectedPorts.size === 1 && (() => {
const portName = Array.from(selectedPorts)[0]; const portName = Array.from(selectedPorts)[0];
return ( return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-stretch"> <div className="space-y-4">
<div className="md:col-span-1"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-stretch">
<PortTrafficGraph <div className="md:col-span-1">
deviceId={deviceId} <PortTrafficGraph
portName={portName} deviceId={deviceId}
range={trafficRange} portName={portName}
onRangeChange={setTrafficRange} range={trafficRange}
/> onRangeChange={setTrafficRange}
</div> />
<div className="md:col-span-1"> </div>
<PortPacketGraph <div className="md:col-span-1">
deviceId={deviceId} <PortPacketGraph
portName={portName} deviceId={deviceId}
range={trafficRange} portName={portName}
/> range={trafficRange}
</div> />
<div className="md:col-span-1"> </div>
<PortInfoCard deviceId={deviceId} portName={portName} /> <div className="md:col-span-1">
<PortInfoCard deviceId={deviceId} portName={portName} />
</div>
</div> </div>
<PortClientsCard deviceId={deviceId} portName={portName} />
</div> </div>
); );
})()} })()}
@@ -0,0 +1,330 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Radio, Signal, Activity, ShieldCheck, AlertTriangle } from 'lucide-react';
import clsx from 'clsx';
import { wirelessApi } from '../../services/api';
import type {
RfChannelRow, RfSignalRow, RfTxQualityRow, RfConnectivity,
} from '../../types';
import {
BAND_CHANNELS, BAND_LABEL, RfBand, channelForFreq, bandForFreq, widthMhz,
RSSI_ZONES, rssiColor, RETRY_BUCKETS, retryBucketIndex, retryColor,
} from '../../utils/wifiChannels';
const ALL_BANDS: RfBand[] = ['2.4', '5', '6'];
// ─── Channel Map ──────────────────────────────────────────────────────────────
interface CellInfo { count: number; labels: string[] }
function ChannelBandRow({ band, radios }: { band: RfBand; radios: RfChannelRow[] }) {
const channels = BAND_CHANNELS[band];
const cells: CellInfo[] = channels.map(() => ({ count: 0, labels: [] }));
for (const r of radios) {
const ch = channelForFreq(r.frequency);
if (ch == null) continue;
const idx = channels.indexOf(ch);
if (idx < 0) continue;
const span = Math.max(1, Math.round(widthMhz(r.channel_width) / 20));
const start = Math.max(0, idx - Math.floor((span - 1) / 2));
const end = Math.min(channels.length - 1, start + span - 1);
for (let i = start; i <= end; i++) cells[i].count++;
cells[idx].labels.push(
`${r.device_name}${r.ssid ? ` · ${r.ssid}` : ''} — ch ${ch}, ${widthMhz(r.channel_width)} MHz, ${r.registered_clients} clients`
);
}
const cellColor = (c: CellInfo) =>
c.count === 0 ? 'bg-gray-200 dark:bg-slate-700'
: c.count === 1 ? 'bg-green-500'
: 'bg-amber-500'; // co-channel overlap
return (
<div>
<div className="text-xs font-medium text-gray-500 dark:text-slate-400 mb-1">{BAND_LABEL[band]}</div>
<div className="flex gap-px">
{cells.map((c, i) => (
<div key={channels[i]}
title={c.labels.length ? c.labels.join('\n') : `Channel ${channels[i]} — unused`}
className={clsx('h-2.5 flex-1 rounded-sm transition-colors', cellColor(c))} />
))}
</div>
<div className="flex mt-1">
{channels.map(ch => (
<div key={ch} className="flex-1 text-center text-[9px] leading-none text-gray-400 dark:text-slate-500">{ch}</div>
))}
</div>
</div>
);
}
export function ChannelMap({ deviceId }: { deviceId?: number }) {
const { data: radios = [] } = useQuery({
queryKey: ['rf-channels', deviceId],
queryFn: () => wirelessApi.getChannelUsage(deviceId).then(r => r.data),
refetchInterval: 60_000,
});
const byBand = (b: RfBand) => radios.filter(r => bandForFreq(r.frequency) === b);
// 2.4 + 5 always; 6 only when present (avoids an always-empty 59-cell row)
const bands = ALL_BANDS.filter(b => b !== '6' || byBand('6').length > 0);
return (
<div className="card p-5">
<div className="flex items-center gap-2 mb-4">
<Radio className="w-4 h-4 text-blue-500" />
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">Channel Usage</h2>
<span className="ml-auto flex items-center gap-3 text-[11px] text-gray-400 dark:text-slate-500">
<span className="flex items-center gap-1"><span className="w-2.5 h-2.5 rounded-sm bg-green-500" />in use</span>
<span className="flex items-center gap-1"><span className="w-2.5 h-2.5 rounded-sm bg-amber-500" />overlap</span>
</span>
</div>
{radios.length === 0 ? (
<div className="py-6 text-center text-sm text-gray-400 dark:text-slate-500">
No active radios reporting a channel yet.
</div>
) : (
<div className="space-y-4">
{bands.map(b => <ChannelBandRow key={b} band={b} radios={byBand(b)} />)}
</div>
)}
</div>
);
}
// ─── RSSI Density ──────────────────────────────────────────────────────────────
const RSSI_MIN = -90;
const RSSI_MAX = -30;
const RSSI_TICKS = [-90, -80, -70, -60, -50, -40, -30];
export function RssiDensity({ deviceId }: { deviceId?: number }) {
const { data: signals = [] } = useQuery({
queryKey: ['rf-signals', deviceId],
queryFn: () => wirelessApi.getClientSignals(deviceId).then(r => r.data),
refetchInterval: 30_000,
});
const clamped = (dbm: number) => Math.max(RSSI_MIN, Math.min(RSSI_MAX, dbm));
const pos = (dbm: number) => ((clamped(dbm) - RSSI_MIN) / (RSSI_MAX - RSSI_MIN)) * 100;
// Bin clients into 2 dB bins so co-located clients form a sized bubble.
const bins = new Map<number, RfSignalRow[]>();
for (const s of signals) {
const bin = Math.round(clamped(s.signal_strength) / 2) * 2;
if (!bins.has(bin)) bins.set(bin, []);
bins.get(bin)!.push(s);
}
const weak = signals.filter(s => s.signal_strength < -75).length;
const weakRatio = signals.length ? weak / signals.length : 0;
const needsImprovement = signals.length >= 3 && weakRatio > 0.3;
return (
<div className="card p-5">
<div className="flex items-center gap-2 mb-4">
<Signal className="w-4 h-4 text-emerald-500" />
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">AP Deployment Density</h2>
<span className="ml-auto text-[11px] text-gray-400 dark:text-slate-500">
{signals.length} client{signals.length !== 1 ? 's' : ''} by signal (dBm)
</span>
</div>
{signals.length === 0 ? (
<div className="py-6 text-center text-sm text-gray-400 dark:text-slate-500">
No connected wireless clients to plot.
</div>
) : (
<>
{needsImprovement && (
<div className="flex items-start gap-2 mb-3 text-xs text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded-lg px-3 py-2">
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<span>AP deployment density may need improvement — {weak} of {signals.length} clients are connecting below −75 dBm, which suggests coverage gaps.</span>
</div>
)}
{/* Density track */}
<div className="relative h-16">
{/* gradient zone background */}
<div className="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full overflow-hidden flex">
{RSSI_ZONES.map(z => (
<div key={z.label} style={{
width: `${((Math.min(z.max, RSSI_MAX) - Math.max(z.min, RSSI_MIN)) / (RSSI_MAX - RSSI_MIN)) * 100}%`,
backgroundColor: z.color, opacity: 0.25,
}} />
))}
</div>
{/* client bubbles */}
{Array.from(bins.entries()).map(([bin, rows]) => {
const size = Math.min(34, 12 + (rows.length - 1) * 4);
return (
<div key={bin}
title={rows.map(r => `${r.custom_name || r.hostname || r.mac_address} (${r.signal_strength} dBm, ${r.device_name})`).join('\n')}
className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full flex items-center justify-center text-[10px] font-semibold text-white shadow ring-2 ring-white dark:ring-slate-800"
style={{ left: `${pos(bin)}%`, width: size, height: size, backgroundColor: rssiColor(bin) }}>
{rows.length > 1 ? rows.length : ''}
</div>
);
})}
</div>
{/* axis */}
<div className="relative h-4 mt-1">
{RSSI_TICKS.map(t => (
<span key={t} className="absolute -translate-x-1/2 text-[10px] text-gray-400 dark:text-slate-500"
style={{ left: `${pos(t)}%` }}>{t}</span>
))}
</div>
</>
)}
</div>
);
}
// ─── TX Retries histogram ───────────────────────────────────────────────────────
export function TxRetries({ deviceId }: { deviceId?: number }) {
const [band, setBand] = useState<RfBand>('5');
const { data: radios = [] } = useQuery({
queryKey: ['rf-tx-quality', deviceId],
queryFn: () => wirelessApi.getTxQuality(deviceId, '6h').then(r => r.data),
refetchInterval: 60_000,
});
const inBand = radios.filter((r: RfTxQualityRow) => r.band === band);
const counts = RETRY_BUCKETS.map(() => 0);
for (const r of inBand) counts[retryBucketIndex(r.tx_retry_pct)]++;
// UniFi orders worst → best (35%+ … 0%)
const ordered = RETRY_BUCKETS.map((b, i) => ({ ...b, count: counts[i] })).reverse();
return (
<div className="card p-5">
<div className="flex items-center gap-2 mb-4">
<Activity className="w-4 h-4 text-violet-500" />
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">AP Radio TX Retries</h2>
<div className="ml-auto flex gap-1">
{ALL_BANDS.map(b => (
<button key={b} onClick={() => setBand(b)}
className={clsx('px-2 py-0.5 rounded-md text-xs font-medium transition-colors',
band === b ? 'bg-blue-600 text-white' : 'text-gray-500 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-700')}>
{BAND_LABEL[b]}
</button>
))}
</div>
</div>
{radios.length === 0 ? (
<div className="py-6 text-center text-sm text-gray-400 dark:text-slate-500">
No TX-retry data. This is derived from per-client CCQ, which the legacy
<span className="font-mono"> wireless</span> driver reports; Wi-Fi 6/7 (<span className="font-mono">wifi</span>) radios don&apos;t expose it.
</div>
) : (
<>
<div className="flex gap-1">
{ordered.map(b => (
<div key={b.label} className="flex-1 text-center">
<div className="h-9 rounded-md flex items-center justify-center text-sm font-bold text-white"
style={{ backgroundColor: b.count > 0 ? retryColor((b.min + Math.min(b.max, 40)) / 2) : 'var(--color-border, #e5e7eb)' }}>
<span className={clsx(b.count === 0 && 'text-gray-400 dark:text-slate-600')}>{b.count || ''}</span>
</div>
<div className="text-[10px] text-gray-400 dark:text-slate-500 mt-1">{b.label}</div>
</div>
))}
</div>
<p className="text-[11px] text-gray-400 dark:text-slate-500 mt-3">
Radios bucketed by transmit-retry rate over the last 6h ({inBand.length} on {BAND_LABEL[band]}). Lower is better.
</p>
</>
)}
</div>
);
}
// ─── Connectivity Success funnel ────────────────────────────────────────────────
const STAGE_META: { key: keyof RfConnectivity['stages']; label: string }[] = [
{ key: 'association', label: 'Association' },
{ key: 'authentication', label: 'Authentication' },
{ key: 'dhcp', label: 'DHCP' },
];
export function ConnectivitySuccess({ deviceId }: { deviceId?: number }) {
const [range, setRange] = useState('24h');
const { data } = useQuery({
queryKey: ['rf-connectivity', deviceId, range],
queryFn: () => wirelessApi.getConnectivity(deviceId, range).then(r => r.data),
refetchInterval: 60_000,
});
const stages = data?.stages;
const totalEvents = stages
? STAGE_META.reduce((s, m) => s + stages[m.key].success + stages[m.key].failure, 0)
: 0;
return (
<div className="card p-5">
<div className="flex items-center gap-2 mb-4">
<ShieldCheck className="w-4 h-4 text-green-500" />
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">WiFi Connectivity Success</h2>
<div className="ml-auto flex gap-1">
{['1h', '24h', '7d'].map(r => (
<button key={r} onClick={() => setRange(r)}
className={clsx('px-2 py-0.5 rounded-md text-xs font-medium transition-colors',
range === r ? 'bg-blue-600 text-white' : 'text-gray-500 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-700')}>
{r}
</button>
))}
</div>
</div>
{!stages || totalEvents === 0 ? (
<div className="py-6 text-center text-sm text-gray-400 dark:text-slate-500">
No connectivity events in this window. This funnel is derived from device
logs — enable wireless &amp; DHCP logging on the APs to populate it.
</div>
) : (
<>
<div className="space-y-3">
{STAGE_META.map(({ key, label }) => {
const s = stages[key];
const pct = s.pct;
const color = pct == null ? '#94a3b8' : pct >= 98 ? '#22c55e' : pct >= 90 ? '#84cc16' : pct >= 75 ? '#f59e0b' : '#ef4444';
return (
<div key={key}>
<div className="flex items-center justify-between text-xs mb-1">
<span className="font-medium text-gray-600 dark:text-slate-300">{label}</span>
<span className="text-gray-400 dark:text-slate-500">
{pct == null ? '—' : `${pct}%`}
<span className="ml-2 text-[10px]">({s.success} ok / {s.failure} fail)</span>
</span>
</div>
<div className="h-2 rounded-full bg-gray-100 dark:bg-slate-700 overflow-hidden">
<div className="h-full rounded-full transition-all" style={{ width: `${pct ?? 0}%`, backgroundColor: color }} />
</div>
</div>
);
})}
</div>
<p className="text-[11px] text-gray-400 dark:text-slate-500 mt-3">
Approximate, derived from device logs. DNS success isn&apos;t observable on RouterOS and is omitted.
</p>
</>
)}
</div>
);
}
// ─── Combined section ─────────────────────────────────────────────────────────
export default function RfHealth({ deviceId }: { deviceId?: number }) {
return (
<div className="space-y-6">
<ChannelMap deviceId={deviceId} />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
<RssiDensity deviceId={deviceId} />
<TxRetries deviceId={deviceId} />
</div>
<ConnectivitySuccess deviceId={deviceId} />
</div>
);
}
+4
View File
@@ -5,6 +5,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { wirelessApi } from '../services/api'; import { wirelessApi } from '../services/api';
import type { WirelessAP } from '../types'; import type { WirelessAP } from '../types';
import RfHealth from '../components/wireless/RfHealth';
import clsx from 'clsx'; import clsx from 'clsx';
function timeAgo(ts: string | undefined): string { function timeAgo(ts: string | undefined): string {
@@ -97,6 +98,9 @@ export default function WirelessPage() {
))} ))}
</div> </div>
{/* RF Health — channel map, density, TX retries, connectivity */}
{aps.length > 0 && <RfHealth />}
{/* AP table */} {/* AP table */}
<div className="card overflow-hidden"> <div className="card overflow-hidden">
<div className="px-5 py-4 border-b border-gray-200 dark:border-slate-700"> <div className="px-5 py-4 border-b border-gray-200 dark:border-slate-700">
+12
View File
@@ -225,6 +225,8 @@ export const devicesApi = {
reboot: (id: number) => api.post<{ message: string }>(`/devices/${id}/reboot`), reboot: (id: number) => api.post<{ message: string }>(`/devices/${id}/reboot`),
getPortMonitor: (id: number, name: string) => getPortMonitor: (id: number, name: string) =>
api.get<PortMonitorData>(`/devices/${id}/ports/${encodeURIComponent(name)}/monitor`), api.get<PortMonitorData>(`/devices/${id}/ports/${encodeURIComponent(name)}/monitor`),
getPortClients: (id: number, name: string, all = false) =>
api.get<import('../types').PortClientsResult>(`/devices/${id}/ports/${encodeURIComponent(name)}/clients`, { params: { all: all || undefined } }),
createBond: (id: number, data: { createBond: (id: number, data: {
name: string; mode: string; slaves: string[]; name: string; mode: string; slaves: string[];
lacp_rate?: string; transmit_hash_policy?: string; mtu?: number; min_links?: number; lacp_rate?: string; transmit_hash_policy?: string; mtu?: number; min_links?: number;
@@ -721,6 +723,16 @@ export const wirelessApi = {
runAPScan: (apId: number) => api.post(`/wireless/${apId}/ap-scan`), runAPScan: (apId: number) => api.post(`/wireless/${apId}/ap-scan`),
getAPScanHistory: (apId: number, limit = 5) => getAPScanHistory: (apId: number, limit = 5) =>
api.get(`/wireless/${apId}/ap-scan-history`, { params: { limit } }), api.get(`/wireless/${apId}/ap-scan-history`, { params: { limit } }),
// RF Health (fleet-wide, or scoped to one AP via deviceId)
getChannelUsage: (deviceId?: number) =>
api.get<import('../types').RfChannelRow[]>('/wireless/rf/channels', { params: { deviceId } }),
getClientSignals: (deviceId?: number) =>
api.get<import('../types').RfSignalRow[]>('/wireless/rf/signals', { params: { deviceId } }),
getTxQuality: (deviceId?: number, range = '6h') =>
api.get<import('../types').RfTxQualityRow[]>('/wireless/rf/tx-quality', { params: { deviceId, range } }),
getConnectivity: (deviceId?: number, range = '24h') =>
api.get<import('../types').RfConnectivity>('/wireless/rf/connectivity', { params: { deviceId, range } }),
}; };
// ─── Network Services ───────────────────────────────────────────────────────── // ─── Network Services ─────────────────────────────────────────────────────────
+68
View File
@@ -118,6 +118,28 @@ export interface SwitchPort extends Interface {
bridgeInfo?: BridgeVlanEntry | null; bridgeInfo?: BridgeVlanEntry | null;
} }
export interface PortClient {
mac_address: string;
hostname: string | null;
custom_name: string | null;
vendor: string | null;
ip_address: string | null;
client_type: string;
vlan_id: number | null;
signal_strength: number | null;
first_seen: string | null;
last_seen: string | null;
}
export interface PortClientsResult {
classification: 'access' | 'uplink';
reason: string;
mac_count: number;
vlan_count: number;
neighbor: { neighbor_identity: string | null; neighbor_platform: string | null } | null;
clients: PortClient[];
}
export interface PortMonitorData { export interface PortMonitorData {
// Link state // Link state
status?: string; status?: string;
@@ -342,6 +364,52 @@ export interface WirelessMetricPoint {
noise_floor?: number; noise_floor?: number;
} }
// ─── RF Health ──────────────────────────────────────────────────────────────
export interface RfChannelRow {
device_id: number;
device_name: string;
name: string;
ssid?: string;
band?: string;
frequency: number;
channel_width?: string;
registered_clients: number;
}
export interface RfSignalRow {
mac_address: string;
signal_strength: number;
device_id: number;
device_name: string;
custom_name?: string | null;
hostname?: string | null;
}
export interface RfTxQualityRow {
device_id: string;
device_name?: string;
interface: string;
band: string;
tx_retry_pct: number;
}
export interface RfConnectivityStage {
success: number;
failure: number;
pct: number | null;
}
export interface RfConnectivity {
range: string;
log_derived: boolean;
stages: {
association: RfConnectivityStage;
authentication: RfConnectivityStage;
dhcp: RfConnectivityStage;
};
}
export interface ConfigSnapshotMeta { export interface ConfigSnapshotMeta {
id: number; id: number;
device_id: number; device_id: number;
+100
View File
@@ -0,0 +1,100 @@
// WiFi channel helpers for the RF Health panels.
//
// Maps RouterOS radio frequencies (MHz) to a band + channel number, supplies the
// canonical channel lists each band's channel map renders, and provides the
// bucketing used by the TX-retries histogram and the RSSI density scale.
export type RfBand = '2.4' | '5' | '6';
// Canonical channel scales shown on the channel map (mirrors what UniFi displays).
export const CHANNELS_24: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
export const CHANNELS_5: number[] = [
36, 40, 44, 48, 52, 56, 60, 64, 100, 104, 108, 112, 116, 120, 124, 128,
132, 136, 140, 144, 149, 153, 157, 161, 165,
];
// 6 GHz: channels 1..233 in steps of 4
export const CHANNELS_6: number[] = Array.from({ length: 59 }, (_, i) => 1 + i * 4);
export const BAND_CHANNELS: Record<RfBand, number[]> = {
'2.4': CHANNELS_24,
'5': CHANNELS_5,
'6': CHANNELS_6,
};
export const BAND_LABEL: Record<RfBand, string> = {
'2.4': '2.4 GHz',
'5': '5 GHz',
'6': '6 GHz',
};
/** Classify a frequency (MHz) into a coarse RF band, or null if unrecognized. */
export function bandForFreq(freq: number): RfBand | null {
if (freq >= 2400 && freq < 2500) return '2.4';
if (freq >= 4900 && freq < 5925) return '5';
if (freq >= 5925 && freq <= 7125) return '6';
return null;
}
/** Convert a frequency (MHz) to its channel number for the appropriate band. */
export function channelForFreq(freq: number): number | null {
const band = bandForFreq(freq);
if (band === '2.4') return freq === 2484 ? 14 : Math.round((freq - 2407) / 5);
if (band === '5') return Math.round((freq - 5000) / 5);
if (band === '6') return Math.round((freq - 5950) / 5);
return null;
}
/** Parse a RouterOS channel-width string to its width in MHz (best effort). */
export function widthMhz(width: string | undefined): number {
if (!width) return 20;
const m = width.match(/(\d+)/);
const n = m ? parseInt(m[1], 10) : 20;
// RouterOS strings like "20/40/80mhz" report the smallest first; take the max present.
const all = (width.match(/\d+/g) || []).map(Number).filter(v => v >= 20 && v <= 160);
return all.length ? Math.max(...all) : (n >= 20 ? n : 20);
}
// ─── RSSI density helpers ──────────────────────────────────────────────────
export interface RssiBand { label: string; min: number; max: number; color: string }
// Signal-quality zones across the −90…−30 dBm scale (weak → excellent).
export const RSSI_ZONES: RssiBand[] = [
{ label: 'Poor', min: -100, max: -80, color: '#ef4444' },
{ label: 'Fair', min: -80, max: -70, color: '#f59e0b' },
{ label: 'Good', min: -70, max: -60, color: '#84cc16' },
{ label: 'Excellent', min: -60, max: -20, color: '#22c55e' },
];
export function rssiColor(dbm: number): string {
for (const z of RSSI_ZONES) if (dbm >= z.min && dbm < z.max) return z.color;
return dbm >= -20 ? '#22c55e' : '#ef4444';
}
// ─── TX-retry histogram buckets (mirrors UniFi's 0%..35%+ scale) ─────────────
export interface RetryBucket { label: string; min: number; max: number }
export const RETRY_BUCKETS: RetryBucket[] = [
{ label: '0%', min: 0, max: 2.5 },
{ label: '5%', min: 2.5, max: 7.5 },
{ label: '10%', min: 7.5, max: 12.5 },
{ label: '15%', min: 12.5, max: 17.5 },
{ label: '20%', min: 17.5, max: 22.5 },
{ label: '25%', min: 22.5, max: 30 },
{ label: '35%+', min: 30, max: 101 },
];
export function retryBucketIndex(pct: number): number {
for (let i = 0; i < RETRY_BUCKETS.length; i++) {
if (pct >= RETRY_BUCKETS[i].min && pct < RETRY_BUCKETS[i].max) return i;
}
return RETRY_BUCKETS.length - 1;
}
/** Color for a retry %: green (low) → red (high). */
export function retryColor(pct: number): string {
if (pct < 5) return '#22c55e';
if (pct < 10) return '#84cc16';
if (pct < 20) return '#f59e0b';
return '#ef4444';
}
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = 'v0.16.0 Beta'; export const APP_VERSION = 'v0.16.1 Beta';