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
+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.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)
+2 -2
View File
@@ -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 -1
View File
@@ -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": {
+2
View File
@@ -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
+78 -8
View File
@@ -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>(
+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');
}
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-frontend",
"version": "0.10.5-beta",
"version": "0.10.6-beta",
"private": true,
"type": "module",
"scripts": {
@@ -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<Record<string, string> | 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) {
</div>}
</div>
{/* ── Firmware Updates ── */}
{/* ── Software & Firmware Updates ── */}
<div className="card p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Download className="w-4 h-4 text-blue-500" />
Firmware Updates
</h3>
<button
onClick={() => checkUpdateMutation.mutate()}
disabled={updateChecking}
className="btn-secondary flex items-center gap-2 text-sm"
>
<RefreshCw className={clsx('w-4 h-4', updateChecking && 'animate-spin')} />
{updateChecking ? 'Checking...' : 'Check for Updates'}
</button>
<h3 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2 mb-5">
<Download className="w-4 h-4 text-blue-500" />
Software &amp; Firmware Updates
</h3>
{/* Order-of-operations notice when both are pending */}
{hasUpdate && rbInfo?.upgradeAvailable && (
<div className="flex items-start gap-2 p-3 mb-5 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg text-sm text-blue-700 dark:text-blue-300">
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
<p>
<span className="font-medium">Update order matters:</span> Install the RouterOS update first and let the device reboot, then apply the RouterBOOT upgrade. The new bootloader is bundled inside the RouterOS package.
</p>
</div>
)}
{/* ── RouterOS ── */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold text-gray-700 dark:text-slate-300 flex items-center gap-1.5">
<Download className="w-3.5 h-3.5 text-gray-400" />
RouterOS
</h4>
<button
onClick={() => checkUpdateMutation.mutate()}
disabled={updateChecking}
className="btn-secondary flex items-center gap-2 text-xs py-1"
>
<RefreshCw className={clsx('w-3 h-3', updateChecking && 'animate-spin')} />
{updateChecking ? 'Checking...' : 'Check for Updates'}
</button>
</div>
{updateError && (
<div className="flex items-center gap-2 text-sm text-red-500">
<AlertCircle className="w-4 h-4" /> {updateError}
</div>
)}
{installSuccess && (
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<CheckCircle className="w-4 h-4" /> {installSuccess}
</div>
)}
{updateInfo ? (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-sm">
{[
['Status', updateInfo['status'] || '—'],
['Installed Version', installedVersion || '—'],
['Latest Version', latestVersion || '—'],
['Channel', updateInfo['channel'] || '—'],
].map(([k, v]) => (
<div key={k} className="flex justify-between border-b border-gray-100 dark:border-slate-700 pb-2">
<span className="text-gray-500 dark:text-slate-400">{k}</span>
<span className={clsx(
'font-medium text-right',
k === 'Status' && hasUpdate ? 'text-orange-500' : 'text-gray-900 dark:text-white'
)}>
{v}
</span>
</div>
))}
</div>
{hasUpdate ? (
<div className="flex items-center gap-3 p-3 bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg">
<AlertCircle className="w-4 h-4 text-orange-500 flex-shrink-0" />
<div className="flex-1 text-sm">
<p className="font-medium text-orange-700 dark:text-orange-400">Update available: {latestVersion}</p>
<p className="text-orange-600 dark:text-orange-500 text-xs mt-0.5">The device will reboot after installing.</p>
</div>
{canWrite && (
<button
onClick={() => {
if (confirm(`Install RouterOS ${latestVersion} and reboot the device?`)) {
installUpdateMutation.mutate();
}
}}
disabled={installUpdateMutation.isPending}
className="btn-primary flex items-center gap-2 text-sm flex-shrink-0"
>
{installUpdateMutation.isPending ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
Install &amp; Reboot
</button>
)}
</div>
) : (
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<CheckCircle className="w-4 h-4" /> RouterOS is up to date
</div>
)}
</div>
) : !updateChecking && !installSuccess && (
<p className="text-sm text-gray-400 dark:text-slate-500">
Click &quot;Check for Updates&quot; to query the MikroTik update server.
</p>
)}
</div>
{updateError && (
<div className="flex items-center gap-2 text-sm text-red-500 mb-3">
<AlertCircle className="w-4 h-4" /> {updateError}
</div>
)}
<div className="border-t border-gray-200 dark:border-slate-700 my-5" />
{installSuccess && (
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400 mb-3">
<CheckCircle className="w-4 h-4" /> {installSuccess}
{/* ── RouterBOOT ── */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold text-gray-700 dark:text-slate-300 flex items-center gap-1.5">
<Cpu className="w-3.5 h-3.5 text-gray-400" />
RouterBOOT Firmware
</h4>
<button
onClick={() => checkRouterboardMutation.mutate()}
disabled={rbChecking}
className="btn-secondary flex items-center gap-2 text-xs py-1"
>
<RefreshCw className={clsx('w-3 h-3', rbChecking && 'animate-spin')} />
{rbChecking ? 'Checking...' : 'Check RouterBOOT'}
</button>
</div>
)}
{updateInfo && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-4 text-sm">
{[
['Status', updateInfo['status'] || '—'],
['Installed Version', installedVersion || '—'],
['Latest Version', latestVersion || '—'],
['Channel', updateInfo['channel'] || '—'],
].map(([k, v]) => (
<div key={k} className="flex justify-between border-b border-gray-100 dark:border-slate-700 pb-2">
<span className="text-gray-500 dark:text-slate-400">{k}</span>
<span className={clsx(
'font-medium text-right',
k === 'Status' && hasUpdate ? 'text-orange-500' : 'text-gray-900 dark:text-white'
)}>
{v}
</span>
</div>
))}
{rbError && (
<div className="flex items-center gap-2 text-sm text-red-500">
<AlertCircle className="w-4 h-4" /> {rbError}
</div>
)}
{rbInstallSuccess && (
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<CheckCircle className="w-4 h-4" /> {rbInstallSuccess}
</div>
)}
{hasUpdate ? (
<div className="flex items-center gap-3 p-3 bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg">
<AlertCircle className="w-4 h-4 text-orange-500 flex-shrink-0" />
<div className="flex-1 text-sm">
<p className="font-medium text-orange-700 dark:text-orange-400">
Update available: {latestVersion}
</p>
<p className="text-orange-600 dark:text-orange-500 text-xs mt-0.5">
The device will reboot after installing.
</p>
{rbInfo ? (
rbInfo.currentFirmware ? (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-sm">
{[
['Current Bootloader', rbInfo.currentFirmware || '—'],
['Available Upgrade', rbInfo.upgradeFirmware || '—'],
].map(([k, v]) => (
<div key={k} className="flex justify-between border-b border-gray-100 dark:border-slate-700 pb-2">
<span className="text-gray-500 dark:text-slate-400">{k}</span>
<span className="font-medium text-right text-gray-900 dark:text-white">{v}</span>
</div>
))}
</div>
{canWrite && (
<button
onClick={() => {
if (confirm(`Install RouterOS ${latestVersion} and reboot the device?`)) {
installUpdateMutation.mutate();
}
}}
disabled={installUpdateMutation.isPending}
className="btn-primary flex items-center gap-2 text-sm flex-shrink-0"
>
{installUpdateMutation.isPending
? <RefreshCw className="w-4 h-4 animate-spin" />
: <Download className="w-4 h-4" />}
Install &amp; Reboot
</button>
{rbInfo.upgradeAvailable ? (
<div className="flex items-center gap-3 p-3 bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg">
<AlertCircle className="w-4 h-4 text-orange-500 flex-shrink-0" />
<div className="flex-1 text-sm">
<p className="font-medium text-orange-700 dark:text-orange-400">Upgrade available: {rbInfo.upgradeFirmware}</p>
<p className="text-orange-600 dark:text-orange-500 text-xs mt-0.5">
{hasUpdate ? 'Install RouterOS first, then apply this upgrade.' : 'The device will reboot after upgrading.'}
</p>
</div>
{canWrite && (
<button
onClick={() => {
if (confirm(`Upgrade RouterBOOT to ${rbInfo.upgradeFirmware} and reboot the device?`)) {
installRouterboardMutation.mutate();
}
}}
disabled={installRouterboardMutation.isPending || Boolean(hasUpdate)}
title={hasUpdate ? 'Install the RouterOS update first' : undefined}
className="btn-primary flex items-center gap-2 text-sm flex-shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
>
{installRouterboardMutation.isPending ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Cpu className="w-4 h-4" />}
Upgrade &amp; Reboot
</button>
)}
</div>
) : (
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<CheckCircle className="w-4 h-4" /> RouterBOOT is up to date
</div>
)}
</div>
) : (
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<CheckCircle className="w-4 h-4" />
RouterOS is up to date
</div>
)}
</div>
)}
{!updateInfo && !updateChecking && !installSuccess && (
<p className="text-sm text-gray-400 dark:text-slate-500">
Click &quot;Check for Updates&quot; to query the MikroTik update server.
</p>
)}
<p className="text-sm text-gray-400 dark:text-slate-500">
RouterBOOT is not available on this device (CHR or virtual instance).
</p>
)
) : !rbChecking && !rbInstallSuccess && (
<p className="text-sm text-gray-400 dark:text-slate-500">
Click &quot;Check RouterBOOT&quot; to read the bootloader firmware status from the device.
</p>
)}
</div>
</div>
</div>
);
+59 -22
View File
@@ -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<number[]>([]);
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<string, string> = {
@@ -300,30 +304,63 @@ export default function DashboardPage() {
<div className="space-y-6">
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
{/* Firmware update banner */}
{devicesWithUpdates.length > 0 && (
{/* Updates available banner */}
{showUpdateBanner && (
<div className="rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-700/50 dark:bg-amber-900/20 px-4 py-3 flex items-start gap-3">
<ArrowUpCircle className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" />
<div className="flex-1 text-sm text-amber-800 dark:text-amber-300">
<span className="font-semibold">Firmware update{devicesWithUpdates.length > 1 ? 's' : ''} available</span>
{' — '}
{devicesWithUpdates.map((d, i) => (
<span key={d.id}>
{i > 0 && ', '}
<button
onClick={() => navigate(`/devices/${d.id}`)}
className="underline underline-offset-2 hover:text-amber-900 dark:hover:text-amber-200 font-medium"
>
{d.name}
{d.latest_ros_version && (
<span className="font-normal"> ({d.latest_ros_version})</span>
)}
</button>
</span>
))}
<div className="flex-1 text-sm text-amber-800 dark:text-amber-300 space-y-1">
<span className="font-semibold">Updates available</span>
{devicesWithRosUpdates.length > 0 && (
<div className="flex items-baseline gap-1.5 flex-wrap">
<span className="font-medium flex items-center gap-1">
<ArrowUpCircle className="w-3 h-3" />
RouterOS ({devicesWithRosUpdates.length}):
</span>
{devicesWithRosUpdates.map((d, i) => (
<span key={d.id}>
{i > 0 && <span className="text-amber-600 dark:text-amber-500"> · </span>}
<button
onClick={() => navigate(`/devices/${d.id}?tab=config`)}
className="underline underline-offset-2 hover:text-amber-900 dark:hover:text-amber-200"
>
{d.name}
{d.latest_ros_version && (
<span className="font-normal opacity-80"> ({d.latest_ros_version})</span>
)}
</button>
</span>
))}
</div>
)}
{devicesWithRbUpgrades.length > 0 && (
<div className="flex items-baseline gap-1.5 flex-wrap">
<span className="font-medium flex items-center gap-1">
<Cpu className="w-3 h-3" />
RouterBOOT ({devicesWithRbUpgrades.length}):
</span>
{devicesWithRbUpgrades.map((d, i) => (
<span key={d.id}>
{i > 0 && <span className="text-amber-600 dark:text-amber-500"> · </span>}
<button
onClick={() => navigate(`/devices/${d.id}?tab=config`)}
className="underline underline-offset-2 hover:text-amber-900 dark:hover:text-amber-200"
>
{d.name}
{d.upgrade_firmware_version && (
<span className="font-normal opacity-80"> ({d.upgrade_firmware_version})</span>
)}
</button>
</span>
))}
</div>
)}
</div>
<button
onClick={() => setDismissedFirmwareIds((ids) => [...ids, ...devicesWithUpdates.map((d) => d.id)])}
onClick={() => setDismissedFirmwareIds((ids) => [
...ids,
...devicesWithRosUpdates.map((d) => d.id),
...devicesWithRbUpgrades.map((d) => d.id),
])}
className="p-0.5 rounded hover:bg-amber-200 dark:hover:bg-amber-800/50 text-amber-500 flex-shrink-0"
title="Dismiss"
>
+1 -1
View File
@@ -311,7 +311,7 @@ export default function DeviceDetailPage() {
)}
{activeTab === 'routing' && <RoutingTab deviceId={deviceId} />}
{activeTab === 'firewall' && <FirewallTab deviceId={deviceId} deviceType={device?.device_type} />}
{activeTab === 'config' && <SystemConfigTab deviceId={deviceId} />}
{activeTab === 'config' && <SystemConfigTab deviceId={deviceId} device={device} />}
{activeTab === 'hardware' && <HardwareTab deviceId={deviceId} />}
{activeTab === 'radios' && <RadiosTab deviceId={deviceId} deviceStatus={device.status} />}
{activeTab === 'tools' && <ToolsTab deviceId={deviceId} />}
+10 -1
View File
@@ -11,6 +11,7 @@ import {
Search,
Radar,
ArrowUpCircle,
Cpu,
Pencil,
ArrowUpDown,
ArrowUp,
@@ -362,12 +363,20 @@ export default function DevicesPage() {
{device.ros_version || '—'}
{device.firmware_update_available && (
<span
title={device.latest_ros_version ? `Update available: ${device.latest_ros_version}` : 'Firmware update available'}
title={device.latest_ros_version ? `RouterOS update available: ${device.latest_ros_version}` : 'RouterOS update available'}
className="flex-shrink-0"
>
<ArrowUpCircle className="w-3.5 h-3.5 text-amber-500" />
</span>
)}
{device.routerboard_upgrade_available && (
<span
title={device.upgrade_firmware_version ? `RouterBOOT upgrade available: ${device.upgrade_firmware_version}` : 'RouterBOOT upgrade available'}
className="flex-shrink-0"
>
<Cpu className="w-3.5 h-3.5 text-blue-400" />
</span>
)}
</span>
</td>
<td className="px-4 py-3">
+3
View File
@@ -179,6 +179,9 @@ export const devicesApi = {
api.delete(`/devices/${id}/ip-addresses/${encodeURIComponent(addrId)}`),
checkUpdate: (id: number) =>
api.post<Record<string, string>>(`/devices/${id}/check-update`),
checkRouterboard: (id: number) =>
api.post<{ currentFirmware: string; upgradeFirmware: string; upgradeAvailable: boolean }>(`/devices/${id}/check-routerboard`),
installRouterboard: (id: number) => api.post(`/devices/${id}/install-routerboard`),
getHardware: (id: number) =>
api.get<{ health: Record<string, string>[]; disks: Record<string, string>[] }>(`/devices/${id}/hardware`),
installUpdate: (id: number) => api.post(`/devices/${id}/install-update`),
+2
View File
@@ -56,6 +56,8 @@ export interface Device {
ros_version?: string;
latest_ros_version?: string;
firmware_update_available?: boolean;
routerboard_upgrade_available?: boolean;
upgrade_firmware_version?: string;
device_type: DeviceType;
status: DeviceStatus;
last_seen?: string;
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = 'v0.10.5 Beta';
export const APP_VERSION = 'v0.10.6 Beta';