mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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.
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
|
||||
@@ -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<string, boolean> {
|
||||
const rows = this.db.prepare('SELECT stack_name, has_update FROM stack_update_status').all() as any[];
|
||||
const result: Record<string, boolean> = {};
|
||||
for (const row of rows) {
|
||||
result[row.stack_name] = row.has_update === 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string | string[] | undefined>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
function httpGet(url: string, headers: Record<string, string> = {}, timeoutMs = 10000): Promise<HttpResult> {
|
||||
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<string, string | string[] | undefined>,
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
try {
|
||||
const token = await getAuthToken(registry, repo);
|
||||
const headers: Record<string, string> = { 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<string, Set<string>>();
|
||||
|
||||
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<string>();
|
||||
for (const imgs of stackImages.values()) for (const img of imgs) allImages.add(img);
|
||||
|
||||
const imageUpdateMap = new Map<string, boolean>();
|
||||
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -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<Record<string, boolean>>({});
|
||||
|
||||
// Notifications & Settings state
|
||||
const [notifications, setNotifications] = useState<any[]>([]);
|
||||
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() {
|
||||
/>
|
||||
<span className="flex-1 truncate">{getDisplayName(file)}</span>
|
||||
|
||||
{stackUpdates[file] && (
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-blue-400 animate-pulse shrink-0"
|
||||
title="Update available"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
Reference in New Issue
Block a user