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
+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"
>