From d64d23fc5011f00ffd740589078613634e29bb84 Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Thu, 19 Mar 2026 21:28:48 -0400 Subject: [PATCH] feat(system): background image update checker with stack badges Adds an ImageUpdateService that queries OCI registry manifest endpoints every 6 hours to compare remote digests against local RepoDigests. Results are cached in a new stack_update_status SQLite table. A pulsing blue dot badge appears in the stack sidebar for stacks with updates available. Manual refresh available via POST /api/image-updates/refresh with a 10-minute rate limit. --- CHANGELOG.md | 1 + backend/src/index.ts | 32 +++ backend/src/services/DatabaseService.ts | 23 ++ backend/src/services/ImageUpdateService.ts | 298 +++++++++++++++++++++ frontend/src/components/EditorLayout.tsx | 21 ++ 5 files changed, 375 insertions(+) create mode 100644 backend/src/services/ImageUpdateService.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ded516d8..eed4f5f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +- **Added:** Background image update checker — `ImageUpdateService` polls OCI-compliant registries (Docker Hub, GHCR, LSCR, etc.) every 6 hours using manifest digest comparison against local `RepoDigests`. Results cached in a new `stack_update_status` SQLite table. A pulsing blue dot badge appears in the stack list sidebar for stacks with available updates. Manual refresh available via `POST /api/image-updates/refresh` (rate-limited to once per 10 minutes). - **Fixed:** `AppStoreView` and `GlobalObservabilityView` using raw `fetch()` instead of `apiFetch()` — all calls now inject the `x-node-id` header so templates, deploys, stacks, and logs are correctly proxied to the active remote node. - **Fixed:** `HostConsole` WebSocket URL missing `?nodeId=` query parameter — the upgrade handler now receives the active node ID and routes the PTY session to the correct node. - **Added:** Two-tier Option A scoped navigation UX — a context pill in the top header bar always shows the active node name (pulsing blue for remote, green for local). diff --git a/backend/src/index.ts b/backend/src/index.ts index f82d503d..26a88aa2 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -21,6 +21,7 @@ import { HostTerminalService } from './services/HostTerminalService'; import { DatabaseService } from './services/DatabaseService'; import { NotificationService } from './services/NotificationService'; import { MonitorService } from './services/MonitorService'; +import { ImageUpdateService } from './services/ImageUpdateService'; import { templateService } from './services/TemplateService'; import { ErrorParser } from './utils/ErrorParser'; import { NodeRegistry } from './services/NodeRegistry'; @@ -1530,6 +1531,34 @@ app.post('/api/templates/deploy', async (req: Request, res: Response) => { } }); +// ========================= +// Image Update Checker API +// ========================= + +app.get('/api/image-updates', authMiddleware, (_req: Request, res: Response) => { + try { + const updates = DatabaseService.getInstance().getStackUpdateStatus(); + res.json(updates); + } catch (error) { + console.error('Failed to fetch image update status:', error); + res.status(500).json({ error: 'Failed to fetch image update status' }); + } +}); + +app.post('/api/image-updates/refresh', authMiddleware, (_req: Request, res: Response) => { + try { + const triggered = ImageUpdateService.getInstance().triggerManualRefresh(); + if (!triggered) { + res.status(429).json({ error: 'Rate limited. Please wait at least 10 minutes between manual refreshes.' }); + return; + } + res.json({ success: true, message: 'Image update check started in background.' }); + } catch (error) { + console.error('Failed to trigger image update refresh:', error); + res.status(500).json({ error: 'Failed to trigger refresh' }); + } +}); + // ========================= // Node Management API // ========================= @@ -1673,6 +1702,9 @@ async function startServer() { // Start Background Watchdog MonitorService.getInstance().start(); + // Start Background Image Update Checker + ImageUpdateService.getInstance().start(); + server.listen(PORT, () => { console.log(`Server running on port ${PORT}`); }); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 46b73177..7125931f 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -121,6 +121,12 @@ export class DatabaseService { CREATE INDEX IF NOT EXISTS idx_metrics_timestamp ON container_metrics(timestamp); CREATE INDEX IF NOT EXISTS idx_metrics_container ON container_metrics(container_id); + CREATE TABLE IF NOT EXISTS stack_update_status ( + stack_name TEXT PRIMARY KEY, + has_update INTEGER DEFAULT 0, + checked_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS nodes ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, @@ -427,4 +433,21 @@ export class DatabaseService { public updateNodeStatus(id: number, status: 'online' | 'offline' | 'unknown'): void { this.db.prepare('UPDATE nodes SET status = ? WHERE id = ?').run(status, id); } + + // --- Stack Update Status --- + + public upsertStackUpdateStatus(stackName: string, hasUpdate: boolean, checkedAt: number): void { + this.db.prepare( + 'INSERT OR REPLACE INTO stack_update_status (stack_name, has_update, checked_at) VALUES (?, ?, ?)' + ).run(stackName, hasUpdate ? 1 : 0, checkedAt); + } + + public getStackUpdateStatus(): Record { + const rows = this.db.prepare('SELECT stack_name, has_update FROM stack_update_status').all() as any[]; + const result: Record = {}; + for (const row of rows) { + result[row.stack_name] = row.has_update === 1; + } + return result; + } } diff --git a/backend/src/services/ImageUpdateService.ts b/backend/src/services/ImageUpdateService.ts new file mode 100644 index 00000000..06df6d33 --- /dev/null +++ b/backend/src/services/ImageUpdateService.ts @@ -0,0 +1,298 @@ +import https from 'https'; +import http from 'http'; +import DockerController from './DockerController'; +import { DatabaseService } from './DatabaseService'; + +// ─── Image ref parsing ──────────────────────────────────────────────────────── + +interface ParsedRef { + registry: string; // e.g. "registry-1.docker.io", "lscr.io", "ghcr.io" + repo: string; // e.g. "library/nginx", "linuxserver/sonarr" + tag: string; // e.g. "latest", "1.25" +} + +function parseImageRef(imageRef: string): ParsedRef | null { + if (imageRef.startsWith('sha256:')) return null; + + // Strip digest pin (e.g. "nginx@sha256:abc" → "nginx") + const atIdx = imageRef.indexOf('@'); + if (atIdx !== -1) imageRef = imageRef.slice(0, atIdx); + + let registry = 'registry-1.docker.io'; + let rest = imageRef; + + const slashIdx = imageRef.indexOf('/'); + if (slashIdx !== -1) { + const firstPart = imageRef.slice(0, slashIdx); + if (firstPart.includes('.') || firstPart.includes(':') || firstPart === 'localhost') { + registry = firstPart; + rest = imageRef.slice(slashIdx + 1); + } + } + + // Extract tag + let tag = 'latest'; + const colonIdx = rest.lastIndexOf(':'); + if (colonIdx > 0) { + tag = rest.slice(colonIdx + 1); + rest = rest.slice(0, colonIdx); + } + + // Docker Hub official images (no slash) → prepend "library/" + if (registry === 'registry-1.docker.io' && !rest.includes('/')) { + rest = `library/${rest}`; + } + + return { registry, repo: rest, tag }; +} + +// ─── Minimal HTTP helper ────────────────────────────────────────────────────── + +interface HttpResult { + statusCode: number; + headers: Record; + body: string; +} + +function httpGet(url: string, headers: Record = {}, timeoutMs = 10000): Promise { + return new Promise((resolve, reject) => { + const lib = url.startsWith('https:') ? https : http; + const req = lib.get(url, { headers }, (res) => { + let body = ''; + res.on('data', (chunk: Buffer) => { body += chunk.toString(); }); + res.on('end', () => resolve({ + statusCode: res.statusCode ?? 0, + headers: res.headers as Record, + body, + })); + }); + req.on('error', reject); + req.setTimeout(timeoutMs, () => req.destroy(new Error('Request timed out'))); + }); +} + +// ─── Registry auth ──────────────────────────────────────────────────────────── + +async function getAuthToken(registry: string, repo: string): Promise { + try { + let tokenUrl: string; + + if (registry === 'registry-1.docker.io') { + tokenUrl = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repo}:pull`; + } else { + // Ping /v2/ to get the WWW-Authenticate challenge + const ping = await httpGet(`https://${registry}/v2/`); + const wwwAuth = ping.headers['www-authenticate'] as string | undefined; + if (!wwwAuth) return null; + + const realmMatch = wwwAuth.match(/realm="([^"]+)"/); + const serviceMatch = wwwAuth.match(/service="([^"]+)"/); + const scopeMatch = wwwAuth.match(/scope="([^"]+)"/); + if (!realmMatch) return null; + + const params = new URLSearchParams(); + if (serviceMatch) params.set('service', serviceMatch[1]); + params.set('scope', scopeMatch ? scopeMatch[1] : `repository:${repo}:pull`); + tokenUrl = `${realmMatch[1]}?${params.toString()}`; + } + + const tokenRes = await httpGet(tokenUrl); + if (tokenRes.statusCode !== 200) return null; + + const parsed = JSON.parse(tokenRes.body); + return parsed.token ?? parsed.access_token ?? null; + } catch { + return null; + } +} + +// ─── Remote digest lookup ───────────────────────────────────────────────────── + +// Include manifest list types so we get the fat-manifest digest for multi-arch +// images — this matches what Docker stores in local RepoDigests. +const MANIFEST_ACCEPT = [ + 'application/vnd.docker.distribution.manifest.list.v2+json', + 'application/vnd.docker.distribution.manifest.v2+json', + 'application/vnd.oci.image.index.v1+json', + 'application/vnd.oci.image.manifest.v1+json', +].join(', '); + +async function getRemoteDigest(registry: string, repo: string, tag: string): Promise { + try { + const token = await getAuthToken(registry, repo); + const headers: Record = { Accept: MANIFEST_ACCEPT }; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const res = await httpGet(`https://${registry}/v2/${repo}/manifests/${tag}`, headers); + if (res.statusCode !== 200) return null; + + return (res.headers['docker-content-digest'] as string) ?? null; + } catch { + return null; + } +} + +// ─── Service ────────────────────────────────────────────────────────────────── + +export class ImageUpdateService { + private static instance: ImageUpdateService; + private intervalId: NodeJS.Timeout | null = null; + private isRunning = false; + private lastManualRefreshAt = 0; + + private static readonly INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours + private static readonly STARTUP_DELAY_MS = 2 * 60 * 1000; // 2 min after boot + private static readonly MANUAL_COOLDOWN_MS = 10 * 60 * 1000; // 10 min between manual triggers + private static readonly INTER_IMAGE_DELAY_MS = 300; // be polite to registries + + private constructor() {} + + public static getInstance(): ImageUpdateService { + if (!ImageUpdateService.instance) { + ImageUpdateService.instance = new ImageUpdateService(); + } + return ImageUpdateService.instance; + } + + public start() { + if (this.intervalId) return; + setTimeout(() => this.check(), ImageUpdateService.STARTUP_DELAY_MS); + this.intervalId = setInterval(() => this.check(), ImageUpdateService.INTERVAL_MS); + } + + public stop() { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } + + /** + * Triggers a check immediately, unless one is already running or the + * 10-minute manual cooldown has not elapsed. + * Returns false if rate-limited, true if a check was started. + */ + public triggerManualRefresh(): boolean { + const now = Date.now(); + if (now - this.lastManualRefreshAt < ImageUpdateService.MANUAL_COOLDOWN_MS) { + return false; + } + this.lastManualRefreshAt = now; + this.check().catch(e => console.error('[ImageUpdateService] Manual refresh error:', e)); + return true; + } + + public isChecking(): boolean { + return this.isRunning; + } + + // ─── Core check ────────────────────────────────────────────────────────── + + private async check() { + if (this.isRunning) return; + this.isRunning = true; + console.log('[ImageUpdateService] Starting image update check...'); + + try { + const db = DatabaseService.getInstance(); + // Only check local nodes — remote nodes run their own instance + for (const node of db.getNodes()) { + if (node.type !== 'local' || !node.id) continue; + try { + await this.checkNode(node.id, db); + } catch (e) { + console.error(`[ImageUpdateService] Error on node ${node.name}:`, e); + } + } + console.log('[ImageUpdateService] Image update check complete.'); + } catch (e) { + console.error('[ImageUpdateService] Check failed:', e); + } finally { + this.isRunning = false; + } + } + + private async checkNode(nodeId: number, db: DatabaseService) { + const docker = DockerController.getInstance(nodeId); + const containers = await docker.getAllContainers(); + + // stackName → set of image refs used by that stack + const stackImages = new Map>(); + + for (const c of containers) { + const stackName: string | undefined = c.Labels?.['com.docker.compose.project']; + if (!stackName) continue; + + const imageRef: string = c.Image ?? ''; + if (!imageRef || imageRef.startsWith('sha256:')) continue; + + if (!stackImages.has(stackName)) stackImages.set(stackName, new Set()); + stackImages.get(stackName)!.add(imageRef); + } + + if (stackImages.size === 0) return; + + // Deduplicate: each unique image is checked once regardless of how many stacks use it + const allImages = new Set(); + for (const imgs of stackImages.values()) for (const img of imgs) allImages.add(img); + + const imageUpdateMap = new Map(); + + for (const imageRef of allImages) { + try { + imageUpdateMap.set(imageRef, await this.checkImage(docker, imageRef)); + } catch (e) { + console.error(`[ImageUpdateService] Error checking ${imageRef}:`, e); + imageUpdateMap.set(imageRef, false); + } + await sleep(ImageUpdateService.INTER_IMAGE_DELAY_MS); + } + + const now = Date.now(); + for (const [stackName, images] of stackImages) { + const hasUpdate = Array.from(images).some(img => imageUpdateMap.get(img) === true); + db.upsertStackUpdateStatus(stackName, hasUpdate, now); + } + } + + private async checkImage(docker: DockerController, imageRef: string): Promise { + const parsed = parseImageRef(imageRef); + if (!parsed) return false; + + // Get local digest from RepoDigests + let localDigest: string | null = null; + try { + const inspect = await docker.getDocker().getImage(imageRef).inspect(); + const repoDigests: string[] = inspect.RepoDigests ?? []; + + for (const rd of repoDigests) { + if (!rd.includes('@sha256:')) continue; + const [, digest] = rd.split('@'); + + // Match: rd contains the repo path or this is the only digest entry + if (rd.includes(parsed.repo) || rd.includes(parsed.registry) || repoDigests.length === 1) { + localDigest = digest; + break; + } + } + } catch { + return false; // Image inspect failed (removed since container was started) + } + + if (!localDigest) return false; // Locally built or never pulled with a digest + + const remoteDigest = await getRemoteDigest(parsed.registry, parsed.repo, parsed.tag); + if (!remoteDigest) return false; // Registry unreachable — no false positives + + const hasUpdate = localDigest !== remoteDigest; + console.log( + `[ImageUpdateService] ${imageRef}: ` + + `local=${localDigest.slice(0, 27)}... remote=${remoteDigest.slice(0, 27)}... update=${hasUpdate}` + ); + return hasUpdate; + } +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 0c68ad4e..8f1b6383 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -96,6 +96,9 @@ export default function EditorLayout() { const [logContainer, setLogContainer] = useState<{ id: string; name: string } | null>(null); + // Image update checker state + const [stackUpdates, setStackUpdates] = useState>({}); + // Notifications & Settings state const [notifications, setNotifications] = useState([]); const [settingsModalOpen, setSettingsModalOpen] = useState(false); @@ -172,6 +175,7 @@ export default function EditorLayout() { setIsEditing(false); setActiveView('dashboard'); refreshStacks(); + fetchImageUpdates(); }, [activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps const fetchNotifications = async () => { @@ -184,6 +188,16 @@ export default function EditorLayout() { } catch (e) { } }; + const fetchImageUpdates = async () => { + try { + const res = await apiFetch('/image-updates'); + if (res.ok) { + const data = await res.json(); + setStackUpdates(data); + } + } catch (e) { } + }; + const markAllRead = async () => { try { await apiFetch('/notifications/read', { method: 'POST' }); @@ -769,6 +783,13 @@ export default function EditorLayout() { /> {getDisplayName(file)} + {stackUpdates[file] && ( + + )} +
e.stopPropagation()}>