fix(system): report real version so update banner clears; move update notice to Operations (v0.16.11-beta)
Update-availability fixes: - The backend read its version from npm_package_version, which is unset when the container runs `node dist/index.js` directly, so current was always "0.0.0" and every release looked newer — the "update available" banner never cleared. Read the bundled package.json instead (routes/system.ts), falling back to npm_package_version. - Move the update notification out of the top-of-app banner into the Operations dashboard (a dedicated notice under the network-status hero), shown only when genuinely behind and cleared once up to date. Security banner: - Replace the now-dead jwt_secret_default / encryption_key_default checks with the current secrets_not_persisted warning (auto-generated secrets that couldn't be persisted), matching the self-healing secrets model. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
9e962939ea
commit
8e564fc813
@@ -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.
|
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.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mikrotik-manager-backend",
|
"name": "mikrotik-manager-backend",
|
||||||
"version": "0.16.10-beta",
|
"version": "0.16.11-beta",
|
||||||
"description": "Mikrotik Network Management Platform - Backend API",
|
"description": "Mikrotik Network Management Platform - Backend API",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,12 +1,27 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
import { requireAuth } from '../middleware/auth';
|
import { requireAuth } from '../middleware/auth';
|
||||||
import { query } from '../config/database';
|
import { query } from '../config/database';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
|
|
||||||
const CURRENT_VERSION = process.env.npm_package_version || '0.0.0';
|
// The container runs `node dist/index.js` (not via npm), so npm_package_version
|
||||||
|
// is unset there — read the bundled package.json instead, falling back to it.
|
||||||
|
// Without this, current stays "0.0.0" and every release looks like an update.
|
||||||
|
function resolveCurrentVersion(): string {
|
||||||
|
if (process.env.npm_package_version) return process.env.npm_package_version;
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(readFileSync(join(__dirname, '../../package.json'), 'utf8')) as { version?: string };
|
||||||
|
return pkg.version || '0.0.0';
|
||||||
|
} catch {
|
||||||
|
return '0.0.0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CURRENT_VERSION = resolveCurrentVersion();
|
||||||
const RAW_URL = 'https://raw.githubusercontent.com/2GT-Media-Group-LLC/mikrotik-manager/main/frontend/package.json';
|
const RAW_URL = 'https://raw.githubusercontent.com/2GT-Media-Group-LLC/mikrotik-manager/main/frontend/package.json';
|
||||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mikrotik-manager-frontend",
|
"name": "mikrotik-manager-frontend",
|
||||||
"version": "0.16.10-beta",
|
"version": "0.16.11-beta",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { AlertTriangle, ShieldAlert, ArrowUpCircle, X } from 'lucide-react';
|
import { AlertTriangle, ShieldAlert } from 'lucide-react';
|
||||||
import { authApi, systemApi } from '../services/api';
|
import { authApi } from '../services/api';
|
||||||
|
|
||||||
interface SecurityStatus {
|
interface SecurityStatus {
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
@@ -9,83 +9,34 @@ interface SecurityStatus {
|
|||||||
|
|
||||||
export default function SecurityBanners() {
|
export default function SecurityBanners() {
|
||||||
const [status, setStatus] = useState<SecurityStatus | null>(null);
|
const [status, setStatus] = useState<SecurityStatus | null>(null);
|
||||||
const [updateInfo, setUpdateInfo] = useState<{ latest: string } | null>(null);
|
|
||||||
const [updateDismissed, setUpdateDismissed] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
authApi.securityStatus()
|
authApi.securityStatus()
|
||||||
.then((r) => setStatus(r.data))
|
.then((r) => setStatus(r.data))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
systemApi.versionCheck()
|
|
||||||
.then((r) => {
|
|
||||||
if (r.data.update_available && r.data.latest) {
|
|
||||||
setUpdateInfo({ latest: r.data.latest });
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const noWarnings = !status || status.warnings.length === 0;
|
const noWarnings = !status || status.warnings.length === 0;
|
||||||
const noUpdate = !updateInfo || updateDismissed;
|
if (noWarnings) return null;
|
||||||
if (noWarnings && noUpdate) return null;
|
|
||||||
|
|
||||||
const hasSecretWarning = !!status && (
|
const hasSecretsNotPersisted = !!status && status.warnings.includes('secrets_not_persisted');
|
||||||
status.warnings.includes('jwt_secret_default') ||
|
|
||||||
status.warnings.includes('encryption_key_default')
|
|
||||||
);
|
|
||||||
const hasAdminWarning = !!status && status.warnings.includes('admin_password_default');
|
const hasAdminWarning = !!status && status.warnings.includes('admin_password_default');
|
||||||
|
|
||||||
const secretNames = [
|
|
||||||
status?.warnings.includes('jwt_secret_default') && 'JWT_SECRET',
|
|
||||||
status?.warnings.includes('encryption_key_default') && 'ENCRYPTION_KEY',
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' and ');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{updateInfo && !updateDismissed && (
|
{hasSecretsNotPersisted && (
|
||||||
<div className="flex items-center justify-between gap-4 bg-blue-50 dark:bg-blue-900/20 border-b border-blue-200 dark:border-blue-800 px-4 py-2 text-sm text-blue-800 dark:text-blue-200">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ArrowUpCircle className="w-4 h-4 shrink-0" />
|
|
||||||
<span>
|
|
||||||
<span className="font-semibold">Update available:</span>{' '}
|
|
||||||
v{updateInfo.latest} is on GitHub.{' '}
|
|
||||||
<a
|
|
||||||
href="https://github.com/2GT-Media-Group-LLC/mikrotik-manager"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="underline hover:no-underline"
|
|
||||||
>
|
|
||||||
View release
|
|
||||||
</a>{' '}
|
|
||||||
and run <code className="font-mono text-xs">docker compose pull && docker compose up -d</code> to update.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setUpdateDismissed(true)}
|
|
||||||
className="shrink-0 text-blue-500 hover:text-blue-700 dark:hover:text-blue-300"
|
|
||||||
aria-label="Dismiss"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hasSecretWarning && (
|
|
||||||
<div className="flex items-center gap-2 bg-yellow-50 dark:bg-yellow-900/20 border-b border-yellow-200 dark:border-yellow-800 px-4 py-2 text-sm text-yellow-800 dark:text-yellow-200">
|
<div className="flex items-center gap-2 bg-yellow-50 dark:bg-yellow-900/20 border-b border-yellow-200 dark:border-yellow-800 px-4 py-2 text-sm text-yellow-800 dark:text-yellow-200">
|
||||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
<span className="font-semibold">Security warning:</span>{' '}
|
<span className="font-semibold">Security warning:</span>{' '}
|
||||||
{secretNames}{' '}
|
Auto-generated secrets could not be saved, so they will change on
|
||||||
{status.warnings.filter((w) =>
|
every restart — logging users out and potentially orphaning stored
|
||||||
['jwt_secret_default', 'encryption_key_default'].includes(w)
|
device credentials. Mount a writable volume at{' '}
|
||||||
).length > 1
|
<code className="font-mono text-xs">SECRETS_DIR</code> (default{' '}
|
||||||
? 'are'
|
<code className="font-mono text-xs">/app/data</code>) or set{' '}
|
||||||
: 'is'}{' '}
|
<code className="font-mono text-xs">JWT_SECRET</code> and{' '}
|
||||||
using default values. Set strong secrets in your{' '}
|
<code className="font-mono text-xs">ENCRYPTION_KEY</code> in your{' '}
|
||||||
<code className="font-mono text-xs">.env</code> file before
|
<code className="font-mono text-xs">.env</code>.
|
||||||
exposing this to any network.
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { metricsApi, eventsApi, devicesApi, clientsApi, trafficApi, operationsApi, topologyApi } from '../services/api';
|
import { metricsApi, eventsApi, devicesApi, clientsApi, trafficApi, operationsApi, topologyApi, systemApi } from '../services/api';
|
||||||
import type { OpsAttentionItem, OpsCapacityRow, OpsActivityItem } from '../services/api';
|
import type { OpsAttentionItem, OpsCapacityRow, OpsActivityItem } from '../services/api';
|
||||||
import TerminalModal from '../components/TerminalModal';
|
import TerminalModal from '../components/TerminalModal';
|
||||||
import { useSocket } from '../hooks/useSocket';
|
import { useSocket } from '../hooks/useSocket';
|
||||||
@@ -611,6 +611,14 @@ function OperationsView({
|
|||||||
const capacity: OpsCapacityRow[] = insights?.capacity ?? [];
|
const capacity: OpsCapacityRow[] = insights?.capacity ?? [];
|
||||||
const activity: OpsActivityItem[] = insights?.activity ?? [];
|
const activity: OpsActivityItem[] = insights?.activity ?? [];
|
||||||
|
|
||||||
|
// Platform update availability (checked against GitHub, cached server-side)
|
||||||
|
const { data: versionInfo } = useQuery({
|
||||||
|
queryKey: ['version-check'],
|
||||||
|
queryFn: () => systemApi.versionCheck().then(r => r.data),
|
||||||
|
staleTime: 60 * 60_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
// Fleet security posture rollup (live per-device; cached 5 min, lazy)
|
// Fleet security posture rollup (live per-device; cached 5 min, lazy)
|
||||||
const { data: posture } = useQuery({
|
const { data: posture } = useQuery({
|
||||||
queryKey: ['ops-security', onlineDevices.map(d => d.id).join(',')],
|
queryKey: ['ops-security', onlineDevices.map(d => d.id).join(',')],
|
||||||
@@ -724,6 +732,32 @@ function OperationsView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Platform update available */}
|
||||||
|
{versionInfo?.update_available && versionInfo.latest && (
|
||||||
|
<div className="card flex items-center gap-3 px-5 py-[13px]" style={{ borderLeft: '3px solid var(--info)' }}>
|
||||||
|
<ArrowUpCircle className="w-[18px] h-[18px] flex-shrink-0" style={{ color: 'var(--info)' }} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[13px] font-semibold" style={{ color: 'var(--ink)' }}>
|
||||||
|
Platform update available — v{versionInfo.latest}
|
||||||
|
</div>
|
||||||
|
<div className="text-[12px]" style={{ color: 'var(--ink-3)' }}>
|
||||||
|
You're on v{versionInfo.current}. Run{' '}
|
||||||
|
<code className="mono text-[11px]" style={{ color: 'var(--ink-2)' }}>docker compose pull && docker compose up -d</code>{' '}
|
||||||
|
to update.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="https://github.com/2GT-Media-Group-LLC/mikrotik-manager/releases"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-[12px] font-medium flex-shrink-0 hover:underline"
|
||||||
|
style={{ color: 'var(--info)' }}
|
||||||
|
>
|
||||||
|
Release notes →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Things to handle + Quick actions */}
|
{/* Things to handle + Quick actions */}
|
||||||
<div className="grid gap-4" style={{ gridTemplateColumns: '1.3fr 1fr' }}>
|
<div className="grid gap-4" style={{ gridTemplateColumns: '1.3fr 1fr' }}>
|
||||||
{/* Things to handle */}
|
{/* Things to handle */}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = 'v0.16.10 Beta';
|
export const APP_VERSION = 'v0.16.11 Beta';
|
||||||
|
|||||||
Reference in New Issue
Block a user