feat: RouterBOOT upgrade support, update UI overhaul, discovery fixes (v0.10.6 Beta)

RouterOS vs RouterBOOT distinction:
- Rename "Firmware Updates" → "RouterOS Updates" everywhere (Config tab,
  dashboard banner, devices list tooltip) to accurately reflect what is
  being updated
- Add RouterBOOT bootloader upgrade support as a distinct operation:
  new DeviceCollector methods checkRouterboardUpgrade() and
  installRouterboardUpgrade() using /system/routerboard/print and
  /system/routerboard/upgrade
- New API endpoints: POST /api/devices/:id/check-routerboard and
  POST /api/devices/:id/install-routerboard
- Daily poller now also checks RouterBOOT upgrade status while already
  connected during firmware check cycle, with first-discovery alerts

Config tab update panel:
- Combine RouterOS and RouterBOOT into a single "Software & Firmware
  Updates" card with two subsections separated by a divider
- Pre-populate both sections from stored DB state on load — no longer
  requires a manual "Check" click to see a known pending update
- Enforce update order: when both are pending, show a blue info banner
  explaining RouterOS must be installed first, and disable the
  RouterBOOT "Upgrade & Reboot" button until the RouterOS update clears
- Fix GET /api/devices/:id to include firmware_update_available,
  latest_ros_version, routerboard_upgrade_available, upgrade_firmware_version
  (these were missing from the detail endpoint)

Dashboard & devices list:
- Combined amber banner now shows two distinct rows: RouterOS (N) with
  device links and RouterBOOT (N) with device links — one dismiss for both
- Devices list: amber ArrowUpCircle = RouterOS update, blue Cpu icon =
  RouterBOOT upgrade, each with distinct tooltips
- Fix GET /api/devices (list) to include routerboard_upgrade_available and
  upgrade_firmware_version (these were missing, causing RouterBOOT
  notifications to never appear on the dashboard)

DB migration:
- Add routerboard_upgrade_available BOOLEAN DEFAULT FALSE
- Add upgrade_firmware_version VARCHAR(20)

Discovery system fixes:
- Fix duplicate entries: replace single-key (mac || ip || identity) dedup
  with three cross-referenced indexes so a neighbor seen via LLDP by one
  device and via MNDP by another always merges into one row
- Fix offline devices persisting: handleDeviceFailure now deletes
  topology_links for the offline device (same pattern as clients)
- Add periodic stale-link sweep every 15 min that removes topology_links
  rows older than 20 min as a safety net for devices that go offline
  without cleanly triggering handleDeviceFailure
This commit is contained in:
2GT-Rich
2026-04-23 13:46:20 -07:00
parent 138d7a935b
commit 68d88d0306
16 changed files with 454 additions and 126 deletions
+42
View File
@@ -165,6 +165,18 @@ export class PollerService {
}
}
// Stale topology-link cleanup — runs every 15 minutes.
// Removes neighbor rows whose reporting device missed enough slow polls
// (slow poll = 5 min; 20-min window = 4 missed polls) without being
// explicitly marked offline, e.g. after a crash.
const staleLinksKey = 'task:stale_topology_links';
const lastStaleLinks = await this.getTimestamp(staleLinksKey);
if (now - lastStaleLinks > 900_000) {
await this.setTimestamp(staleLinksKey, now);
query(`DELETE FROM topology_links WHERE discovered_at < NOW() - INTERVAL '20 minutes'`)
.catch((e) => console.error('[Poller] Stale topology-link cleanup error:', e));
}
// Firmware update check — runs once per day
const firmwareKey = 'task:firmware_check';
const lastFirmware = await this.getTimestamp(firmwareKey);
@@ -611,6 +623,8 @@ export class PollerService {
}
// Mark all clients for this device inactive — updateClients() never ran because connect() failed.
await query(`UPDATE clients SET active = FALSE WHERE device_id = $1`, [deviceId]);
// Clear stale neighbor links — the device can't re-sync to clean them up itself.
await query(`DELETE FROM topology_links WHERE from_device_id = $1`, [deviceId]);
// Write updated global deduped count and a per-device zero so history is continuous.
if (device) {
const writeApi = getWriteApi();
@@ -695,6 +709,34 @@ export class PollerService {
// Update was installed — clear the flag and notify the UI
this.io?.emit('device:updated', { deviceId: device.id });
}
// RouterBOOT check — reuse the existing connection
const rbInfo = await collector.checkRouterboardUpgrade().catch(() => ({ upgradeAvailable: false, upgradeFirmware: '', currentFirmware: '' }));
const rbCurrentRows = await query<{ routerboard_upgrade_available: boolean }>(
`SELECT routerboard_upgrade_available FROM devices WHERE id = $1`,
[device.id]
);
const rbWasAvailable = rbCurrentRows[0]?.routerboard_upgrade_available ?? false;
await query(
`UPDATE devices SET routerboard_upgrade_available = $1, upgrade_firmware_version = $2, updated_at = NOW() WHERE id = $3`,
[rbInfo.upgradeAvailable, rbInfo.upgradeFirmware || null, device.id]
);
if (rbInfo.upgradeAvailable) {
this.io?.emit('device:updated', { deviceId: device.id });
if (!rbWasAvailable) {
const rbMsg = rbInfo.upgradeFirmware
? `${device.name} has a RouterBOOT upgrade available: ${rbInfo.upgradeFirmware}`
: `${device.name} has a RouterBOOT upgrade available`;
alertService.dispatch('firmware_update_available', rbMsg, {
deviceId: device.id,
deviceName: device.name,
details: rbInfo.upgradeFirmware ? `Current: ${rbInfo.currentFirmware} → Upgrade: ${rbInfo.upgradeFirmware}` : undefined,
}).catch(() => {});
console.log(`[Poller] RouterBOOT upgrade detected for ${device.name}: ${rbInfo.currentFirmware} → ${rbInfo.upgradeFirmware}`);
}
} else if (rbWasAvailable) {
this.io?.emit('device:updated', { deviceId: device.id });
}
} catch (err) {
console.error(`[Poller] Firmware check failed for ${device.name}:`, (err as Error).message);
} finally {
@@ -2152,6 +2152,20 @@ export class DeviceCollector {
await this.client.execute('/system/package/update/install');
}
async checkRouterboardUpgrade(): Promise<{ currentFirmware: string; upgradeFirmware: string; upgradeAvailable: boolean }> {
const result = await this.client.execute('/system/routerboard/print').catch(() => [] as Record<string, string>[]);
const rb = (result[0] as Record<string, string>) || {};
const currentFirmware = (rb['current-firmware'] || '').trim();
const upgradeFirmware = (rb['upgrade-firmware'] || '').trim();
const upgradeAvailable = Boolean(upgradeFirmware && currentFirmware && upgradeFirmware !== currentFirmware);
return { currentFirmware, upgradeFirmware, upgradeAvailable };
}
async installRouterboardUpgrade(): Promise<void> {
await this.client.execute('/system/routerboard/upgrade');
await this.client.execute('/system/reboot');
}
async reboot(): Promise<void> {
await this.client.execute('/system/reboot');
}