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

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

Per-port connected clients (Ports tab):
- New card lists clients physically connected to a selected port (host/MAC/
  IP/VLAN/connection time, linked to client pages).
- Distinguishes physical clients from the raw MAC table: uplink/trunk ports
  are auto-detected (topology neighbor, multi-VLAN MAC spread, or high MAC
  count) and show an uplink explainer + "view MAC table" disclosure instead
  of every reachable MAC.
- Added clients.first_seen ("connected since"); set once, preserved across polls.
This commit is contained in:
2GT-Rich
2026-06-25 15:37:08 -07:00
parent 1be161e67a
commit 4e6c9f481e
17 changed files with 994 additions and 35 deletions
+3
View File
@@ -160,6 +160,9 @@ CREATE TABLE IF NOT EXISTS app_settings (
ALTER TABLE clients ADD COLUMN IF NOT EXISTS vendor VARCHAR(255);
ALTER TABLE clients ADD COLUMN IF NOT EXISTS 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);
+60
View File
@@ -569,6 +569,66 @@ router.get('/:id/ports/:name/monitor', async (req: Request, res: Response) => {
}
});
// GET /api/devices/:id/ports/:name/clients — clients on this port.
//
// The bridge FDB lists every MAC *reachable through* a port, so on an
// uplink/trunk it shows the whole network. We classify the port and, by
// default, only return clients that are genuinely *physically* connected
// (a single access VLAN, no infra neighbour, not an aggregation point).
// Pass ?all=true to get the raw FDB list regardless (the "view MAC table"
// disclosure in the UI).
const UPLINK_MAC_THRESHOLD = 8;
router.get('/:id/ports/:name/clients', async (req: Request, res: Response) => {
const all = req.query.all === 'true';
const rows = await query<{
mac_address: string; hostname: string | null; custom_name: string | null;
vendor: string | null; ip_address: string | null; client_type: string;
vlan_id: number | null; signal_strength: number | null;
first_seen: string | null; last_seen: string | null;
}>(`
SELECT mac_address, hostname, custom_name, vendor, ip_address, client_type,
vlan_id, signal_strength, first_seen, last_seen
FROM clients
WHERE device_id = $1 AND interface_name = $2 AND active = TRUE
ORDER BY COALESCE(NULLIF(custom_name, ''), hostname, mac_address) ASC
`, [req.params.id, req.params.name]);
// A discovered LLDP/MNDP neighbour on this interface means it links to
// another managed device → definitively an uplink.
const neighbor = await queryOne<{ neighbor_identity: string | null; neighbor_platform: string | null }>(
`SELECT neighbor_identity, neighbor_platform FROM topology_links
WHERE from_device_id = $1 AND from_interface = $2
ORDER BY (neighbor_identity IS NOT NULL) DESC LIMIT 1`,
[req.params.id, req.params.name]
);
const macCount = rows.length;
const vlanCount = new Set(rows.filter(r => r.vlan_id != null).map(r => r.vlan_id)).size;
let classification: 'access' | 'uplink' = 'access';
let reason = '';
if (neighbor) {
classification = 'uplink';
reason = `Links to ${neighbor.neighbor_identity || 'another device'}`;
} else if (vlanCount > 1) {
classification = 'uplink';
reason = `MACs seen across ${vlanCount} VLANs — carrying tagged/trunk traffic`;
} else if (macCount > UPLINK_MAC_THRESHOLD) {
classification = 'uplink';
reason = `${macCount} MAC addresses reachable through this port`;
}
res.json({
classification,
reason,
mac_count: macCount,
vlan_count: vlanCount,
neighbor: neighbor || null,
// Physically-connected view hides everything on an uplink; ?all=true returns the raw FDB.
clients: all || classification === 'access' ? rows : [],
});
});
// GET /api/devices/:id/ports (switch port layout with VLAN info)
router.get('/:id/ports', async (req: Request, res: Response) => {
const [ifaces, bridgePorts, vlans] = await Promise.all([
+145
View File
@@ -701,4 +701,149 @@ router.get('/:id/ap-scan-history', async (req: Request, res: Response) => {
return res.json(scans);
});
// ─── 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`,