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:
2GT-Rich
2026-05-17 19:38:25 -07:00
parent bf62b9e10f
commit b1e4944823
8 changed files with 143 additions and 12 deletions
+2
View File
@@ -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);
+83
View File
@@ -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;