diff --git a/backend/src/index.ts b/backend/src/index.ts index 456015de..03160883 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -3615,6 +3615,9 @@ app.delete('/api/stacks/:name', async (req: Request, res: Response) => { // Stage 2: Obliterate the files await FileSystemService.getInstance(req.nodeId).deleteStack(stackName); + // Prevent stale update badges for deleted stacks + DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName); + invalidateNodeCaches(req.nodeId); res.json({ success: true }); } catch (error: any) { diff --git a/backend/src/services/ImageUpdateService.ts b/backend/src/services/ImageUpdateService.ts index 4d9f1fa8..2a5f69a5 100644 --- a/backend/src/services/ImageUpdateService.ts +++ b/backend/src/services/ImageUpdateService.ts @@ -1,8 +1,10 @@ import https from 'https'; import http from 'http'; import path from 'path'; +import YAML from 'yaml'; import DockerController from './DockerController'; import { DatabaseService } from './DatabaseService'; +import { FileSystemService } from './FileSystemService'; import { RegistryService } from './RegistryService'; import { NodeRegistry } from './NodeRegistry'; @@ -74,6 +76,62 @@ function httpGet(url: string, headers: Record = {}, timeoutMs = }); } +// ─── Compose file helpers ──────────────────────────────────────────────────── + +function loadDotEnv(content: string): Record { + const vars: Record = {}; + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx < 1) continue; + const key = trimmed.slice(0, eqIdx).trim(); + let val = trimmed.slice(eqIdx + 1).trim(); + if ((val.startsWith('"') && val.endsWith('"')) || + (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1); + } + vars[key] = val; + } + return vars; +} + +function extractImagesFromCompose( + yamlContent: string, + envVars: Record +): string[] { + let parsed: Record; + try { + parsed = YAML.parse(yamlContent) as Record; + } catch { + return []; + } + if (!parsed?.services || typeof parsed.services !== 'object') return []; + + const images: string[] = []; + for (const svc of Object.values(parsed.services as Record)) { + if (!svc || typeof svc !== 'object') continue; + const raw = (svc as Record).image; + if (!raw || typeof raw !== 'string') continue; + + let ref = raw.replace( + /\$\{([^}]+)\}/g, + (_: string, expr: string) => { + const defaultMatch = expr.match(/^([^:-]+)(?::?-)(.+)$/); + if (defaultMatch) { + return envVars[defaultMatch[1]] ?? defaultMatch[2]; + } + return envVars[expr] ?? ''; + } + ); + + ref = ref.trim(); + if (!ref || ref.includes('${') || ref.startsWith('sha256:')) continue; + images.push(ref); + } + return images; +} + // ─── Registry auth ──────────────────────────────────────────────────────────── async function getAuthToken( @@ -235,33 +293,65 @@ export class ImageUpdateService { private async checkNode(nodeId: number, db: DatabaseService) { const docker = DockerController.getInstance(nodeId); - const containers = await docker.getAllContainers(); + const fs = FileSystemService.getInstance(nodeId); const composeDir = path.resolve(NodeRegistry.getInstance().getComposeDir(nodeId)); - // stackName → set of image refs used by that stack - // Key by directory name (matching FileSystemService.getStacks()) rather than - // com.docker.compose.project label, which diverges when compose files set `name:`. + // Phase 1: Filesystem discovery (all stacks with compose files) + const stacks = await fs.getStacks(); const stackImages = new Map>(); + for (const name of stacks) stackImages.set(name, new Set()); - for (const c of containers) { - const workingDir: string | undefined = c.Labels?.['com.docker.compose.project.working_dir']; - if (!workingDir) continue; + // Phase 2: Parse compose files for image refs + for (const stackName of stacks) { + try { + const content = await fs.getStackContent(stackName); - // Only consider containers managed under COMPOSE_DIR - const resolved = path.resolve(workingDir); - if (resolved !== composeDir && !resolved.startsWith(composeDir + path.sep)) continue; + // Load .env for variable resolution (best-effort) + let envVars: Record = {}; + try { + const envContent = await fs.getEnvContent(stackName); + envVars = loadDotEnv(envContent); + } catch { + // No .env file or unreadable; continue with process.env only + } + // Docker Compose precedence: host env overrides .env + const merged: Record = { ...envVars }; + for (const [k, v] of Object.entries(process.env)) { + if (v !== undefined) merged[k] = v; + } - const stackName = path.basename(resolved); - 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); + for (const img of extractImagesFromCompose(content, merged)) { + stackImages.get(stackName)?.add(img); + } + } catch (e) { + console.warn(`[ImageUpdateService] Could not parse compose for "${stackName}":`, e); + } } - if (stackImages.size === 0) return; + // Phase 3: Container augmentation (captures actual deployed image tags) + try { + const containers = await docker.getAllContainers(); + for (const c of containers) { + const workingDir: string | undefined = c.Labels?.['com.docker.compose.project.working_dir']; + if (!workingDir) continue; - // Deduplicate: each unique image is checked once regardless of how many stacks use it + const resolved = path.resolve(workingDir); + if (resolved !== composeDir && !resolved.startsWith(composeDir + path.sep)) continue; + + const stackName = path.basename(resolved); + const imageRef: string = c.Image ?? ''; + if (!imageRef || imageRef.startsWith('sha256:')) continue; + + // Only augment stacks found on the filesystem + if (stackImages.has(stackName)) { + stackImages.get(stackName)?.add(imageRef); + } + } + } catch (e) { + console.warn('[ImageUpdateService] Container augmentation failed:', e); + } + + // Phase 4: Deduplicate and check all unique images const allImages = new Set(); for (const imgs of stackImages.values()) for (const img of imgs) allImages.add(img); @@ -277,11 +367,20 @@ export class ImageUpdateService { await sleep(ImageUpdateService.INTER_IMAGE_DELAY_MS); } + // Write status for ALL stacks (including those with no pullable images) const now = Date.now(); for (const [stackName, images] of stackImages) { const hasUpdate = Array.from(images).some(img => imageUpdateMap.get(img) === true); db.upsertStackUpdateStatus(nodeId, stackName, hasUpdate, now); } + + // Prune stale entries for stacks no longer on disk + const existing = db.getStackUpdateStatus(nodeId); + for (const staleStack of Object.keys(existing)) { + if (!stackImages.has(staleStack)) { + db.clearStackUpdateStatus(nodeId, staleStack); + } + } } public async checkImage(docker: DockerController, imageRef: string): Promise { diff --git a/docs/images/troubleshooting/image-update-stack-list.png b/docs/images/troubleshooting/image-update-stack-list.png new file mode 100644 index 00000000..6f07f4f2 Binary files /dev/null and b/docs/images/troubleshooting/image-update-stack-list.png differ diff --git a/docs/operations/troubleshooting.mdx b/docs/operations/troubleshooting.mdx index ebc11fee..bee69ee6 100644 --- a/docs/operations/troubleshooting.mdx +++ b/docs/operations/troubleshooting.mdx @@ -509,6 +509,18 @@ docker compose pull && docker compose up -d --- +## Image update checks skip some stacks + +**Symptom:** Some stacks in the sidebar never show the "Update available" badge, even though newer images exist on the registry. + +**Possible causes:** + +- **Build-only services:** Services in your compose file that use `build:` without an `image:` key are skipped. Only services with a remote image reference are checked. +- **Unresolvable variables:** If a service's image uses `${VAR}` interpolation and the variable has no default value and is not defined in the stack's `.env` file, the image reference cannot be resolved and is skipped. Add a default value (e.g., `${TAG:-latest}`) or define the variable in the stack's `.env` file. +- **Image not pulled locally:** Stacks that have never been deployed (no local image on disk) will not show update badges because there is no local digest to compare against the registry. Deploy the stack at least once to enable update detection. + +--- + ## "Check for updates" shows rate-limit error **Symptom:** Clicking "Check for updates" shows a toast error about waiting before checking again. diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index cfbd138d..b12a1f56 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -18,6 +18,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, import { Tabs, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from './ui/tabs'; import { springs } from '@/lib/motion'; import { Highlight, HighlightItem } from './animate-ui/primitives/effects/highlight'; +import { CursorProvider, Cursor, CursorContainer, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor'; import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; import { Badge } from './ui/badge'; import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download, Clock, Menu, FolderSearch, Loader2, Tag, Check, ChevronDown } from 'lucide-react'; @@ -1521,10 +1522,24 @@ export default function EditorLayout() { )} {stackUpdates[file] && ( - + + + + + +
+ + +
+ Update available +
+
+ )}
e.stopPropagation()}>