mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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 |
@@ -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.
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
Reference in New Issue
Block a user