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:
2GT-Rich
2026-07-11 15:45:53 -07:00
co-authored by Claude Fable 5
parent 8307bcd291
commit 27f155f778
11 changed files with 217 additions and 49 deletions
+1 -1
View File
@@ -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&apos;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&apos;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&apos;s new in {latestVersion}
</button>
</div>
{canWrite && (
<button
+18 -1
View File
@@ -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>
+2
View File
@@ -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
View File
@@ -1 +1 @@
export const APP_VERSION = 'v0.16.5 Beta';
export const APP_VERSION = 'v0.16.6 Beta';