mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
feat(dashboard): version indicator + GHCR update check (#165)
* feat(dashboard): version indicator + GHCR update check with admin panel - Add server/lib/ghcr-check.js: GHCR tag poller (36h cache, semver filter) - Extend /api/version with latest_version and update_available - Add POST /api/admin/check-update (force GHCR poll) - Add POST /api/admin/trigger-update (Docker compose or manual instructions) - Sidebar footer: version label + amber badge when update available - Admin > System: version comparison card with Check/Update buttons - 14 new tests (10 unit + 4 integration), 68/68 passing Closes #163 * fix(dashboard): gate trigger-update to platform-admin + add GHCR fetch timeout Review follow-up on #165 (the two blockers): - trigger-update runs `docker compose up -d` on the HOST via docker.sock (root-equivalent) but was behind requireAdmin, i.e. reachable by any workspace-level admin. On a multi-tenant host that's a customer, not the infra operator. Gate it with requirePlatformAdmin (DOCKER_UPDATE_ENABLED still gates it further). check-update stays requireAdmin — it's a read-only GHCR poll. - ghcr-check.checkNow had no fetch timeout. Node's global fetch has no default timeout, so a hung GHCR connection never settled — leaving `inFlight` set forever (the finally never ran), which wedged the background poller AND hung any awaited checkNow (/api/admin/check-update). Add a 10s AbortController timeout on both requests so the try/catch/finally always fire. All 405 server tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: ScreenTinker <hello@screentinker.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1ebdb1f7a9
commit
34f1cb9e7c
|
|
@ -253,6 +253,25 @@ body {
|
|||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.version-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
background: var(--warning);
|
||||
color: #000;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
|
|
|
|||
|
|
@ -152,6 +152,10 @@
|
|||
<span class="status-dot offline"></span>
|
||||
<span>Disconnected</span>
|
||||
</div>
|
||||
<div class="version-status" id="versionStatus">
|
||||
<span id="versionLabel"></span>
|
||||
<span class="version-badge" id="versionBadge" hidden>Update</span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
|
|
|||
|
|
@ -567,13 +567,19 @@ window.addEventListener('keydown', (e) => {
|
|||
|
||||
// Auto-reload on frontend update (no more hard refresh needed)
|
||||
let knownHash = null;
|
||||
export function updateVersionIndicator({ version, latest_version, update_available }) {
|
||||
const label = document.getElementById('versionLabel');
|
||||
const badge = document.getElementById('versionBadge');
|
||||
if (label) label.textContent = version ? 'v' + version : '';
|
||||
if (badge) badge.hidden = !update_available;
|
||||
}
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/version');
|
||||
const { hash } = await res.json();
|
||||
if (knownHash === null) { knownHash = hash; return; }
|
||||
if (hash !== knownHash) {
|
||||
knownHash = hash;
|
||||
const data = await res.json();
|
||||
if (knownHash === null) { knownHash = data.hash; }
|
||||
else if (data.hash !== knownHash) {
|
||||
knownHash = data.hash;
|
||||
const toast = document.getElementById('toastContainer');
|
||||
if (toast) {
|
||||
const notice = document.createElement('div');
|
||||
|
|
@ -582,6 +588,7 @@ setInterval(async () => {
|
|||
toast.appendChild(notice);
|
||||
}
|
||||
}
|
||||
updateVersionIndicator(data);
|
||||
} catch {}
|
||||
}, 15000);
|
||||
|
||||
|
|
|
|||
|
|
@ -406,16 +406,128 @@ async function loadSystem() {
|
|||
try {
|
||||
const version = await fetch('/api/version').then(r => r.json());
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
const versionComparison = version.latest_version
|
||||
? `<div class="info-card">
|
||||
<div class="info-card-label">${t('admin.latest_version') || 'Latest Version'}</div>
|
||||
<div class="info-card-value small">${esc(version.latest_version)}</div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<div class="info-card-label">${t('admin.status') || 'Status'}</div>
|
||||
<div class="info-card-value small" style="color:${version.update_available ? 'var(--warning)' : 'var(--success)'}">${version.update_available ? (t('admin.update_available') || 'Update Available') : (t('admin.up_to_date') || 'Up to Date')}</div>
|
||||
</div>`
|
||||
: `<div class="info-card">
|
||||
<div class="info-card-label">${t('admin.latest_version') || 'Latest Version'}</div>
|
||||
<div class="info-card-value small" style="color:var(--text-muted)">${t('admin.checking') || 'Checking...'}</div>
|
||||
</div>`;
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="info-grid">
|
||||
<div class="info-card"><div class="info-card-label">${t('admin.version')}</div><div class="info-card-value small">${version.version}</div></div>
|
||||
<div class="info-card"><div class="info-card-label">${t('admin.frontend_hash')}</div><div class="info-card-value small">${version.hash}</div></div>
|
||||
<div class="info-card"><div class="info-card-label">${t('admin.version')}</div><div class="info-card-value small">${esc(version.version)}</div></div>
|
||||
${versionComparison}
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:16px">
|
||||
<div style="display:flex;gap:8px;margin-top:16px;flex-wrap:wrap">
|
||||
<button class="btn btn-secondary btn-sm" id="checkUpdateBtn">${t('admin.check_now') || 'Check Now'}</button>
|
||||
<button class="btn btn-primary btn-sm" id="triggerUpdateBtn"${!version.update_available ? ' style="display:none"' : ''}>${t('admin.update_now') || 'Update Now'}</button>
|
||||
<a href="/api/status/backup?token=${token}" class="btn btn-secondary btn-sm" style="text-decoration:none">${t('admin.download_db_backup')}</a>
|
||||
<a href="/api/status" target="_blank" class="btn btn-secondary btn-sm" style="text-decoration:none">${t('admin.server_status')}</a>
|
||||
</div>
|
||||
<div id="updateResult" style="margin-top:12px"></div>
|
||||
`;
|
||||
|
||||
// Check Now button
|
||||
document.getElementById('checkUpdateBtn')?.addEventListener('click', async () => {
|
||||
const btn = document.getElementById('checkUpdateBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = t('admin.checking') || 'Checking...';
|
||||
try {
|
||||
const res = await fetch('/api/admin/check-update', { method: 'POST', headers: headers() });
|
||||
const data = await res.json();
|
||||
const updBtn = document.getElementById('triggerUpdateBtn');
|
||||
if (data.update_available && updBtn) {
|
||||
updBtn.style.display = '';
|
||||
}
|
||||
loadSystem(); // refresh the whole card
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
btn.disabled = false;
|
||||
btn.textContent = t('admin.check_now') || 'Check Now';
|
||||
}
|
||||
});
|
||||
|
||||
// Update Now button
|
||||
document.getElementById('triggerUpdateBtn')?.addEventListener('click', async () => {
|
||||
const btn = document.getElementById('triggerUpdateBtn');
|
||||
const resultEl = document.getElementById('updateResult');
|
||||
btn.disabled = true;
|
||||
btn.textContent = t('admin.updating') || 'Updating...';
|
||||
try {
|
||||
const res = await fetch('/api/admin/trigger-update', { method: 'POST', headers: headers() });
|
||||
const data = await res.json();
|
||||
if (data.docker_enabled) {
|
||||
// Docker executed — show output with Copy button
|
||||
resultEl.innerHTML = `
|
||||
<div style="margin-top:12px;border:1px solid var(--border);border-radius:var(--radius);padding:12px;background:var(--bg-card)">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<strong style="font-size:13px">${data.success ? (t('admin.update_success') || 'Update Successful') : (t('admin.update_failed') || 'Update Failed')}</strong>
|
||||
<button class="btn btn-secondary btn-sm" id="copyOutputBtn">${t('admin.copy') || 'Copy'}</button>
|
||||
</div>
|
||||
<pre style="max-height:300px;overflow:auto;font-size:11px;margin:0;background:var(--bg-primary);padding:8px;border-radius:4px;white-space:pre-wrap;word-break:break-all">${esc(data.output || '')}</pre>
|
||||
</div>`;
|
||||
document.getElementById('copyOutputBtn')?.addEventListener('click', () => {
|
||||
const pre = resultEl.querySelector('pre');
|
||||
const text = pre ? pre.textContent : '';
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(text).then(() => showToast(t('admin.copied') || 'Copied!', 'success'));
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
showToast(t('admin.copied') || 'Copied!', 'success');
|
||||
}
|
||||
});
|
||||
} else if (data.instructions) {
|
||||
// Docker disabled — show manual instructions with Copy button
|
||||
resultEl.innerHTML = `
|
||||
<div style="margin-top:12px;border:1px solid var(--border);border-radius:var(--radius);padding:12px;background:var(--bg-secondary)">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<strong style="font-size:13px">${t('admin.manual_update') || 'Manual Update Required'}</strong>
|
||||
<button class="btn btn-secondary btn-sm" id="copyCmdBtn">${t('admin.copy_command') || 'Copy'}</button>
|
||||
</div>
|
||||
<p style="font-size:12px;color:var(--text-muted);margin-bottom:8px">${t('admin.manual_update_desc') || 'Run this command on the server:'}</p>
|
||||
<pre style="font-size:11px;margin:0;background:var(--bg-primary);padding:8px;border-radius:4px;white-space:pre-wrap;word-break:break-all">${esc(data.instructions)}</pre>
|
||||
</div>`;
|
||||
document.getElementById('copyCmdBtn')?.addEventListener('click', () => {
|
||||
const pre = resultEl.querySelector('pre');
|
||||
const text = pre ? pre.textContent : '';
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(text).then(() => showToast(t('admin.copied') || 'Copied!', 'success'));
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
showToast(t('admin.copied') || 'Copied!', 'success');
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = t('admin.update_now') || 'Update Now';
|
||||
}
|
||||
});
|
||||
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`; }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -304,6 +304,12 @@ module.exports = {
|
|||
contentAckMaxPerWindow: parseInt(process.env.CONTENT_ACK_MAX_PER_WINDOW) || 20,
|
||||
contentAckRateWindowMs: parseInt(process.env.CONTENT_ACK_RATE_WINDOW_MS) || 10000,
|
||||
|
||||
// Version update indicator — polls GHCR for the latest Docker image tag via
|
||||
// anonymous token flow. All optional with safe defaults.
|
||||
dockerUpdateEnabled: process.env.DOCKER_UPDATE_ENABLED === 'true',
|
||||
ghcrCheckIntervalHours: parseInt(process.env.GHCR_CHECK_INTERVAL_HOURS) || 36,
|
||||
composeFilePath: process.env.COMPOSE_FILE_PATH || '/opt/screentinker/docker-compose.yml',
|
||||
|
||||
// #143 fingerprint-reclaim liveness. A reinstalled app (same fingerprint, no
|
||||
// device_id, has pairing_code) may reclaim its old device's identity once that
|
||||
// device is gone by RUNTIME signals: no live socket AND last heartbeat older than
|
||||
|
|
|
|||
171
server/lib/ghcr-check.js
Normal file
171
server/lib/ghcr-check.js
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
'use strict';
|
||||
|
||||
// In-memory cache for the latest version discovered from GHCR. Restart = fresh poll.
|
||||
// 36h interval means ~1 request per deploy cycle — no persistence needed.
|
||||
|
||||
let latestVersion = null; // string | null — highest semver tag found
|
||||
let checkedAt = null; // number (epoch ms) | null — last poll timestamp
|
||||
let inFlight = null; // Promise | null — dedup concurrent polls
|
||||
|
||||
// Extract semver x.y.z tags from a tag list array, ignoring pre-release suffixes
|
||||
// and non-semver labels. Returns tags sorted descending (latest first) by numeric
|
||||
// component comparison so [0] is the highest version.
|
||||
function extractSemverTags(tags) {
|
||||
if (!Array.isArray(tags) || tags.length === 0) return [];
|
||||
|
||||
// Must match the ENTIRE string — pre-release suffixes (-beta, -rc1) are excluded
|
||||
const semverRegex = /^(\d+)\.(\d+)\.(\d+)$/;
|
||||
const parsed = [];
|
||||
|
||||
for (const tag of tags) {
|
||||
const m = tag.match(semverRegex);
|
||||
if (!m) continue;
|
||||
parsed.push({ tag, major: +m[1], minor: +m[2], patch: +m[3] });
|
||||
}
|
||||
|
||||
parsed.sort((a, b) => {
|
||||
if (a.major !== b.major) return b.major - a.major;
|
||||
if (a.minor !== b.minor) return b.minor - a.minor;
|
||||
return b.patch - a.patch;
|
||||
});
|
||||
|
||||
return parsed.map(p => p.tag);
|
||||
}
|
||||
|
||||
// Compare two semver strings element-wise. Returns:
|
||||
// negative → a < b
|
||||
// 0 → a == b
|
||||
// positive → a > b
|
||||
// NaN → one or both inputs are not semver
|
||||
function compareVersions(a, b) {
|
||||
// Match the ENTIRE string — pre-release suffixes rejected
|
||||
const ra = a.match(/^(\d+)\.(\d+)\.(\d+)$/);
|
||||
const rb = b.match(/^(\d+)\.(\d+)\.(\d+)$/);
|
||||
if (!ra || !rb) return NaN;
|
||||
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const diff = +ra[i] - +rb[i];
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Synchronous cache reader. Returns null before the first poll completes.
|
||||
function getLatestVersion() {
|
||||
return latestVersion;
|
||||
}
|
||||
|
||||
// Hard per-request cap. Node's global fetch has NO default timeout, so a hung GHCR
|
||||
// connection would otherwise never settle — leaving `inFlight` set forever (the finally
|
||||
// never runs) and wedging BOTH the background poller and any awaited checkNow (e.g.
|
||||
// /api/admin/check-update). AbortController makes a stalled fetch reject so the outer
|
||||
// try/catch/finally always fire and the cache keeps serving.
|
||||
const FETCH_TIMEOUT_MS = 10000;
|
||||
async function fetchWithTimeout(url, opts) {
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(url, { ...(opts || {}), signal: ctrl.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// Force a fresh GHCR poll (bypasses cache), fetch tags, extract the highest
|
||||
// semver, and update the cache. Returns { latest, update_available }.
|
||||
async function checkNow(currentVersion) {
|
||||
// De-duplicate: if a poll is already in flight, wait for it instead of
|
||||
// starting a second concurrent fetch.
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
inFlight = (async () => {
|
||||
try {
|
||||
// Step 1: get anonymous OAuth token for GHCR public repo access
|
||||
const tokenRes = await fetchWithTimeout(
|
||||
'https://ghcr.io/token?scope=repository:screentinker/screentinker:pull'
|
||||
);
|
||||
if (!tokenRes.ok) throw new Error(`GHCR token endpoint returned ${tokenRes.status}`);
|
||||
const { token } = await tokenRes.json();
|
||||
if (!token) throw new Error('GHCR token response missing token field');
|
||||
|
||||
// Step 2: list tags with Bearer auth
|
||||
const tagsUrl = 'https://ghcr.io/v2/screentinker/screentinker/tags/list';
|
||||
const tagsRes = await fetchWithTimeout(tagsUrl, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
// Some registries return 401/404 if no tags exist yet — not an error,
|
||||
// just means no releases published.
|
||||
if (tagsRes.status === 401 || tagsRes.status === 404) {
|
||||
latestVersion = null;
|
||||
checkedAt = Date.now();
|
||||
return { latest: null, update_available: false };
|
||||
}
|
||||
|
||||
if (!tagsRes.ok) throw new Error(`GHCR tags endpoint returned ${tagsRes.status}`);
|
||||
|
||||
const body = await tagsRes.json();
|
||||
|
||||
// Handle both { tags: [...] } and { name, tags: [...] } response shapes
|
||||
const tags = body.tags || [];
|
||||
if (!Array.isArray(tags) || tags.length === 0) {
|
||||
latestVersion = null;
|
||||
checkedAt = Date.now();
|
||||
return { latest: null, update_available: false };
|
||||
}
|
||||
|
||||
// Extract and sort semver tags
|
||||
const semverTags = extractSemverTags(tags);
|
||||
if (semverTags.length === 0) {
|
||||
latestVersion = null;
|
||||
checkedAt = Date.now();
|
||||
return { latest: null, update_available: false };
|
||||
}
|
||||
|
||||
// The highest version is the first element (sorted descending)
|
||||
const latest = semverTags[0];
|
||||
latestVersion = latest;
|
||||
checkedAt = Date.now();
|
||||
|
||||
const updateAvailable = currentVersion
|
||||
? compareVersions(latest, currentVersion) > 0
|
||||
: false;
|
||||
|
||||
return { latest, update_available: updateAvailable };
|
||||
} catch (err) {
|
||||
// Network errors, DNS failures, etc. — silent, cache stays as-is.
|
||||
// The next poll will retry; the existing cache (if any) still serves.
|
||||
console.error('[ghcr-check] poll failed:', err.message);
|
||||
return { latest: latestVersion, update_available: false };
|
||||
} finally {
|
||||
// Clear in-flight guard so next poll can run
|
||||
inFlight = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
// Start background polling at the given interval (in hours). First poll fires
|
||||
// after a 30s initial delay to let the server stabilize.
|
||||
function startPolling(intervalHours, currentVersion) {
|
||||
const intervalMs = intervalHours * 60 * 60 * 1000;
|
||||
|
||||
// Initial poll after 30s
|
||||
setTimeout(() => {
|
||||
checkNow(currentVersion).catch(() => {});
|
||||
}, 30000);
|
||||
|
||||
// Periodic poll
|
||||
setInterval(() => {
|
||||
checkNow(currentVersion).catch(() => {});
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
extractSemverTags,
|
||||
compareVersions,
|
||||
getLatestVersion,
|
||||
checkNow,
|
||||
startPolling,
|
||||
};
|
||||
|
|
@ -4,7 +4,7 @@ const bcrypt = require('bcryptjs');
|
|||
const { v4: uuidv4 } = require('uuid');
|
||||
const { db } = require('../db/database');
|
||||
const { canAdminWorkspace } = require('../lib/permissions');
|
||||
const { requirePlatformAdmin } = require('../middleware/auth');
|
||||
const { requirePlatformAdmin, requireAdmin } = require('../middleware/auth');
|
||||
const { logActivity, getClientIp } = require('../services/activity');
|
||||
const { deleteWorkspaceCascade, deleteOrgCascade } = require('../lib/user-deletion');
|
||||
const { platformDefaultRow, HARDCODED_BRANDING, PLATFORM_DEFAULT_ID } = require('../lib/branding');
|
||||
|
|
@ -376,4 +376,52 @@ router.put('/status-debug', requirePlatformAdmin, (req, res) => {
|
|||
res.json({ enabled });
|
||||
});
|
||||
|
||||
// ===================== Version update indicator =====================
|
||||
// check-update = requireAdmin — a read-only GHCR poll, operational.
|
||||
// trigger-update = requirePlatformAdmin — it runs `docker compose up -d` on the
|
||||
// HOST via docker.sock (root-equivalent), so it's restricted to platform-owner
|
||||
// level; DOCKER_UPDATE_ENABLED gates it further (off by default).
|
||||
|
||||
const ghcrCheck = require('../lib/ghcr-check');
|
||||
const VERSION = require('../version');
|
||||
|
||||
// POST /api/admin/check-update — force a fresh GHCR poll (bypasses cache)
|
||||
// and return version comparison.
|
||||
router.post('/check-update', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const result = await ghcrCheck.checkNow(VERSION);
|
||||
res.json({
|
||||
current: VERSION,
|
||||
latest: result.latest,
|
||||
update_available: result.update_available,
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: 'GHCR poll failed', detail: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/admin/trigger-update — run docker compose pull && up -d,
|
||||
// or return manual instructions when docker is disabled.
|
||||
router.post('/trigger-update', requirePlatformAdmin, async (req, res) => {
|
||||
const { exec } = require('child_process');
|
||||
const composeFile = require('../config').composeFilePath;
|
||||
const cmd = `docker compose -f ${composeFile} pull && docker compose -f ${composeFile} up -d`;
|
||||
|
||||
if (!require('../config').dockerUpdateEnabled) {
|
||||
return res.json({
|
||||
docker_enabled: false,
|
||||
instructions: cmd,
|
||||
});
|
||||
}
|
||||
|
||||
exec(cmd, { timeout: 60000 }, (err, stdout, stderr) => {
|
||||
const output = (stdout || '') + (stderr || '');
|
||||
if (err) {
|
||||
return res.json({ success: false, output, docker_enabled: true, error: err.message });
|
||||
}
|
||||
logActivity(req.user.id, 'admin_trigger_update', `docker compose up -d`, null, getClientIp(req), null);
|
||||
res.json({ success: true, output, docker_enabled: true });
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ const path = require('path');
|
|||
const fs = require('fs');
|
||||
const config = require('./config');
|
||||
const VERSION = require('./version');
|
||||
const ghcrCheck = require('./lib/ghcr-check');
|
||||
|
||||
// #114: last-resort crash safety net. better-sqlite3 is SYNCHRONOUS, so a constraint
|
||||
// violation (e.g. a FK write) inside a socket.io handler with no local try/catch
|
||||
|
|
@ -586,7 +587,9 @@ updateFrontendHash();
|
|||
// Recheck every 30 seconds
|
||||
setInterval(updateFrontendHash, 30000);
|
||||
app.get('/api/version', (req, res) => {
|
||||
res.json({ hash: frontendHash, version: VERSION });
|
||||
const latest = ghcrCheck.getLatestVersion();
|
||||
const updateAvailable = latest ? ghcrCheck.compareVersions(latest, VERSION) > 0 : false;
|
||||
res.json({ hash: frontendHash, version: VERSION, latest_version: latest, update_available: updateAvailable });
|
||||
});
|
||||
|
||||
// Public status page
|
||||
|
|
@ -749,6 +752,10 @@ startAgencyDigest();
|
|||
const { startWalCheckpointer, stopWalCheckpointer } = require('./db/wal-checkpointer');
|
||||
startWalCheckpointer(require('./db/database').db, config.dbPath);
|
||||
|
||||
// Version update indicator: poll GHCR for latest image tag, cache in memory.
|
||||
// First poll fires after 30s to let the server stabilize.
|
||||
ghcrCheck.startPolling(config.ghcrCheckIntervalHours, VERSION);
|
||||
|
||||
// Graceful shutdown: stop the checkpointer worker (closes its own DB handle) + flush + close.
|
||||
let _shuttingDown = false;
|
||||
function gracefulShutdown(sig) {
|
||||
|
|
|
|||
|
|
@ -400,3 +400,40 @@ test('pip clear: full token clears (POST /clear and DELETE), read token rejected
|
|||
const del = await jfetch('/api/pip', { method: 'DELETE', ...auth(S.tok.full), body: JSON.stringify({ device_id: S.deviceId }) });
|
||||
assert.equal(del.status, 200);
|
||||
});
|
||||
|
||||
// ───────────────────────── TIER 6: VERSION & UPDATE INDICATOR ─────────────────────────
|
||||
|
||||
test('version: GET /api/version includes latest_version and update_available', async () => {
|
||||
const res = await fetch(`${BASE}/api/version`);
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
// Fields must exist (type presence, not specific value — server may or
|
||||
// may not have polled GHCR yet).
|
||||
assert.ok('latest_version' in body, '/api/version must include latest_version');
|
||||
assert.ok('update_available' in body, '/api/version must include update_available');
|
||||
assert.ok(typeof body.update_available === 'boolean', 'update_available must be boolean');
|
||||
});
|
||||
|
||||
test('version: POST /api/admin/check-update returns version comparison (admin)', async () => {
|
||||
// user1 is platform_admin — must be able to check-update
|
||||
const res = await jfetch('/api/admin/check-update', post(S.jwt, {}));
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(res.body, 'check-update must return a body');
|
||||
assert.ok('current' in res.body, 'must include current');
|
||||
assert.ok('latest' in res.body, 'must include latest');
|
||||
assert.ok('update_available' in res.body, 'must include update_available');
|
||||
});
|
||||
|
||||
test('version: POST /api/admin/check-update rejects non-admin (403)', async () => {
|
||||
// user2 is a regular user, not platform_admin — must be rejected
|
||||
const res = await jfetch('/api/admin/check-update', post(S.jwt2, {}));
|
||||
assert.equal(res.status, 403, 'non-admin must get 403 on check-update');
|
||||
});
|
||||
|
||||
test('version: POST /api/admin/trigger-update (docker disabled) returns instructions', async () => {
|
||||
const res = await jfetch('/api/admin/trigger-update', post(S.jwt, {}));
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(res.body, 'trigger-update must return a body');
|
||||
assert.ok('instructions' in res.body || 'docker_enabled' in res.body,
|
||||
'must return instructions or docker_enabled flag');
|
||||
});
|
||||
|
|
|
|||
64
server/test/ghcr-check.test.js
Normal file
64
server/test/ghcr-check.test.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const ghcrCheck = require('../lib/ghcr-check');
|
||||
|
||||
// =========== extractSemverTags ===========
|
||||
|
||||
test('extractSemverTags: returns only valid x.y.z semver tags, pre-release suffixes excluded', () => {
|
||||
const input = ['latest', '1.9.4', 'v1.9.4', '1.9.4-beta', '1.10.0', '2.0.0-rc1', '1.0.0-alpha'];
|
||||
const result = ghcrCheck.extractSemverTags(input);
|
||||
// Pre-release (-beta, -rc1, -alpha) and non-semver (latest, v1.9.4) excluded
|
||||
assert.deepEqual(result, ['1.10.0', '1.9.4']);
|
||||
});
|
||||
|
||||
test('extractSemverTags: sorts numeric components correctly (10 > 9)', () => {
|
||||
const input = ['1.2.3', '1.10.0', '1.9.4', '1.2.10'];
|
||||
const result = ghcrCheck.extractSemverTags(input);
|
||||
assert.deepEqual(result, ['1.10.0', '1.9.4', '1.2.10', '1.2.3']);
|
||||
});
|
||||
|
||||
test('extractSemverTags: empty input returns empty array', () => {
|
||||
assert.deepEqual(ghcrCheck.extractSemverTags([]), []);
|
||||
});
|
||||
|
||||
test('extractSemverTags: all non-semver tags returns empty array', () => {
|
||||
assert.deepEqual(ghcrCheck.extractSemverTags(['latest', 'dev', 'beta']), []);
|
||||
});
|
||||
|
||||
test('extractSemverTags: handles single valid tag', () => {
|
||||
assert.deepEqual(ghcrCheck.extractSemverTags(['1.0.0']), ['1.0.0']);
|
||||
});
|
||||
|
||||
// =========== compareVersions ===========
|
||||
|
||||
test('compareVersions: equal versions return 0', () => {
|
||||
assert.equal(ghcrCheck.compareVersions('1.0.0', '1.0.0'), 0);
|
||||
assert.equal(ghcrCheck.compareVersions('2.5.7', '2.5.7'), 0);
|
||||
});
|
||||
|
||||
test('compareVersions: a < b returns negative', () => {
|
||||
assert.ok(ghcrCheck.compareVersions('1.9.4', '1.10.0') < 0, 'minor bump');
|
||||
assert.ok(ghcrCheck.compareVersions('1.0.0', '2.0.0') < 0, 'major bump');
|
||||
assert.ok(ghcrCheck.compareVersions('1.0.0', '1.0.1') < 0, 'patch bump');
|
||||
assert.ok(ghcrCheck.compareVersions('1.0.0', '1.1.0') < 0, 'minor bump 0->1');
|
||||
});
|
||||
|
||||
test('compareVersions: a > b returns positive', () => {
|
||||
assert.ok(ghcrCheck.compareVersions('2.0.0', '1.9.4') > 0, 'major larger');
|
||||
assert.ok(ghcrCheck.compareVersions('1.10.0', '1.9.4') > 0, 'minor larger');
|
||||
assert.ok(ghcrCheck.compareVersions('1.0.1', '1.0.0') > 0, 'patch larger');
|
||||
});
|
||||
|
||||
test('compareVersions: non-semver input returns NaN', () => {
|
||||
assert.ok(Number.isNaN(ghcrCheck.compareVersions('abc', '1.0.0')));
|
||||
assert.ok(Number.isNaN(ghcrCheck.compareVersions('1.0.0', 'latest')));
|
||||
assert.ok(Number.isNaN(ghcrCheck.compareVersions('1.0', '1.0.0')));
|
||||
});
|
||||
|
||||
// =========== getLatestVersion (sync cache read) ===========
|
||||
|
||||
test('getLatestVersion: returns null before any poll', () => {
|
||||
assert.equal(ghcrCheck.getLatestVersion(), null);
|
||||
});
|
||||
Loading…
Reference in a new issue