diff --git a/README.md b/README.md index 7d00e2e..bc388a7 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.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) ![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) @@ -134,6 +134,7 @@ A Meraki/UniFi-grade firewall experience built on the full RouterOS feature set: - VLAN management (create, edit, delete VLANs) - Per-port configuration and VLAN membership - 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 ### 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) - Real-time radio monitoring - 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 Each service supports multi-device management with conflict detection: diff --git a/backend/package-lock.json b/backend/package-lock.json index 311726d..a74572e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mikrotik-manager-backend", - "version": "0.16.0-beta", + "version": "0.16.1-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mikrotik-manager-backend", - "version": "0.16.0-beta", + "version": "0.16.1-beta", "dependencies": { "@influxdata/influxdb-client": "^1.33.2", "@influxdata/influxdb-client-apis": "^1.35.0", diff --git a/backend/package.json b/backend/package.json index f1a995f..a44ef30 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.16.0-beta", + "version": "0.16.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 31a22c8..9605f96 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -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 vlan_id INTEGER; 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); 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); diff --git a/backend/src/routes/devices.ts b/backend/src/routes/devices.ts index 1302b64..d7b889e 100644 --- a/backend/src/routes/devices.ts +++ b/backend/src/routes/devices.ts @@ -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) router.get('/:id/ports', async (req: Request, res: Response) => { const [ifaces, bridgePorts, vlans] = await Promise.all([ diff --git a/backend/src/routes/wireless.ts b/backend/src/routes/wireless.ts index 09ff4cd..7105aa6 100644 --- a/backend/src/routes/wireless.ts +++ b/backend/src/routes/wireless.ts @@ -701,4 +701,149 @@ router.get('/:id/ap-scan-history', async (req: Request, res: Response) => { 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 = { '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((resolve, reject) => { + queryApi.queryRows(flux, { + next(row, tableMeta) { + const obj = tableMeta.toObject(row) as Record; + 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 = { '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; diff --git a/backend/src/services/mikrotik/DeviceCollector.ts b/backend/src/services/mikrotik/DeviceCollector.ts index 6e3a530..68907eb 100644 --- a/backend/src/services/mikrotik/DeviceCollector.ts +++ b/backend/src/services/mikrotik/DeviceCollector.ts @@ -30,6 +30,36 @@ function topologyNeighborOversized( 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: "/[/]" 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 { id: number; name: string; @@ -513,8 +543,8 @@ export class DeviceCollector { const vlanId = entry?.vid ?? null; 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) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,TRUE,NOW()) + `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(),NOW()) ON CONFLICT (device_id, mac_address) DO UPDATE SET hostname=COALESCE($3, clients.hostname), ip_address=COALESCE(NULLIF($4,''), clients.ip_address), @@ -525,7 +555,8 @@ export class DeviceCollector { signal_strength=$9, client_type=$10, active=TRUE, - last_seen=NOW()`, + last_seen=NOW(), + first_seen=COALESCE(clients.first_seen, NOW())`, [ this.device.id, mac, @@ -1269,12 +1300,35 @@ export class DeviceCollector { const wlans = pkg === 'wifi' ? rawList.map(r => this.normalizeWifiInterface(r)) : rawList; 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 = {}; + const liveWidth: Record = {}; + 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) { const name = wlan['name']; 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 gain = parseInt(wlan['antenna-gain'] || '0', 10); + const bandStr = wlan['band'] || (liveF ? bandPrefix(liveF) : null); + const widthStr = wlan['channel-width'] || liveW || null; await query( `INSERT INTO wireless_interfaces @@ -1291,9 +1345,9 @@ export class DeviceCollector { this.device.id, name, wlan['ssid'] || null, wlan['mode'] || null, - wlan['band'] || null, + bandStr || null, !isNaN(freq) && freq > 0 ? freq : null, - wlan['channel-width'] || null, + widthStr, !isNaN(txPow) && txPow > 0 ? txPow : null, wlan['tx-power-mode'] || null, !isNaN(gain) ? gain : null, @@ -1332,9 +1386,24 @@ export class DeviceCollector { if (wlans.length === 0) return; const clientsByIface: Record = {}; + // 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 = {}; + const ccqCountByIface: Record = {}; for (const r of regTable) { 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(); @@ -1357,6 +1426,23 @@ export class DeviceCollector { } 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( `UPDATE wireless_interfaces SET registered_clients=$1, updated_at=NOW() WHERE device_id=$2 AND name=$3`, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9816143..22d54f5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mikrotik-manager-frontend", - "version": "0.16.0-beta", + "version": "0.16.1-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mikrotik-manager-frontend", - "version": "0.16.0-beta", + "version": "0.16.1-beta", "dependencies": { "@tanstack/react-query": "^5.101.0", "@types/leaflet": "^1.9.21", diff --git a/frontend/package.json b/frontend/package.json index 1d74110..aeec0b5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.16.0-beta", + "version": "0.16.1-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/device-detail/RadiosTab.tsx b/frontend/src/components/device-detail/RadiosTab.tsx index 9d12c1c..6f1465b 100644 --- a/frontend/src/components/device-detail/RadiosTab.tsx +++ b/frontend/src/components/device-detail/RadiosTab.tsx @@ -10,6 +10,7 @@ import { } from 'recharts'; import { wirelessApi } from '../../services/api'; import { useCanWrite } from '../../hooks/useCanWrite'; +import RfHealth from '../wireless/RfHealth'; import type { WirelessInterface, WirelessMetricPoint } from '../../types'; import clsx from 'clsx'; import { format } from 'date-fns'; @@ -1103,6 +1104,15 @@ export default function RadiosTab({ deviceId, deviceStatus }: Props) { + {/* RF Health — channel map, client density, TX retries, connectivity */} +
+

+ + RF Health +

+ +
+ {/* Performance Charts */}

diff --git a/frontend/src/components/ports/SwitchPortDiagram.tsx b/frontend/src/components/ports/SwitchPortDiagram.tsx index b28893c..76369d7 100644 --- a/frontend/src/components/ports/SwitchPortDiagram.tsx +++ b/frontend/src/components/ports/SwitchPortDiagram.tsx @@ -1,13 +1,15 @@ import { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; 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 { useCanWrite } from '../../hooks/useCanWrite'; -import type { SwitchPort, Vlan, TrafficPoint, PortMonitorData } from '../../types'; +import type { SwitchPort, Vlan, TrafficPoint, PortMonitorData, PortClient } from '../../types'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, } from 'recharts'; +import { formatDistanceToNow } from 'date-fns'; import clsx from 'clsx'; 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 ( +
+ + + + + + + + + + + + {clients.map((c, i) => { + const name = c.custom_name || c.hostname; + return ( + + + + + + + + ); + })} + +
Host / VendorMACIPVLANConnected
+ + {c.client_type === 'wireless' && } + {name || No hostname} + + {c.vendor &&
{c.vendor}
} +
{c.mac_address}{c.ip_address || '—'} + {c.vlan_id != null + ? {c.vlan_id} + : '—'} + + {c.first_seen ? formatDistanceToNow(new Date(c.first_seen), { addSuffix: false }) : '—'} +
+
+ ); +} + +// 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 ( +
+
+ +

+ Connected Clients + {portName} +

+ {!isLoading && !isUplink && ( + + {connected.length} device{connected.length !== 1 ? 's' : ''} + + )} +
+ + {isLoading ? ( +
+ Loading… +
+ ) : isUplink ? ( +
+
+ +
+
This looks like an uplink / trunk port
+

+ {data?.reason}. The {data?.mac_count} MAC address{data?.mac_count !== 1 ? 'es' : ''} learned here are + reachable through this port, not physically connected to it. +

+
+
+ + {showMacTable && ( + macTable + ?
+
+ Reachable via this port — not necessarily physically connected. +
+ +
+ :
Loading…
+ )} +
+ ) : connected.length === 0 ? ( +
+ No clients currently connected to this port. +
+ ) : ( + + )} +
+ ); +} + export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOpened }: Props) { const queryClient = useQueryClient(); const canWrite = useCanWrite(); @@ -988,25 +1120,28 @@ export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOp {selectedPorts.size === 1 && (() => { const portName = Array.from(selectedPorts)[0]; return ( -
-
- -
-
- -
-
- +
+
+
+ +
+
+ +
+
+ +
+
); })()} diff --git a/frontend/src/components/wireless/RfHealth.tsx b/frontend/src/components/wireless/RfHealth.tsx new file mode 100644 index 0000000..3c39177 --- /dev/null +++ b/frontend/src/components/wireless/RfHealth.tsx @@ -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 ( +
+
{BAND_LABEL[band]}
+
+ {cells.map((c, i) => ( +
+ ))} +
+
+ {channels.map(ch => ( +
{ch}
+ ))} +
+
+ ); +} + +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 ( +
+
+ +

Channel Usage

+ + in use + overlap + +
+ {radios.length === 0 ? ( +
+ No active radios reporting a channel yet. +
+ ) : ( +
+ {bands.map(b => )} +
+ )} +
+ ); +} + +// ─── 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(); + 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 ( +
+
+ +

AP Deployment Density

+ + {signals.length} client{signals.length !== 1 ? 's' : ''} by signal (dBm) + +
+ + {signals.length === 0 ? ( +
+ No connected wireless clients to plot. +
+ ) : ( + <> + {needsImprovement && ( +
+ + AP deployment density may need improvement — {weak} of {signals.length} clients are connecting below −75 dBm, which suggests coverage gaps. +
+ )} + + {/* Density track */} +
+ {/* gradient zone background */} +
+ {RSSI_ZONES.map(z => ( +
+ ))} +
+ {/* client bubbles */} + {Array.from(bins.entries()).map(([bin, rows]) => { + const size = Math.min(34, 12 + (rows.length - 1) * 4); + return ( +
`${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 : ''} +
+ ); + })} +
+ {/* axis */} +
+ {RSSI_TICKS.map(t => ( + {t} + ))} +
+ + )} +
+ ); +} + +// ─── TX Retries histogram ─────────────────────────────────────────────────────── + +export function TxRetries({ deviceId }: { deviceId?: number }) { + const [band, setBand] = useState('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 ( +
+
+ +

AP Radio TX Retries

+
+ {ALL_BANDS.map(b => ( + + ))} +
+
+ + {radios.length === 0 ? ( +
+ No TX-retry data. This is derived from per-client CCQ, which the legacy + wireless driver reports; Wi-Fi 6/7 (wifi) radios don't expose it. +
+ ) : ( + <> +
+ {ordered.map(b => ( +
+
0 ? retryColor((b.min + Math.min(b.max, 40)) / 2) : 'var(--color-border, #e5e7eb)' }}> + {b.count || ''} +
+
{b.label}
+
+ ))} +
+

+ Radios bucketed by transmit-retry rate over the last 6h ({inBand.length} on {BAND_LABEL[band]}). Lower is better. +

+ + )} +
+ ); +} + +// ─── 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 ( +
+
+ +

WiFi Connectivity Success

+
+ {['1h', '24h', '7d'].map(r => ( + + ))} +
+
+ + {!stages || totalEvents === 0 ? ( +
+ No connectivity events in this window. This funnel is derived from device + logs — enable wireless & DHCP logging on the APs to populate it. +
+ ) : ( + <> +
+ {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 ( +
+
+ {label} + + {pct == null ? '—' : `${pct}%`} + ({s.success} ok / {s.failure} fail) + +
+
+
+
+
+ ); + })} +
+

+ Approximate, derived from device logs. DNS success isn't observable on RouterOS and is omitted. +

+ + )} +
+ ); +} + +// ─── Combined section ───────────────────────────────────────────────────────── + +export default function RfHealth({ deviceId }: { deviceId?: number }) { + return ( +
+ +
+ + +
+ +
+ ); +} diff --git a/frontend/src/pages/WirelessPage.tsx b/frontend/src/pages/WirelessPage.tsx index 2582f71..011288c 100644 --- a/frontend/src/pages/WirelessPage.tsx +++ b/frontend/src/pages/WirelessPage.tsx @@ -5,6 +5,7 @@ import { } from 'lucide-react'; import { wirelessApi } from '../services/api'; import type { WirelessAP } from '../types'; +import RfHealth from '../components/wireless/RfHealth'; import clsx from 'clsx'; function timeAgo(ts: string | undefined): string { @@ -97,6 +98,9 @@ export default function WirelessPage() { ))}
+ {/* RF Health — channel map, density, TX retries, connectivity */} + {aps.length > 0 && } + {/* AP table */}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 5ed3c6e..8dc3d35 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -225,6 +225,8 @@ export const devicesApi = { reboot: (id: number) => api.post<{ message: string }>(`/devices/${id}/reboot`), getPortMonitor: (id: number, name: string) => api.get(`/devices/${id}/ports/${encodeURIComponent(name)}/monitor`), + getPortClients: (id: number, name: string, all = false) => + api.get(`/devices/${id}/ports/${encodeURIComponent(name)}/clients`, { params: { all: all || undefined } }), createBond: (id: number, data: { name: string; mode: string; slaves: string[]; 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`), getAPScanHistory: (apId: number, limit = 5) => 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('/wireless/rf/channels', { params: { deviceId } }), + getClientSignals: (deviceId?: number) => + api.get('/wireless/rf/signals', { params: { deviceId } }), + getTxQuality: (deviceId?: number, range = '6h') => + api.get('/wireless/rf/tx-quality', { params: { deviceId, range } }), + getConnectivity: (deviceId?: number, range = '24h') => + api.get('/wireless/rf/connectivity', { params: { deviceId, range } }), }; // ─── Network Services ───────────────────────────────────────────────────────── diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index c6ad370..8ec9636 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -118,6 +118,28 @@ export interface SwitchPort extends Interface { 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 { // Link state status?: string; @@ -342,6 +364,52 @@ export interface WirelessMetricPoint { 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 { id: number; device_id: number; diff --git a/frontend/src/utils/wifiChannels.ts b/frontend/src/utils/wifiChannels.ts new file mode 100644 index 0000000..e51a53c --- /dev/null +++ b/frontend/src/utils/wifiChannels.ts @@ -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 = { + '2.4': CHANNELS_24, + '5': CHANNELS_5, + '6': CHANNELS_6, +}; + +export const BAND_LABEL: Record = { + '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'; +} diff --git a/frontend/src/version.ts b/frontend/src/version.ts index bcaf576..c599a9d 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.16.0 Beta'; +export const APP_VERSION = 'v0.16.1 Beta';