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:
@@ -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.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
@@ -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:
|
||||||
|
|||||||
Generated
+2
-2
@@ -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,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": {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
Generated
+2
-2
@@ -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,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'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 & 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
export const APP_VERSION = 'v0.16.0 Beta';
|
export const APP_VERSION = 'v0.16.1 Beta';
|
||||||
|
|||||||
Reference in New Issue
Block a user