From b1e4944823e423342d9d509eb31c48aa87b5ca1e Mon Sep 17 00:00:00 2001 From: 2GT-Rich <107219997+2GT-Rich@users.noreply.github.com> Date: Sun, 17 May 2026 19:38:25 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20version=20update=20check=20=E2=80=94=20?= =?UTF-8?q?notify=20when=20newer=20version=20is=20on=20GitHub=20(v0.11.8?= =?UTF-8?q?=20Beta)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 2 +- backend/package.json | 2 +- backend/src/index.ts | 2 + backend/src/routes/system.ts | 83 +++++++++++++++++++++ frontend/package.json | 2 +- frontend/src/components/SecurityBanners.tsx | 56 ++++++++++++-- frontend/src/services/api.ts | 6 ++ frontend/src/version.ts | 2 +- 8 files changed, 143 insertions(+), 12 deletions(-) create mode 100644 backend/src/routes/system.ts diff --git a/README.md b/README.md index 964f46f..cc243e9 100644 --- a/README.md +++ b/README.md @@ -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. -![Version](https://img.shields.io/badge/version-0.11.7_Beta-blue) +![Version](https://img.shields.io/badge/version-0.11.8_Beta-blue) ![License](https://img.shields.io/badge/license-AGPLv3-blue) ![Docker](https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white) diff --git a/backend/package.json b/backend/package.json index f76847f..20d8c99 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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": { diff --git a/backend/src/index.ts b/backend/src/index.ts index d82e37b..f6bb679 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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); diff --git a/backend/src/routes/system.ts b/backend/src/routes/system.ts new file mode 100644 index 0000000..b954c70 --- /dev/null +++ b/backend/src/routes/system.ts @@ -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; diff --git a/frontend/package.json b/frontend/package.json index 2bf26a5..0236e95 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.11.7-beta", + "version": "0.11.8-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/SecurityBanners.tsx b/frontend/src/components/SecurityBanners.tsx index 4cf2c0f..90ed3e7 100644 --- a/frontend/src/components/SecurityBanners.tsx +++ b/frontend/src/components/SecurityBanners.tsx @@ -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(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 (
+ {updateInfo && !updateDismissed && ( +
+
+ + + Update available:{' '} + v{updateInfo.latest} is on GitHub.{' '} + + View release + {' '} + and run docker compose pull && docker compose up -d to update. + +
+ +
+ )} {hasSecretWarning && (
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6ed9310..cbfaca1 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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?: { diff --git a/frontend/src/version.ts b/frontend/src/version.ts index d51e824..796a515 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.11.7 Beta'; +export const APP_VERSION = 'v0.11.8 Beta';