fix(updates): scan all filesystem stacks for image updates (#514)

ImageUpdateService previously discovered stacks by iterating Docker
containers, which meant stacks without containers (e.g. after
docker compose down) were silently excluded from update checks.

Switch to a hybrid discovery approach: enumerate stacks from the
filesystem via FileSystemService.getStacks(), parse compose files
for image refs with .env variable resolution, then augment with
container-based image discovery for running stacks.

Also cleans up stale stack_update_status entries when stacks are
deleted or no longer exist on disk, and replaces the plain
update-available tooltip with an animated cursor follow pattern.
This commit is contained in:
Anso
2026-04-12 03:17:37 -04:00
committed by GitHub
parent f26ac16bc0
commit 4950cd0bd0
5 changed files with 151 additions and 22 deletions
+3
View File
@@ -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) {
+117 -18
View File
@@ -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<string, string> = {}, timeoutMs =
});
}
// ─── Compose file helpers ────────────────────────────────────────────────────
function loadDotEnv(content: string): Record<string, string> {
const vars: Record<string, string> = {};
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, string>
): string[] {
let parsed: Record<string, unknown>;
try {
parsed = YAML.parse(yamlContent) as Record<string, unknown>;
} catch {
return [];
}
if (!parsed?.services || typeof parsed.services !== 'object') return [];
const images: string[] = [];
for (const svc of Object.values(parsed.services as Record<string, unknown>)) {
if (!svc || typeof svc !== 'object') continue;
const raw = (svc as Record<string, unknown>).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<string, Set<string>>();
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<string, string> = {};
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<string, string> = { ...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<string>();
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<boolean> {
Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

+12
View File
@@ -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.
+19 -4
View File
@@ -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] && (
<span
className="w-2 h-2 rounded-full bg-info animate-pulse shrink-0"
title="Update available"
/>
<CursorProvider>
<CursorContainer className="inline-flex items-center shrink-0">
<span className="w-2 h-2 rounded-full bg-info animate-pulse" />
</CursorContainer>
<Cursor>
<div className="h-2 w-2 rounded-full bg-brand" />
</Cursor>
<CursorFollow
side="bottom"
sideOffset={4}
align="center"
transition={{ stiffness: 400, damping: 40, bounce: 0 }}
>
<div className="rounded-md border border-card-border bg-popover/95 backdrop-blur-[10px] backdrop-saturate-[1.15] px-2.5 py-1.5 shadow-md">
<span className="font-mono text-xs tabular-nums text-stat-value">Update available</span>
</div>
</CursorFollow>
</CursorProvider>
)}
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" onClick={(e) => e.stopPropagation()}>