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:
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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<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;
|
||||
|
||||
@@ -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: "<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 {
|
||||
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<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) {
|
||||
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<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) {
|
||||
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`,
|
||||
|
||||
Reference in New Issue
Block a user