feat: version update check — notify when newer version is on GitHub (v0.11.8 Beta)
Backend: GET /api/system/version-check fetches frontend/package.json from the main branch on GitHub, caches result in app_settings for 24 hours, and compares semver against the running version. Fails silently (no banner) for air-gapped or firewalled deployments. Frontend: dismissible blue update banner in SecurityBanners (rendered between TopBar and main content) — only visible when a newer version is detected. Shows target version and docker compose update command.
This commit is contained in:
@@ -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.11.7-beta",
|
"version": "0.11.8-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": {
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import configTemplatesRoutes from './routes/configTemplates';
|
|||||||
import wirelessRoutes from './routes/wireless';
|
import wirelessRoutes from './routes/wireless';
|
||||||
import networkServicesRoutes from './routes/networkServices';
|
import networkServicesRoutes from './routes/networkServices';
|
||||||
import credentialPresetsRoutes from './routes/credentialPresets';
|
import credentialPresetsRoutes from './routes/credentialPresets';
|
||||||
|
import systemRoutes from './routes/system';
|
||||||
import { auditMiddleware } from './middleware/auditMiddleware';
|
import { auditMiddleware } from './middleware/auditMiddleware';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -217,6 +218,7 @@ app.use('/api/audit-log', auditLogRoutes);
|
|||||||
app.use('/api/tags', tagsRoutes);
|
app.use('/api/tags', tagsRoutes);
|
||||||
app.use('/api/maintenance-windows', maintenanceWindowsRoutes);
|
app.use('/api/maintenance-windows', maintenanceWindowsRoutes);
|
||||||
app.use('/api/config-templates', configTemplatesRoutes);
|
app.use('/api/config-templates', configTemplatesRoutes);
|
||||||
|
app.use('/api/system', systemRoutes);
|
||||||
|
|
||||||
// ─── Error Handler ────────────────────────────────────────────────────────────
|
// ─── Error Handler ────────────────────────────────────────────────────────────
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { verifyToken } from '../middleware/auth';
|
||||||
|
import { query } from '../config/database';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use(verifyToken);
|
||||||
|
|
||||||
|
const CURRENT_VERSION = process.env.npm_package_version || '0.0.0';
|
||||||
|
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
|
||||||
|
|
||||||
|
// Normalise "0.11.7-beta" or "v0.11.7 Beta" → [0, 11, 7]
|
||||||
|
function parseVersion(v: string): number[] {
|
||||||
|
return v
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^0-9.]/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.split(/[\s.]+/)
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(Number);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNewer(latest: number[], current: number[]): boolean {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
if ((latest[i] ?? 0) > (current[i] ?? 0)) return true;
|
||||||
|
if ((latest[i] ?? 0) < (current[i] ?? 0)) return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/system/version-check
|
||||||
|
// Returns { current, latest, update_available } — cached 24 h in app_settings.
|
||||||
|
router.get('/version-check', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
// Check cache
|
||||||
|
const cached = await query<{ value: { version: string; checked_at: string } }>(
|
||||||
|
`SELECT value FROM app_settings WHERE key = 'version_check_cache'`
|
||||||
|
);
|
||||||
|
const row = cached[0]?.value;
|
||||||
|
if (row && Date.now() - new Date(row.checked_at).getTime() < CACHE_TTL_MS) {
|
||||||
|
const latestParsed = parseVersion(row.version);
|
||||||
|
const currentParsed = parseVersion(CURRENT_VERSION);
|
||||||
|
return res.json({
|
||||||
|
current: CURRENT_VERSION,
|
||||||
|
latest: row.version,
|
||||||
|
update_available: isNewer(latestParsed, currentParsed),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch latest version from GitHub
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 8_000);
|
||||||
|
let latestVersion = CURRENT_VERSION;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(RAW_URL, { signal: controller.signal });
|
||||||
|
if (resp.ok) {
|
||||||
|
const pkg = await resp.json() as { version?: string };
|
||||||
|
if (pkg.version) latestVersion = pkg.version;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache result
|
||||||
|
await query(
|
||||||
|
`INSERT INTO app_settings (key, value) VALUES ('version_check_cache', $1)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`,
|
||||||
|
[JSON.stringify({ version: latestVersion, checked_at: new Date().toISOString() })]
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
current: CURRENT_VERSION,
|
||||||
|
latest: latestVersion,
|
||||||
|
update_available: isNewer(parseVersion(latestVersion), parseVersion(CURRENT_VERSION)),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Fail silently — air-gapped or unreachable GitHub
|
||||||
|
return res.json({ current: CURRENT_VERSION, latest: null, update_available: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mikrotik-manager-frontend",
|
"name": "mikrotik-manager-frontend",
|
||||||
"version": "0.11.7-beta",
|
"version": "0.11.8-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 } from 'lucide-react';
|
import { AlertTriangle, ShieldAlert, ArrowUpCircle, X } from 'lucide-react';
|
||||||
import { authApi } from '../services/api';
|
import { authApi, systemApi } from '../services/api';
|
||||||
|
|
||||||
interface SecurityStatus {
|
interface SecurityStatus {
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
@@ -9,29 +9,69 @@ 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(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!status || status.warnings.length === 0) return null;
|
const noWarnings = !status || status.warnings.length === 0;
|
||||||
|
const noUpdate = !updateInfo || updateDismissed;
|
||||||
|
if (noWarnings && noUpdate) return null;
|
||||||
|
|
||||||
const hasSecretWarning =
|
const hasSecretWarning = !!status && (
|
||||||
status.warnings.includes('jwt_secret_default') ||
|
status.warnings.includes('jwt_secret_default') ||
|
||||||
status.warnings.includes('encryption_key_default');
|
status.warnings.includes('encryption_key_default')
|
||||||
const hasAdminWarning = status.warnings.includes('admin_password_default');
|
);
|
||||||
|
const hasAdminWarning = !!status && status.warnings.includes('admin_password_default');
|
||||||
|
|
||||||
const secretNames = [
|
const secretNames = [
|
||||||
status.warnings.includes('jwt_secret_default') && 'JWT_SECRET',
|
status?.warnings.includes('jwt_secret_default') && 'JWT_SECRET',
|
||||||
status.warnings.includes('encryption_key_default') && 'ENCRYPTION_KEY',
|
status?.warnings.includes('encryption_key_default') && 'ENCRYPTION_KEY',
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' and ');
|
.join(' and ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{updateInfo && !updateDismissed && (
|
||||||
|
<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 && (
|
{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" />
|
||||||
|
|||||||
@@ -343,6 +343,12 @@ export const deviceToolsApi = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── System ───────────────────────────────────────────────────────────────────
|
||||||
|
export const systemApi = {
|
||||||
|
versionCheck: () =>
|
||||||
|
api.get<{ current: string; latest: string | null; update_available: boolean }>('/system/version-check'),
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Events ───────────────────────────────────────────────────────────────────
|
// ─── Events ───────────────────────────────────────────────────────────────────
|
||||||
export const eventsApi = {
|
export const eventsApi = {
|
||||||
list: (params?: {
|
list: (params?: {
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = 'v0.11.7 Beta';
|
export const APP_VERSION = 'v0.11.8 Beta';
|
||||||
|
|||||||
Reference in New Issue
Block a user