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-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,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 & 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 & 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 "Check for Updates" 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 & 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 & 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 "Check for Updates" 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 "Check RouterBOOT" to read the bootloader firmware status from the device.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -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 @@
|
||||
export const APP_VERSION = 'v0.10.5 Beta';
|
||||
export const APP_VERSION = 'v0.10.6 Beta';
|
||||
|
||||
Reference in New Issue
Block a user