diff --git a/README.md b/README.md index f60421f..989ea1e 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.10.5_Beta-blue) +![Version](https://img.shields.io/badge/version-0.10.6_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-lock.json b/backend/package-lock.json index 9d05b3b..b74f9eb 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 7b4c9d6..bfb918f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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": { diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 49341f0..696a412 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -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 diff --git a/backend/src/routes/devices.ts b/backend/src/routes/devices.ts index 9573c2d..f1aff17 100644 --- a/backend/src/routes/devices.ts +++ b/backend/src/routes/devices.ts @@ -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(); 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(); + const macIdx = new Map(); // normalised-mac → canonical key + const ipIdx = new Map(); // ip → canonical key + const nameIdx = new Map(); // 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( + `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( + `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( diff --git a/backend/src/services/PollerService.ts b/backend/src/services/PollerService.ts index 2abe21c..c0809f7 100644 --- a/backend/src/services/PollerService.ts +++ b/backend/src/services/PollerService.ts @@ -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 { diff --git a/backend/src/services/mikrotik/DeviceCollector.ts b/backend/src/services/mikrotik/DeviceCollector.ts index 9518254..1feef57 100644 --- a/backend/src/services/mikrotik/DeviceCollector.ts +++ b/backend/src/services/mikrotik/DeviceCollector.ts @@ -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[]); + const rb = (result[0] as Record) || {}; + 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 { + await this.client.execute('/system/routerboard/upgrade'); + await this.client.execute('/system/reboot'); + } + async reboot(): Promise { await this.client.execute('/system/reboot'); } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bc55467..bc4a806 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mikrotik-manager-frontend", - "version": "0.10.2-beta", + "version": "0.10.6-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mikrotik-manager-frontend", - "version": "0.10.2-beta", + "version": "0.10.6-beta", "dependencies": { "@tanstack/react-query": "^5.17.1", "@types/leaflet": "^1.9.21", diff --git a/frontend/package.json b/frontend/package.json index ac604ef..8f585be 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.10.5-beta", + "version": "0.10.6-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/device-detail/SystemConfigTab.tsx b/frontend/src/components/device-detail/SystemConfigTab.tsx index 00b8f6d..0c9fae3 100644 --- a/frontend/src/components/device-detail/SystemConfigTab.tsx +++ b/frontend/src/components/device-detail/SystemConfigTab.tsx @@ -1,18 +1,19 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { - RefreshCw, Plus, Trash2, CheckCircle, AlertCircle, Download, Server, + RefreshCw, Plus, Trash2, CheckCircle, AlertCircle, Download, Server, Cpu, } from 'lucide-react'; import { devicesApi } from '../../services/api'; -import type { IpAddress, Interface } from '../../types'; +import type { Device, IpAddress, Interface } from '../../types'; import clsx from 'clsx'; import { useCanWrite } from '../../hooks/useCanWrite'; interface Props { deviceId: number; + device: Device; } -export default function SystemConfigTab({ deviceId }: Props) { +export default function SystemConfigTab({ deviceId, device }: Props) { const queryClient = useQueryClient(); const canWrite = useCanWrite(); @@ -114,15 +115,27 @@ export default function SystemConfigTab({ deviceId }: Props) { onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ip-addresses', deviceId] }), }); - // ─── Firmware check / install ───────────────────────────────────────────── + // ─── RouterOS update check / install ───────────────────────────────────── const [updateInfo, setUpdateInfo] = useState | null>(null); const [updateChecking, setUpdateChecking] = useState(false); const [updateError, setUpdateError] = useState(''); const [installSuccess, setInstallSuccess] = useState(''); + // Pre-populate from stored DB state so the section is useful without a manual check + useEffect(() => { + if (device.ros_version || device.latest_ros_version || device.firmware_update_available) { + setUpdateInfo({ + 'installed-version': device.ros_version || '', + 'latest-version': device.latest_ros_version || '', + 'status': device.firmware_update_available ? 'New version is available' : 'System is up to date', + 'channel': '', + }); + } + }, [device.id]); + const checkUpdateMutation = useMutation({ mutationFn: () => devicesApi.checkUpdate(deviceId), - onMutate: () => { setUpdateChecking(true); setUpdateError(''); setUpdateInfo(null); setInstallSuccess(''); }, + onMutate: () => { setUpdateChecking(true); setUpdateError(''); setInstallSuccess(''); }, onSuccess: (res) => { setUpdateInfo(res.data); setUpdateChecking(false); }, onError: (err: unknown) => { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; @@ -153,6 +166,46 @@ export default function SystemConfigTab({ deviceId }: Props) { (!installedVersion && statusSaysUpdate) ); + // ─── RouterBOOT upgrade check / install ────────────────────────────────── + const [rbInfo, setRbInfo] = useState<{ currentFirmware: string; upgradeFirmware: string; upgradeAvailable: boolean } | null>(null); + const [rbChecking, setRbChecking] = useState(false); + const [rbError, setRbError] = useState(''); + const [rbInstallSuccess, setRbInstallSuccess] = useState(''); + + // Pre-populate from stored DB state + useEffect(() => { + if (device.firmware_version) { + setRbInfo({ + currentFirmware: device.firmware_version, + upgradeFirmware: device.upgrade_firmware_version || '', + upgradeAvailable: device.routerboard_upgrade_available || false, + }); + } + }, [device.id]); + + const checkRouterboardMutation = useMutation({ + mutationFn: () => devicesApi.checkRouterboard(deviceId), + onMutate: () => { setRbChecking(true); setRbError(''); setRbInstallSuccess(''); }, + onSuccess: (res) => { setRbInfo(res.data); setRbChecking(false); }, + onError: (err: unknown) => { + const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; + setRbError(msg || 'Failed to check RouterBOOT'); + setRbChecking(false); + }, + }); + + const installRouterboardMutation = useMutation({ + mutationFn: () => devicesApi.installRouterboard(deviceId), + onSuccess: () => { + setRbInfo(null); + setRbInstallSuccess('RouterBOOT upgrade initiated. The device will reboot shortly.'); + }, + onError: (err: unknown) => { + const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; + setRbError(msg || 'Failed to install RouterBOOT upgrade'); + }, + }); + const isLoading = sysLoading || clockLoading; return ( @@ -454,98 +507,194 @@ export default function SystemConfigTab({ deviceId }: Props) { } - {/* ── Firmware Updates ── */} + {/* ── Software & Firmware Updates ── */}
-
-

- - Firmware Updates -

- +

+ + Software & Firmware Updates +

+ + {/* Order-of-operations notice when both are pending */} + {hasUpdate && rbInfo?.upgradeAvailable && ( +
+ +

+ Update order matters: Install the RouterOS update first and let the device reboot, then apply the RouterBOOT upgrade. The new bootloader is bundled inside the RouterOS package. +

+
+ )} + + {/* ── RouterOS ── */} +
+
+

+ + RouterOS +

+ +
+ + {updateError && ( +
+ {updateError} +
+ )} + {installSuccess && ( +
+ {installSuccess} +
+ )} + + {updateInfo ? ( +
+
+ {[ + ['Status', updateInfo['status'] || '—'], + ['Installed Version', installedVersion || '—'], + ['Latest Version', latestVersion || '—'], + ['Channel', updateInfo['channel'] || '—'], + ].map(([k, v]) => ( +
+ {k} + + {v} + +
+ ))} +
+ + {hasUpdate ? ( +
+ +
+

Update available: {latestVersion}

+

The device will reboot after installing.

+
+ {canWrite && ( + + )} +
+ ) : ( +
+ RouterOS is up to date +
+ )} +
+ ) : !updateChecking && !installSuccess && ( +

+ Click "Check for Updates" to query the MikroTik update server. +

+ )}
- {updateError && ( -
- {updateError} -
- )} +
- {installSuccess && ( -
- {installSuccess} + {/* ── RouterBOOT ── */} +
+
+

+ + RouterBOOT Firmware +

+
- )} - {updateInfo && ( -
-
- {[ - ['Status', updateInfo['status'] || '—'], - ['Installed Version', installedVersion || '—'], - ['Latest Version', latestVersion || '—'], - ['Channel', updateInfo['channel'] || '—'], - ].map(([k, v]) => ( -
- {k} - - {v} - -
- ))} + {rbError && ( +
+ {rbError}
+ )} + {rbInstallSuccess && ( +
+ {rbInstallSuccess} +
+ )} - {hasUpdate ? ( -
- -
-

- Update available: {latestVersion} -

-

- The device will reboot after installing. -

+ {rbInfo ? ( + rbInfo.currentFirmware ? ( +
+
+ {[ + ['Current Bootloader', rbInfo.currentFirmware || '—'], + ['Available Upgrade', rbInfo.upgradeFirmware || '—'], + ].map(([k, v]) => ( +
+ {k} + {v} +
+ ))}
- {canWrite && ( - + + {rbInfo.upgradeAvailable ? ( +
+ +
+

Upgrade available: {rbInfo.upgradeFirmware}

+

+ {hasUpdate ? 'Install RouterOS first, then apply this upgrade.' : 'The device will reboot after upgrading.'} +

+
+ {canWrite && ( + + )} +
+ ) : ( +
+ RouterBOOT is up to date +
)}
) : ( -
- - RouterOS is up to date -
- )} -
- )} - - {!updateInfo && !updateChecking && !installSuccess && ( -

- Click "Check for Updates" to query the MikroTik update server. -

- )} +

+ RouterBOOT is not available on this device (CHR or virtual instance). +

+ ) + ) : !rbChecking && !rbInstallSuccess && ( +

+ Click "Check RouterBOOT" to read the bootloader firmware status from the device. +

+ )} +
); diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index dd5f6e4..b4181b6 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -5,7 +5,7 @@ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend, } from 'recharts'; -import { Router, Users, AlertTriangle, Wifi, Activity, TrendingUp, MapPin, ArrowUpCircle, X, Signal } from 'lucide-react'; +import { Router, Users, AlertTriangle, Wifi, Activity, TrendingUp, MapPin, ArrowUpCircle, Cpu, X, Signal } from 'lucide-react'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import { metricsApi, eventsApi, devicesApi, clientsApi } from '../services/api'; @@ -274,9 +274,13 @@ export default function DashboardPage() { }); const [dismissedFirmwareIds, setDismissedFirmwareIds] = useState([]); - const devicesWithUpdates = devices.filter( + const devicesWithRosUpdates = devices.filter( (d) => d.firmware_update_available && !dismissedFirmwareIds.includes(d.id) ); + const devicesWithRbUpgrades = devices.filter( + (d) => d.routerboard_upgrade_available && !dismissedFirmwareIds.includes(d.id) + ); + const showUpdateBanner = devicesWithRosUpdates.length > 0 || devicesWithRbUpgrades.length > 0; // Device type distribution for pie chart const deviceTypeLabel: Record = { @@ -300,30 +304,63 @@ export default function DashboardPage() {

Dashboard

- {/* Firmware update banner */} - {devicesWithUpdates.length > 0 && ( + {/* Updates available banner */} + {showUpdateBanner && (
-
- Firmware update{devicesWithUpdates.length > 1 ? 's' : ''} available - {' — '} - {devicesWithUpdates.map((d, i) => ( - - {i > 0 && ', '} - - - ))} +
+ Updates available + {devicesWithRosUpdates.length > 0 && ( +
+ + + RouterOS ({devicesWithRosUpdates.length}): + + {devicesWithRosUpdates.map((d, i) => ( + + {i > 0 && · } + + + ))} +
+ )} + {devicesWithRbUpgrades.length > 0 && ( +
+ + + RouterBOOT ({devicesWithRbUpgrades.length}): + + {devicesWithRbUpgrades.map((d, i) => ( + + {i > 0 && · } + + + ))} +
+ )}