From b6d161fc9df795f0249077ba06e0102424648544 Mon Sep 17 00:00:00 2001 From: 2GT-Rich <107219997+2GT-Rich@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:36:35 -0700 Subject: [PATCH] fix(wireless): prune interfaces deleted on the device; collect wireless on full sync (v0.17.5-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 2 +- backend/package.json | 2 +- .../src/services/mikrotik/DeviceCollector.ts | 81 +++++++++++++++---- frontend/package.json | 2 +- frontend/src/version.ts | 2 +- 5 files changed, 68 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 7be65d3..d915d6b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A self-hosted, full-stack network management platform for MikroTik devices. Monitor, configure, and manage your entire MikroTik infrastructure — routers, switches, and wireless access points — from a single web interface. -![Version](https://img.shields.io/badge/version-0.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) diff --git a/backend/package.json b/backend/package.json index da499b2..6a0a086 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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": { diff --git a/backend/src/services/mikrotik/DeviceCollector.ts b/backend/src/services/mikrotik/DeviceCollector.ts index 70f946b..454dec4 100644 --- a/backend/src/services/mikrotik/DeviceCollector.ts +++ b/backend/src/services/mikrotik/DeviceCollector.ts @@ -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[]) - : await this.client.execute('/interface/wireless/print', { detail: '' }).catch(() => [] as Record[]); + ? 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 { + 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 { @@ -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[]); - const sourceList: Array<{ name: string; raw: Record }> = named.length > 0 - ? named.map(p => ({ name: p['name'], raw: p })) - : (await this.client.execute('/interface/wifi/print').catch(() => [] as Record[])) - .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 }>; + 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[]); + .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); } diff --git a/frontend/package.json b/frontend/package.json index 0986947..3c6851b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.17.4-beta", + "version": "0.17.5-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 2bfadc5..100dadb 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.17.4 Beta'; +export const APP_VERSION = 'v0.17.5 Beta';