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:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mikrotik-manager-backend",
|
||||
"version": "0.10.2-beta",
|
||||
"version": "0.10.6-beta",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mikrotik-manager-backend",
|
||||
"version": "0.10.2-beta",
|
||||
"version": "0.10.6-beta",
|
||||
"dependencies": {
|
||||
"@influxdata/influxdb-client": "^1.33.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mikrotik-manager-backend",
|
||||
"version": "0.10.5-beta",
|
||||
"version": "0.10.6-beta",
|
||||
"description": "Mikrotik Network Management Platform - Backend API",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -180,6 +180,8 @@ ALTER TABLE topology_links ALTER COLUMN from_interface TYPE VARCHAR(512);
|
||||
ALTER TABLE topology_links ALTER COLUMN to_interface TYPE VARCHAR(512);
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS firmware_update_available BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS latest_ros_version VARCHAR(20);
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS routerboard_upgrade_available BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS upgrade_firmware_version VARCHAR(20);
|
||||
|
||||
-- Cached IPv4/IPv6 addresses from /ip/address (per device) for topology resolution:
|
||||
-- neighbors seen only by IP (CDP/MNDP) can be matched to managed devices even
|
||||
|
||||
@@ -61,6 +61,7 @@ router.get('/', async (_req: Request, res: Response) => {
|
||||
const devices = await query(
|
||||
`SELECT id, name, ip_address, api_port, api_username, model, serial_number,
|
||||
firmware_version, ros_version, latest_ros_version, firmware_update_available,
|
||||
routerboard_upgrade_available, upgrade_firmware_version,
|
||||
device_type, status, last_seen, notes,
|
||||
location_address, location_lat::float8 AS location_lat, location_lng::float8 AS location_lng,
|
||||
rack_name, rack_slot, created_at
|
||||
@@ -139,18 +140,41 @@ router.get('/discovered', async (_req: Request, res: Response) => {
|
||||
const nameToDevice = new Map<string, { id: number; name: string }>();
|
||||
for (const r of managedByName) nameToDevice.set(r.name.toLowerCase(), { id: r.id, name: r.name });
|
||||
|
||||
// Deduplicate by MAC (most reliable), fallback to IP, then identity
|
||||
// Deduplicate across all three identifiers so that a neighbor seen by multiple managed
|
||||
// devices — some via LLDP (has MAC), others via MNDP (no MAC) — merges into one entry.
|
||||
// Three indexes cross-reference mac/ip/identity to the canonical dedup key.
|
||||
const seen = new Map<string, typeof rows[0] & { seen_by_list: string[] }>();
|
||||
const macIdx = new Map<string, string>(); // normalised-mac → canonical key
|
||||
const ipIdx = new Map<string, string>(); // ip → canonical key
|
||||
const nameIdx = new Map<string, string>(); // lower-identity → canonical key
|
||||
|
||||
for (const row of rows) {
|
||||
const key = row.neighbor_mac || row.neighbor_address || row.neighbor_identity || '';
|
||||
if (!key) continue;
|
||||
if (seen.has(key)) {
|
||||
const existing = seen.get(key)!;
|
||||
if (!existing.seen_by_list.includes(row.seen_by)) {
|
||||
const mac = (row.neighbor_mac || '').toLowerCase();
|
||||
const ip = row.neighbor_address || '';
|
||||
const identity = (row.neighbor_identity || '').toLowerCase();
|
||||
|
||||
// Find whether any identifier matches something already in the map
|
||||
const existingKey =
|
||||
(mac && macIdx.get(mac)) ||
|
||||
(ip && ipIdx.get(ip)) ||
|
||||
(identity && nameIdx.get(identity));
|
||||
|
||||
if (existingKey) {
|
||||
const existing = seen.get(existingKey)!;
|
||||
if (row.seen_by && !existing.seen_by_list.includes(row.seen_by)) {
|
||||
existing.seen_by_list.push(row.seen_by);
|
||||
}
|
||||
// Register any newly-learned identifiers against the same key
|
||||
if (mac && !macIdx.has(mac)) macIdx.set(mac, existingKey);
|
||||
if (ip && !ipIdx.has(ip)) ipIdx.set(ip, existingKey);
|
||||
if (identity && !nameIdx.has(identity)) nameIdx.set(identity, existingKey);
|
||||
} else {
|
||||
seen.set(key, { ...row, seen_by_list: [row.seen_by] });
|
||||
const key = mac || ip || identity;
|
||||
if (!key) continue;
|
||||
seen.set(key, { ...row, seen_by_list: row.seen_by ? [row.seen_by] : [] });
|
||||
if (mac) macIdx.set(mac, key);
|
||||
if (ip) ipIdx.set(ip, key);
|
||||
if (identity) nameIdx.set(identity, key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +273,9 @@ router.get('/bulk-add/jobs/:jobId', requireWrite, async (req: Request, res: Resp
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
const device = await queryOne(
|
||||
`SELECT id, name, ip_address, api_port, api_username, ssh_port, ssh_username, model,
|
||||
serial_number, firmware_version, ros_version, device_type, status, last_seen,
|
||||
serial_number, firmware_version, ros_version, latest_ros_version,
|
||||
firmware_update_available, routerboard_upgrade_available, upgrade_firmware_version,
|
||||
device_type, status, last_seen,
|
||||
notes, location_address,
|
||||
location_lat::float8 AS location_lat,
|
||||
location_lng::float8 AS location_lng,
|
||||
@@ -923,6 +949,50 @@ router.post('/:id/install-update', requireWrite, async (req: Request, res: Respo
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/devices/:id/check-routerboard
|
||||
router.post('/:id/check-routerboard', async (req: Request, res: Response) => {
|
||||
const deviceRow = await queryOne<any>(
|
||||
`SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
if (!deviceRow) return res.status(404).json({ error: 'Device not found' });
|
||||
|
||||
const collector = new DeviceCollector(deviceRow);
|
||||
try {
|
||||
await collector.connect();
|
||||
const info = await collector.checkRouterboardUpgrade();
|
||||
await query(
|
||||
`UPDATE devices SET routerboard_upgrade_available = $1, upgrade_firmware_version = $2, updated_at = NOW() WHERE id = $3`,
|
||||
[info.upgradeAvailable, info.upgradeFirmware || null, deviceRow.id]
|
||||
);
|
||||
return res.json(info);
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/devices/:id/install-routerboard
|
||||
router.post('/:id/install-routerboard', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceRow = await queryOne<any>(
|
||||
`SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
if (!deviceRow) return res.status(404).json({ error: 'Device not found' });
|
||||
|
||||
const collector = new DeviceCollector(deviceRow);
|
||||
try {
|
||||
await collector.connect();
|
||||
await collector.installRouterboardUpgrade();
|
||||
await query(
|
||||
`UPDATE devices SET routerboard_upgrade_available = FALSE, updated_at = NOW() WHERE id = $1`,
|
||||
[deviceRow.id]
|
||||
);
|
||||
return res.json({ message: 'RouterBOOT upgrade initiated. Device will reboot.' });
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/devices/:id/reboot
|
||||
router.post('/:id/reboot', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceRow = await queryOne<any>(
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user