feat(firmware): in-app MikroTik release notes + posture findings for pending updates (v0.16.6-beta)
- New GET /api/firmware/changelog/:version proxies MikroTik's official per-version release notes (download.mikrotik.com/routeros/<ver>/CHANGELOG) with strict version validation and process-lifetime caching - ChangelogModal: "What's new in X.Y.Z" on the device Config tab's update box and on the Firmware page's latest-version cells opens the changelog in-app, with an external mikrotik.com link and graceful fallback when the CDN is unreachable - Security posture: a pending RouterOS update is now a medium-severity finding and a pending RouterBOOT upgrade a low-severity one; titles are version-free so Common Findings aggregates the fleet into one row - chore(deps): npm audit fix bumps morgan (GHSA-4vj7-5mj6-jm8m); both workspaces audit clean Validation: backend tsc/lint/92 tests, frontend tsc/lint/build/11 tests, endpoint tested (valid/invalid/unknown versions), browser-verified on Firmware page, device Config tab, Security Center, and device Security tab; security review of the diff found no findings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
8307bcd291
commit
27f155f778
@@ -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.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@@ -134,7 +134,7 @@ A Meraki/UniFi-grade firewall experience built on the full RouterOS feature set:
|
||||
- **Hit counters & dead-rule insight** — per-rule packet/byte counters surface which rules are matching and flag zero-hit rules; one-click counter reset
|
||||
- **NAT wizards** — guided Port Forward, Masquerade (internet sharing), and 1:1 NAT flows instead of raw fields, plus a Custom mode
|
||||
- **Safe-apply lockout guard** — refuses to apply an unscoped `input`-chain drop/reject that would lock the platform/admin out of the device, requiring explicit confirmation
|
||||
- **Security posture audit** — per-device hardening checklist (insecure services like telnet/ftp/www/api, missing input-chain firewall, SNMP exposure) with a score and one-click remediation, plus a management-services table
|
||||
- **Security posture audit** — per-device hardening checklist (insecure services like telnet/ftp/www/api, missing input-chain firewall, SNMP exposure, outdated RouterOS/RouterBOOT firmware) with a score and one-click remediation, plus a management-services table
|
||||
- **Bandwidth control** — simple-queue management with up/down caps per IP, subnet, or interface, and a one-click "Limit this client" action on the client detail page
|
||||
- **Active connections viewer** — live connection-tracking table (source/destination, protocol, state, rate, bytes) with search
|
||||
|
||||
@@ -216,6 +216,7 @@ Per-device diagnostic and testing tools accessible from the device detail Tools
|
||||
### Firmware Orchestration
|
||||
Staged fleet-wide RouterOS upgrades, under the top-level **Firmware** section:
|
||||
- **Fleet versions** — current RouterOS, latest-known version, and pending RouterBOOT upgrades per device; **Check all for updates** queries every online device live
|
||||
- **Release notes in-app** — when an update is available, a "What's new" link on the device's firmware section (and the version on the Firmware page) opens MikroTik's official changelog for that exact version in a modal, with a link out to mikrotik.com
|
||||
- **Staged rollouts in waves** — select updatable devices and assign each to a wave (wave 1 = **canary**); the orchestrator runs one rollout at a time, devices sequentially, through a verified pipeline: **pre-upgrade backup → install → ride out the reboot → verify it returned healthy on the new version → next device**
|
||||
- **Halt on failure** stops the entire remaining rollout if any device fails, so a bad build never reaches the fleet; a reboot that comes back on the *old* version counts as a failure
|
||||
- **Schedule** rollouts for a future time (pair with a maintenance window); live wave-grouped progress with animated per-device status, `from → to` versions, error detail, and **Cancel** (never interrupts an in-flight flash)
|
||||
|
||||
Generated
+33
-41
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mikrotik-manager-backend",
|
||||
"version": "0.16.4-beta",
|
||||
"version": "0.16.6-beta",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mikrotik-manager-backend",
|
||||
"version": "0.16.4-beta",
|
||||
"version": "0.16.6-beta",
|
||||
"dependencies": {
|
||||
"@influxdata/influxdb-client": "^1.33.2",
|
||||
"@influxdata/influxdb-client-apis": "^1.35.0",
|
||||
@@ -993,16 +993,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
@@ -1017,20 +1007,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
@@ -2877,6 +2853,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
@@ -5886,6 +5872,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "3.15.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
|
||||
"integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
@@ -6282,31 +6282,23 @@
|
||||
}
|
||||
},
|
||||
"node_modules/morgan": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
|
||||
"integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==",
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.11.0.tgz",
|
||||
"integrity": "sha512-zSkVu3t18r39pw4ixfBKvfZi3y2UOqr7d4WYwcj3m8nXpEQK4rPO6GLzs/CExoRgmX3y9EjmmcXqv6jq0SK46g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"basic-auth": "~2.0.1",
|
||||
"debug": "2.6.9",
|
||||
"depd": "~2.0.0",
|
||||
"on-finished": "~2.3.0",
|
||||
"on-finished": "~2.4.1",
|
||||
"on-headers": "~1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/morgan/node_modules/on-finished": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
|
||||
"integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mikrotik-manager-backend",
|
||||
"version": "0.16.5-beta",
|
||||
"version": "0.16.6-beta",
|
||||
"description": "Mikrotik Network Management Platform - Backend API",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -1012,7 +1012,13 @@ router.put('/:id/services/:serviceId', requireWrite, async (req, res) => {
|
||||
// de-duplicated services, and it never proposes disabling the management
|
||||
// service MikroTik Manager itself connects through (that would self-lockout).
|
||||
router.get('/:id/security-posture', async (req, res) => {
|
||||
const device = await queryOne<DeviceRow>(`SELECT * FROM devices WHERE id = $1`, [req.params.id]);
|
||||
const device = await queryOne<DeviceRow & {
|
||||
firmware_update_available?: boolean;
|
||||
latest_ros_version?: string | null;
|
||||
routerboard_upgrade_available?: boolean;
|
||||
firmware_version?: string | null;
|
||||
upgrade_firmware_version?: string | null;
|
||||
}>(`SELECT * FROM devices WHERE id = $1`, [req.params.id]);
|
||||
if (!device) return res.status(404).json({ error: 'Device not found' });
|
||||
// Which RouterOS service the platform uses to reach this device. Default api
|
||||
// (8728); api-ssl is 8729. We must never offer to disable this one.
|
||||
@@ -1079,6 +1085,25 @@ router.get('/:id/security-posture', async (req, res) => {
|
||||
detail: 'Ensure SNMP uses a non-default community string and is restricted to trusted addresses.' });
|
||||
}
|
||||
|
||||
// Outdated firmware is a security exposure: RouterOS releases routinely
|
||||
// ship security fixes, so a pending update counts against the posture.
|
||||
// Titles stay version-free so the Security Center's "Common Findings"
|
||||
// rollup aggregates devices on different versions into one row.
|
||||
if (device.firmware_update_available && device.latest_ros_version) {
|
||||
checks.push({
|
||||
id: 'firmware-outdated', severity: 'medium',
|
||||
title: 'RouterOS update available',
|
||||
detail: `This device runs RouterOS ${device.ros_version || '(unknown)'} but ${device.latest_ros_version} is available. RouterOS releases regularly include security fixes — review the changelog and schedule the upgrade from the Firmware section.`,
|
||||
});
|
||||
}
|
||||
if (device.routerboard_upgrade_available) {
|
||||
checks.push({
|
||||
id: 'routerboot-outdated', severity: 'low',
|
||||
title: 'RouterBOOT upgrade pending',
|
||||
detail: `The bootloader (${device.firmware_version || 'current'}) has an available upgrade${device.upgrade_firmware_version ? ` to ${device.upgrade_firmware_version}` : ''}. Apply it after the next RouterOS upgrade from the device's Config tab.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Graduated score: lighter weights + a floor of 5 so it never reads a hard
|
||||
// 0 (which falsely implies "maximally insecure"). It's a relative hardening
|
||||
// indicator, not an absolute grade.
|
||||
|
||||
@@ -117,6 +117,38 @@ router.post('/rollouts/:id/start', requireWrite, async (req: Request, res: Respo
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/firmware/changelog/:version — proxy MikroTik's per-version release
|
||||
// notes (download.mikrotik.com/routeros/<ver>/CHANGELOG) so the UI can show
|
||||
// "what's new" without a CORS-blocked cross-origin fetch. Released changelogs
|
||||
// are immutable, so successful fetches are cached for the process lifetime.
|
||||
const changelogCache = new Map<string, string>();
|
||||
const CHANGELOG_CACHE_MAX = 100;
|
||||
|
||||
router.get('/changelog/:version', async (req: Request, res: Response) => {
|
||||
const version = String(req.params.version).trim();
|
||||
// RouterOS versions: 7.23.1, 7.16, 6.49.10, plus rc/beta suffixes (7.20rc3)
|
||||
if (!/^\d+\.\d+(\.\d+)?(rc\d+|beta\d+)?$/i.test(version)) {
|
||||
return res.status(400).json({ error: 'Invalid RouterOS version' });
|
||||
}
|
||||
const url = `https://download.mikrotik.com/routeros/${version}/CHANGELOG`;
|
||||
|
||||
const cached = changelogCache.get(version);
|
||||
if (cached) return res.json({ version, url, text: cached });
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
||||
if (!resp.ok) {
|
||||
return res.status(404).json({ error: `No changelog found for ${version}`, url });
|
||||
}
|
||||
const text = await resp.text();
|
||||
if (changelogCache.size >= CHANGELOG_CACHE_MAX) changelogCache.clear();
|
||||
changelogCache.set(version, text);
|
||||
res.json({ version, url, text });
|
||||
} catch {
|
||||
res.status(502).json({ error: 'Could not reach the MikroTik changelog server', url });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/firmware/rollouts/:id/cancel — stops before the next device (an
|
||||
// in-flight upgrade is never interrupted mid-write)
|
||||
router.post('/rollouts/:id/cancel', requireWrite, async (req: Request, res: Response) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mikrotik-manager-frontend",
|
||||
"version": "0.16.5-beta",
|
||||
"version": "0.16.6-beta",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { X, RefreshCw, AlertCircle, ExternalLink, FileText } from 'lucide-react';
|
||||
import { firmwareApi } from '../services/api';
|
||||
|
||||
/**
|
||||
* Shows MikroTik's official release notes for a RouterOS version, fetched
|
||||
* through the backend proxy (released changelogs are immutable, so results
|
||||
* are cached client- and server-side). Falls back to an external link when
|
||||
* the changelog can't be fetched.
|
||||
*/
|
||||
export default function ChangelogModal({ version, onClose }: { version: string; onClose: () => void }) {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['firmware-changelog', version],
|
||||
queryFn: () => firmwareApi.changelog(version).then(r => r.data),
|
||||
staleTime: Infinity,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const fallbackUrl = `https://download.mikrotik.com/routeros/${encodeURIComponent(version)}/CHANGELOG`;
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
|
||||
<div
|
||||
className="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-blue-500" />
|
||||
What's new in RouterOS {version}
|
||||
</h2>
|
||||
<button onClick={onClose} className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400 py-8 justify-center">
|
||||
<RefreshCw className="w-4 h-4 animate-spin" /> Fetching release notes from MikroTik…
|
||||
</div>
|
||||
) : error || !data ? (
|
||||
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
||||
<AlertCircle className="w-6 h-6 text-amber-500" />
|
||||
<p className="text-sm text-gray-600 dark:text-slate-300">
|
||||
Couldn't load the changelog for {version}.
|
||||
</p>
|
||||
<a
|
||||
href={fallbackUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1"
|
||||
>
|
||||
Open it on mikrotik.com <ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="text-xs leading-relaxed whitespace-pre-wrap font-mono text-gray-700 dark:text-slate-300">
|
||||
{data.text}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-t border-gray-200 dark:border-slate-700">
|
||||
<a
|
||||
href={data?.url ?? fallbackUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1"
|
||||
>
|
||||
Open on mikrotik.com <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
<button onClick={onClose} className="btn-secondary text-sm">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
RefreshCw, Plus, Trash2, CheckCircle, AlertCircle, Download, Server, Cpu,
|
||||
RefreshCw, Plus, Trash2, CheckCircle, AlertCircle, Download, Server, Cpu, FileText,
|
||||
} from 'lucide-react';
|
||||
import { devicesApi } from '../../services/api';
|
||||
import type { Device, IpAddress, Interface } from '../../types';
|
||||
import clsx from 'clsx';
|
||||
import { useCanWrite } from '../../hooks/useCanWrite';
|
||||
import ChangelogModal from '../ChangelogModal';
|
||||
|
||||
interface Props {
|
||||
deviceId: number;
|
||||
@@ -31,6 +32,7 @@ export default function SystemConfigTab({ deviceId, device }: Props) {
|
||||
|
||||
// ─── Combined edit state ──────────────────────────────────────────────────
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [changelogVersion, setChangelogVersion] = useState<string | null>(null);
|
||||
const [sysForm, setSysForm] = useState({
|
||||
identity: '',
|
||||
ntp_enabled: true,
|
||||
@@ -210,6 +212,9 @@ export default function SystemConfigTab({ deviceId, device }: Props) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{changelogVersion && (
|
||||
<ChangelogModal version={changelogVersion} onClose={() => setChangelogVersion(null)} />
|
||||
)}
|
||||
{/* ── System Settings (identity, NTP, DNS, clock) ── */}
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -579,6 +584,12 @@ export default function SystemConfigTab({ deviceId, device }: Props) {
|
||||
<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>
|
||||
<button
|
||||
onClick={() => setChangelogVersion(latestVersion)}
|
||||
className="mt-1.5 text-xs text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1"
|
||||
>
|
||||
<FileText className="w-3 h-3" /> What's new in {latestVersion}
|
||||
</button>
|
||||
</div>
|
||||
{canWrite && (
|
||||
<button
|
||||
|
||||
@@ -9,6 +9,7 @@ import { firmwareApi } from '../services/api';
|
||||
import type { FirmwareRolloutDevice } from '../services/api';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import ChangelogModal from '../components/ChangelogModal';
|
||||
|
||||
const ITEM_STATUS: Record<string, { label: string; cls: string; spin?: boolean }> = {
|
||||
pending: { label: 'Pending', cls: 'bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400' },
|
||||
@@ -126,6 +127,7 @@ export default function FirmwarePage() {
|
||||
const [scheduleAt, setScheduleAt] = useState('');
|
||||
const [viewRolloutId, setViewRolloutId] = useState<number | null>(null);
|
||||
const [checkResult, setCheckResult] = useState<string | null>(null);
|
||||
const [changelogVersion, setChangelogVersion] = useState<string | null>(null);
|
||||
|
||||
const { data: overview, isLoading } = useQuery({
|
||||
queryKey: ['fw-overview'],
|
||||
@@ -193,6 +195,9 @@ export default function FirmwarePage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{changelogVersion && (
|
||||
<ChangelogModal version={changelogVersion} onClose={() => setChangelogVersion(null)} />
|
||||
)}
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
@@ -280,7 +285,19 @@ export default function FirmwarePage() {
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400">{d.model || '—'}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-700 dark:text-slate-300">{d.ros_version || '—'}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">{d.latest_ros_version || '—'}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs">
|
||||
{d.latest_ros_version ? (
|
||||
<button
|
||||
onClick={() => setChangelogVersion(d.latest_ros_version)}
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
title={`View MikroTik's release notes for ${d.latest_ros_version}`}
|
||||
>
|
||||
{d.latest_ros_version}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-gray-500 dark:text-slate-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs">
|
||||
{d.routerboard_upgrade_available
|
||||
? <span className="px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 font-mono">{d.firmware_version} → {d.upgrade_firmware_version}</span>
|
||||
|
||||
@@ -531,6 +531,8 @@ export const firmwareApi = {
|
||||
api.get<FirmwareRollout & { devices: FirmwareRolloutDevice[] }>(`/firmware/rollouts/${id}`),
|
||||
startRollout: (id: number) => api.post(`/firmware/rollouts/${id}/start`),
|
||||
cancelRollout: (id: number) => api.post(`/firmware/rollouts/${id}/cancel`),
|
||||
changelog: (version: string) =>
|
||||
api.get<{ version: string; url: string; text: string }>(`/firmware/changelog/${encodeURIComponent(version)}`),
|
||||
};
|
||||
|
||||
// ─── Operations ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = 'v0.16.5 Beta';
|
||||
export const APP_VERSION = 'v0.16.6 Beta';
|
||||
|
||||
Reference in New Issue
Block a user