fix(wireless): prune interfaces deleted on the device; collect wireless on full sync (v0.17.5-beta)

Resolves #87 — virtual APs removed directly on the device (WinBox/WebFig) kept
showing in the radios/interface lists, and neither refresh button cleared them.

Two distinct bugs:

1. Stale rows. The collectors upsert every interface found on the device but
   never removed rows for interfaces that disappeared, so deleted radios lingered
   forever. Added a prune to collectWirelessInterfaces, collectInterfaces (same
   bug for deleted VLANs/bridges/bonds) and collectSecurityProfiles, deleting
   stored rows whose names are no longer present on the device.

   Safety: the prune only runs when the device list was fetched successfully.
   Fetches that previously swallowed errors into an empty array now return null
   and skip the prune, so a transient API failure can never be mistaken for
   "everything was deleted". /interface/print already throws on failure.

2. Full sync skipped wireless entirely. collectAll() — what POST /devices/:id/sync
   runs — never called collectWirelessInterfaces/collectSecurityProfiles, so a
   manual resync could not refresh radios or clear stale ones; only the 5-minute
   slow poll touched them. collectAll now collects wireless data for wireless_ap
   devices, making the fix reachable on demand.

Verified on a live hAP-class AP: injected a ghost virtual-AP row, ran a sync, and
the ghost was removed while all 7 real interfaces (and offline devices' rows)
were retained. 114 backend tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-07-30 19:36:35 -07:00
co-authored by Claude Opus 4.8
parent 4ef9fd0e37
commit b6d161fc9d
5 changed files with 68 additions and 21 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
A self-hosted, full-stack network management platform for MikroTik devices. Monitor, configure, and manage your entire MikroTik infrastructure — routers, switches, and wireless access points — from a single web interface.
![Version](https://img.shields.io/badge/version-0.17.4_Beta-blue)
![Version](https://img.shields.io/badge/version-0.17.5_Beta-blue)
![License](https://img.shields.io/badge/license-AGPLv3-blue)
![Docker](https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white)
![TypeScript](https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-backend",
"version": "0.17.4-beta",
"version": "0.17.5-beta",
"description": "Mikrotik Network Management Platform - Backend API",
"main": "dist/index.js",
"scripts": {
@@ -143,6 +143,13 @@ export class DeviceCollector {
await this.updateClients();
await this.collectEvents();
await this.collectNeighbors();
// Wireless data belongs in a full resync too — without this, a manual sync
// never refreshed radios/SSIDs (or pruned ones deleted on the device), so
// the UI could only catch up on the 5-minute slow poll.
if (this.device.device_type === 'wireless_ap') {
await this.collectWirelessInterfaces();
await this.collectSecurityProfiles();
}
await this.saveFullConfig();
await this.updateDeviceStatus('online');
}
@@ -324,6 +331,15 @@ export class DeviceCollector {
console.error(`[${this.device.name}] Failed to insert interface ${name}:`, insertErr);
}
}
// Drop rows for interfaces removed on the device (deleted VLAN/bridge/bond).
// Safe: /interface/print above throws on failure, so reaching this point
// means we have an authoritative list rather than a failed fetch.
const presentNames = allIfaces.map((i) => i['name']).filter(Boolean);
await query(
`DELETE FROM interfaces WHERE device_id = $1 AND NOT (name = ANY($2::text[]))`,
[this.device.id, presentNames]
);
} catch (err) {
console.error(`[${this.device.name}] Failed to collect interfaces:`, err);
}
@@ -1293,12 +1309,15 @@ export class DeviceCollector {
const pkg = await this.detectWifiPackage();
if (pkg === 'none') return;
// null = the fetch itself failed. Distinguished from an empty list (device
// genuinely has no wireless interfaces) so a transient API error can never
// be mistaken for "everything was deleted" during the prune below.
const rawList = pkg === 'wifi'
? await this.client.execute('/interface/wifi/print').catch(() => [] as Record<string, string>[])
: await this.client.execute('/interface/wireless/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
? await this.client.execute('/interface/wifi/print').catch(() => null)
: await this.client.execute('/interface/wireless/print', { detail: '' }).catch(() => null);
if (rawList === null) return;
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
@@ -1361,6 +1380,15 @@ export class DeviceCollector {
]
);
}
// Drop rows for interfaces that no longer exist on the device (e.g. a
// virtual AP removed directly in WinBox/WebFig). Without this the upsert
// above only ever adds, so deleted radios linger in the UI forever.
const present = wlans.map((w) => w['name']).filter(Boolean);
await query(
`DELETE FROM wireless_interfaces WHERE device_id = $1 AND NOT (name = ANY($2::text[]))`,
[this.device.id, present]
);
} catch (err) {
console.error(`[${this.device.name}] Failed to collect wireless interfaces:`, err);
}
@@ -1455,6 +1483,14 @@ export class DeviceCollector {
}
}
/** Remove stored security profiles that no longer exist on the device. */
private async pruneSecurityProfiles(present: string[]): Promise<void> {
await query(
`DELETE FROM wireless_security_profiles WHERE device_id = $1 AND NOT (name = ANY($2::text[]))`,
[this.device.id, present]
);
}
// ─── Security Profiles → Postgres ─────────────────────────────────────────
async collectSecurityProfiles(): Promise<void> {
@@ -1465,19 +1501,27 @@ export class DeviceCollector {
if (pkg === 'wifi') {
// Try named /interface/wifi/security profiles first.
// Fall back to synthesizing from interface inline security.* fields if none exist.
const named = await this.client.execute('/interface/wifi/security/print').catch(() => [] as Record<string, string>[]);
const sourceList: Array<{ name: string; raw: Record<string, string> }> = named.length > 0
? named.map(p => ({ name: p['name'], raw: p }))
: (await this.client.execute('/interface/wifi/print').catch(() => [] as Record<string, string>[]))
.filter(r => r['security.authentication-types'] || r['security.passphrase'])
.map(r => ({
name: r['name'],
raw: {
'authentication-types': r['security.authentication-types'] || '',
passphrase: r['security.passphrase'] || '',
encryption: r['security.encryption'] || '',
},
}));
// null = fetch failed (leave existing rows alone); [] = genuinely none.
const named = await this.client.execute('/interface/wifi/security/print').catch(() => null);
if (named === null) return;
let sourceList: Array<{ name: string; raw: Record<string, string> }>;
if (named.length > 0) {
sourceList = named.map(p => ({ name: p['name'], raw: p }));
} else {
const ifaceList = await this.client.execute('/interface/wifi/print').catch(() => null);
if (ifaceList === null) return;
sourceList = ifaceList
.filter(r => r['security.authentication-types'] || r['security.passphrase'])
.map(r => ({
name: r['name'],
raw: {
'authentication-types': r['security.authentication-types'] || '',
passphrase: r['security.passphrase'] || '',
encryption: r['security.encryption'] || '',
},
}));
}
for (const { name, raw } of sourceList) {
if (!name) continue;
@@ -1497,13 +1541,15 @@ export class DeviceCollector {
]
);
}
await this.pruneSecurityProfiles(sourceList.map((s) => s.name).filter(Boolean));
return;
}
// Legacy wireless package
const profiles = await this.client
.execute('/interface/wireless/security-profiles/print')
.catch(() => [] as Record<string, string>[]);
.catch(() => null);
if (profiles === null) return;
for (const p of profiles) {
const name = p['name'];
@@ -1529,6 +1575,7 @@ export class DeviceCollector {
]
);
}
await this.pruneSecurityProfiles(profiles.map((p) => p['name']).filter(Boolean));
} catch (err) {
console.error(`[${this.device.name}] Failed to collect security profiles:`, err);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-frontend",
"version": "0.17.4-beta",
"version": "0.17.5-beta",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = 'v0.17.4 Beta';
export const APP_VERSION = 'v0.17.5 Beta';