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.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mikrotik-manager-backend",
|
||||
"version": "0.11.7-beta",
|
||||
"version": "0.11.8-beta",
|
||||
"description": "Mikrotik Network Management Platform - Backend API",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -47,6 +47,7 @@ import configTemplatesRoutes from './routes/configTemplates';
|
||||
import wirelessRoutes from './routes/wireless';
|
||||
import networkServicesRoutes from './routes/networkServices';
|
||||
import credentialPresetsRoutes from './routes/credentialPresets';
|
||||
import systemRoutes from './routes/system';
|
||||
import { auditMiddleware } from './middleware/auditMiddleware';
|
||||
|
||||
const app = express();
|
||||
@@ -217,6 +218,7 @@ app.use('/api/audit-log', auditLogRoutes);
|
||||
app.use('/api/tags', tagsRoutes);
|
||||
app.use('/api/maintenance-windows', maintenanceWindowsRoutes);
|
||||
app.use('/api/config-templates', configTemplatesRoutes);
|
||||
app.use('/api/system', systemRoutes);
|
||||
|
||||
// ─── Error Handler ────────────────────────────────────────────────────────────
|
||||
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",
|
||||
"version": "0.11.7-beta",
|
||||
"version": "0.11.8-beta",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { AlertTriangle, ShieldAlert } from 'lucide-react';
|
||||
import { authApi } from '../services/api';
|
||||
import { AlertTriangle, ShieldAlert, ArrowUpCircle, X } from 'lucide-react';
|
||||
import { authApi, systemApi } from '../services/api';
|
||||
|
||||
interface SecurityStatus {
|
||||
warnings: string[];
|
||||
@@ -9,29 +9,69 @@ interface SecurityStatus {
|
||||
|
||||
export default function SecurityBanners() {
|
||||
const [status, setStatus] = useState<SecurityStatus | null>(null);
|
||||
const [updateInfo, setUpdateInfo] = useState<{ latest: string } | null>(null);
|
||||
const [updateDismissed, setUpdateDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
authApi.securityStatus()
|
||||
.then((r) => setStatus(r.data))
|
||||
.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('encryption_key_default');
|
||||
const hasAdminWarning = status.warnings.includes('admin_password_default');
|
||||
status.warnings.includes('encryption_key_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',
|
||||
status?.warnings.includes('jwt_secret_default') && 'JWT_SECRET',
|
||||
status?.warnings.includes('encryption_key_default') && 'ENCRYPTION_KEY',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' and ');
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<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" />
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────────────
|
||||
export const eventsApi = {
|
||||
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