Files
dockhand/routes/containers/BatchUpdateModal.svelte
Jarek Krochmalski 62e3c6439e Initial commit
2025-12-28 21:16:03 +01:00

605 lines
20 KiB
Svelte

<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Progress } from '$lib/components/ui/progress';
import * as Tooltip from '$lib/components/ui/tooltip';
import { CircleArrowUp, Loader2, AlertCircle, CheckCircle2, XCircle, ChevronDown, ChevronRight } from 'lucide-svelte';
import { appendEnvParam } from '$lib/stores/environment';
import type { VulnerabilityCriteria } from '$lib/server/db';
import type { StepType } from '$lib/utils/update-steps';
import { getStepLabel, getStepIcon, getStepColor } from '$lib/utils/update-steps';
import VulnerabilityCriteriaBadge from '$lib/components/VulnerabilityCriteriaBadge.svelte';
import UpdateSummaryStats from '$lib/components/UpdateSummaryStats.svelte';
import ScannerSeverityPills from '$lib/components/ScannerSeverityPills.svelte';
interface Props {
open: boolean;
containerIds: string[];
containerNames: Map<string, string>;
envId: number | null;
vulnerabilityCriteria?: VulnerabilityCriteria;
onClose: () => void;
onComplete: (results: { success: string[]; failed: string[]; blocked: string[] }) => void;
}
let { open = $bindable(), containerIds, containerNames, envId, vulnerabilityCriteria = 'never', onClose, onComplete }: Props = $props();
interface PullLogEntry {
status: string;
id?: string;
progress?: string;
}
interface ScanLogEntry {
scanner?: string;
message: string;
}
interface ScanResult {
critical: number;
high: number;
medium: number;
low: number;
negligible?: number;
unknown?: number;
}
interface ScannerResult extends ScanResult {
scanner: 'grype' | 'trivy';
}
interface ContainerProgress {
containerId: string;
containerName: string;
step: StepType;
success?: boolean;
error?: string;
pullLogs: PullLogEntry[];
scanLogs: ScanLogEntry[];
scanResult?: ScanResult;
scannerResults?: ScannerResult[];
blockReason?: string;
showLogs: boolean;
}
let status = $state<'idle' | 'updating' | 'complete' | 'error'>('idle');
let progress = $state<ContainerProgress[]>([]);
let currentIndex = $state(0);
let totalCount = $state(0);
let summary = $state<{ total: number; success: number; failed: number; blocked: number } | null>(null);
let errorMessage = $state('');
let forceUpdating = $state<Set<string>>(new Set()); // Track containers being force-updated
function formatPullLog(entry: PullLogEntry): string {
// Clarify potentially confusing Docker messages
let status = entry.status;
if (status.toLowerCase().includes('image is up to date')) {
status = 'Image cached (registry version matches local)';
} else if (status.toLowerCase().includes('status: image is up to date')) {
status = 'Image cached (registry version matches local)';
}
if (entry.id && entry.progress) {
return `${entry.id}: ${status} ${entry.progress}`;
}
if (entry.id) {
return `${entry.id}: ${status}`;
}
return status;
}
function formatScanLog(entry: ScanLogEntry): string {
if (entry.scanner) {
return `[${entry.scanner}] ${entry.message}`;
}
return entry.message;
}
async function startUpdate() {
if (containerIds.length === 0) return;
status = 'updating';
progress = [];
currentIndex = 0;
totalCount = containerIds.length;
summary = null;
errorMessage = '';
try {
const response = await fetch(appendEnvParam('/api/containers/batch-update-stream', envId), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ containerIds, vulnerabilityCriteria })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to start update');
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response body');
}
const decoder = new TextDecoder();
let buffer = '';
const successIds: string[] = [];
const failedIds: string[] = [];
const blockedIds: string[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim() || !line.startsWith('data: ')) continue;
try {
const data = JSON.parse(line.slice(6));
if (data.type === 'start') {
totalCount = data.total;
} else if (data.type === 'progress') {
currentIndex = data.current;
// Update or add progress entry
const existingIndex = progress.findIndex(p => p.containerId === data.containerId);
if (existingIndex >= 0) {
progress[existingIndex].step = data.step;
progress[existingIndex].success = data.success;
progress[existingIndex].error = data.error;
progress = [...progress]; // Trigger reactivity
} else {
progress = [...progress, {
containerId: data.containerId,
containerName: data.containerName,
step: data.step,
success: data.success,
error: data.error,
pullLogs: [],
scanLogs: [],
showLogs: true // Auto-expand for the first/current container
}];
}
// Track success/failed for onComplete callback
if (data.success === true) {
successIds.push(data.containerId);
} else if (data.success === false && data.step === 'failed') {
failedIds.push(data.containerId);
}
} else if (data.type === 'pull_log') {
// Add pull log to the container's log list
const containerProgress = progress.find(p => p.containerId === data.containerId);
if (containerProgress) {
// For layer progress, update existing entry or add new
if (data.pullId) {
const existingLog = containerProgress.pullLogs.find(l => l.id === data.pullId);
if (existingLog) {
existingLog.status = data.pullStatus;
existingLog.progress = data.pullProgress;
} else {
containerProgress.pullLogs.push({
status: data.pullStatus,
id: data.pullId,
progress: data.pullProgress
});
}
} else {
// General status message (no layer ID)
containerProgress.pullLogs.push({
status: data.pullStatus
});
}
progress = [...progress]; // Trigger reactivity
}
} else if (data.type === 'scan_start') {
// Update step to scanning
const containerProgress = progress.find(p => p.containerId === data.containerId);
if (containerProgress) {
containerProgress.step = 'scanning';
progress = [...progress];
}
} else if (data.type === 'scan_log') {
// Add scan log to the container's log list
const containerProgress = progress.find(p => p.containerId === data.containerId);
if (containerProgress) {
containerProgress.scanLogs.push({
scanner: data.scanner,
message: data.message
});
progress = [...progress];
}
} else if (data.type === 'scan_complete') {
// Store scan result and individual scanner results
const containerProgress = progress.find(p => p.containerId === data.containerId);
if (containerProgress) {
containerProgress.scanResult = data.scanResult;
containerProgress.scannerResults = data.scannerResults;
progress = [...progress];
}
} else if (data.type === 'blocked') {
// Mark container as blocked
const existingIndex = progress.findIndex(p => p.containerId === data.containerId);
if (existingIndex >= 0) {
progress[existingIndex].step = 'blocked';
progress[existingIndex].success = false;
progress[existingIndex].scanResult = data.scanResult;
progress[existingIndex].scannerResults = data.scannerResults;
progress[existingIndex].blockReason = data.blockReason;
progress = [...progress];
}
blockedIds.push(data.containerId);
currentIndex = data.current;
} else if (data.type === 'complete') {
status = 'complete';
summary = data.summary;
onComplete({ success: successIds, failed: failedIds, blocked: blockedIds });
} else if (data.type === 'error') {
status = 'error';
errorMessage = data.error || 'Unknown error occurred';
}
} catch (e) {
console.error('Failed to parse SSE data:', e);
}
}
}
} catch (error: any) {
console.error('Failed to update containers:', error);
status = 'error';
errorMessage = error.message || 'Failed to update';
}
}
function handleClose() {
open = false;
onClose();
// Reset state
status = 'idle';
progress = [];
currentIndex = 0;
summary = null;
errorMessage = '';
}
function handleOpenChange(isOpen: boolean) {
if (!isOpen && status === 'updating') {
// Don't allow closing while updating
return;
}
if (!isOpen) {
handleClose();
}
}
function toggleLogs(containerId: string) {
const item = progress.find(p => p.containerId === containerId);
if (item) {
item.showLogs = !item.showLogs;
progress = [...progress];
}
}
async function forceUpdateContainer(containerId: string) {
const item = progress.find(p => p.containerId === containerId);
if (!item || item.step !== 'blocked') return;
// Mark as force-updating
forceUpdating = new Set([...forceUpdating, containerId]);
// Reset container state
item.step = 'pulling';
item.blockReason = undefined;
item.pullLogs = [];
item.scanLogs = [];
progress = [...progress];
try {
const response = await fetch(appendEnvParam('/api/containers/batch-update-stream', envId), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ containerIds: [containerId], vulnerabilityCriteria: 'never' })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to start update');
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response body');
}
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim() || !line.startsWith('data: ')) continue;
try {
const data = JSON.parse(line.slice(6));
if (data.type === 'progress') {
item.step = data.step;
item.success = data.success;
item.error = data.error;
progress = [...progress];
// Update summary if container succeeded
if (data.success === true && summary) {
summary.blocked--;
summary.success++;
summary = { ...summary };
}
} else if (data.type === 'pull_log') {
if (data.pullId) {
const existingLog = item.pullLogs.find(l => l.id === data.pullId);
if (existingLog) {
existingLog.status = data.pullStatus;
existingLog.progress = data.pullProgress;
} else {
item.pullLogs.push({
status: data.pullStatus,
id: data.pullId,
progress: data.pullProgress
});
}
} else {
item.pullLogs.push({ status: data.pullStatus });
}
progress = [...progress];
}
} catch (e) {
console.error('Failed to parse SSE data:', e);
}
}
}
} catch (error: any) {
console.error('Failed to force update container:', error);
item.step = 'failed';
item.error = error.message || 'Force update failed';
progress = [...progress];
} finally {
forceUpdating = new Set([...forceUpdating].filter(id => id !== containerId));
}
}
const progressPercentage = $derived(
totalCount > 0 ? Math.round((currentIndex / totalCount) * 100) : 0
);
// Start update when modal opens
$effect(() => {
if (open && status === 'idle' && containerIds.length > 0) {
startUpdate();
}
});
</script>
<Dialog.Root {open} onOpenChange={handleOpenChange}>
<Dialog.Content class="max-w-5xl h-[70vh] overflow-hidden flex flex-col" onInteractOutside={(e) => { if (status === 'updating') e.preventDefault(); }}>
<Dialog.Header class="shrink-0">
<Dialog.Title class="flex items-center gap-2">
<CircleArrowUp class="w-5 h-5 text-amber-500" />
Updating containers
{#if vulnerabilityCriteria !== 'never'}
<span class="ml-2">
<VulnerabilityCriteriaBadge criteria={vulnerabilityCriteria} />
</span>
{/if}
</Dialog.Title>
<Dialog.Description>
{#if status === 'updating'}
{@const activeContainer = progress.find(p => p.step !== 'done' && p.step !== 'failed' && p.step !== 'blocked')}
{#if activeContainer}
<span class="text-primary font-medium">
{getStepLabel(activeContainer.step)} {activeContainer.containerName}...
</span>
<span class="text-muted-foreground ml-2">({currentIndex}/{totalCount})</span>
{:else}
Processing {currentIndex} of {totalCount} containers...
{/if}
{:else if status === 'complete'}
Update complete
{:else if status === 'error'}
Update failed
{:else}
Preparing to update {containerIds.length} container{containerIds.length > 1 ? 's' : ''}...
{/if}
</Dialog.Description>
</Dialog.Header>
<div class="flex-1 min-h-0 space-y-4 py-4 overflow-hidden flex flex-col">
<!-- Progress bar -->
<div class="space-y-2 shrink-0">
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">Progress</span>
<Badge variant="secondary">{currentIndex}/{totalCount}</Badge>
</div>
<Progress value={progressPercentage} class="h-2" />
</div>
<!-- Container list with status - scrollable area -->
{#if progress.length > 0}
<div class="border rounded-lg divide-y flex-1 min-h-0 overflow-auto">
{#each progress as item (item.containerId)}
{@const StepIcon = getStepIcon(item.step)}
{@const isActive = item.step !== 'done' && item.step !== 'failed' && item.step !== 'blocked'}
{@const hasLogs = item.pullLogs.length > 0 || item.scanLogs.length > 0}
<div class="text-sm">
<!-- Container header -->
<div class="flex items-center gap-3 p-3">
<StepIcon
class="w-4 h-4 shrink-0 {getStepColor(item.step)} {isActive ? 'animate-spin' : ''}"
/>
<div class="flex-1 min-w-0">
<div class="font-medium truncate">{item.containerName}</div>
{#if item.error}
<div class="text-xs text-red-600 dark:text-red-400 truncate">{item.error}</div>
{:else if item.blockReason}
<div class="text-xs text-amber-600 dark:text-amber-400 truncate">{item.blockReason}</div>
{:else}
<div class="text-xs text-muted-foreground">{getStepLabel(item.step)}</div>
{/if}
</div>
<!-- Scan result badges - show per scanner when available -->
{#if item.scannerResults && item.scannerResults.length > 0}
<ScannerSeverityPills results={item.scannerResults} />
{:else if item.scanResult}
<div class="flex items-center gap-1 text-xs shrink-0">
{#if item.scanResult.critical > 0}
<Tooltip.Root>
<Tooltip.Trigger>
<Badge variant="destructive" class="px-1.5 py-0 cursor-help">C:{item.scanResult.critical}</Badge>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{item.scanResult.critical} Critical vulnerabilities</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if item.scanResult.high > 0}
<Tooltip.Root>
<Tooltip.Trigger>
<Badge variant="destructive" class="px-1.5 py-0 bg-orange-500 cursor-help">H:{item.scanResult.high}</Badge>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{item.scanResult.high} High severity vulnerabilities</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if item.scanResult.medium > 0}
<Tooltip.Root>
<Tooltip.Trigger>
<Badge variant="secondary" class="px-1.5 py-0 bg-amber-500 text-white cursor-help">M:{item.scanResult.medium}</Badge>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{item.scanResult.medium} Medium severity vulnerabilities</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if item.scanResult.low > 0}
<Tooltip.Root>
<Tooltip.Trigger>
<Badge variant="secondary" class="px-1.5 py-0 cursor-help">L:{item.scanResult.low}</Badge>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{item.scanResult.low} Low severity vulnerabilities</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</div>
{/if}
{#if item.success === true}
<CheckCircle2 class="w-4 h-4 text-green-600 shrink-0" />
{:else if item.step === 'failed'}
<XCircle class="w-4 h-4 text-red-600 shrink-0" />
{:else if item.step === 'blocked'}
{#if forceUpdating.has(item.containerId)}
<Loader2 class="w-4 h-4 text-blue-500 shrink-0 animate-spin" />
{:else}
<Button
variant="ghost"
size="sm"
class="h-6 px-2 text-xs text-amber-600 hover:text-amber-700 hover:bg-amber-50 dark:hover:bg-amber-950/50"
onclick={() => forceUpdateContainer(item.containerId)}
>
Update anyway
</Button>
{/if}
{/if}
{#if hasLogs}
<button
type="button"
onclick={() => toggleLogs(item.containerId)}
class="p-1 hover:bg-muted rounded cursor-pointer"
title={item.showLogs ? 'Hide logs' : 'Show logs'}
>
{#if item.showLogs}
<ChevronDown class="w-4 h-4 text-muted-foreground" />
{:else}
<ChevronRight class="w-4 h-4 text-muted-foreground" />
{/if}
</button>
{/if}
</div>
<!-- Pull and Scan logs (collapsible) -->
{#if item.showLogs && hasLogs}
<div
class="bg-muted/50 px-3 py-2 font-mono text-xs max-h-40 overflow-auto border-t overflow-x-hidden"
>
{#each item.pullLogs as log}
<div class="text-muted-foreground break-all">
{formatPullLog(log)}
</div>
{/each}
{#if item.scanLogs.length > 0}
{#if item.pullLogs.length > 0}
<div class="border-t border-dashed my-1 border-muted-foreground/30"></div>
{/if}
{#each item.scanLogs as log}
<div class="text-purple-600 dark:text-purple-400 break-all">
{formatScanLog(log)}
</div>
{/each}
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
<!-- Summary - shrink-0 to stay visible -->
{#if summary}
<div class="shrink-0 pt-2 border-t">
<UpdateSummaryStats
updated={summary.success}
blocked={summary.blocked}
failed={summary.failed}
compact
/>
</div>
{/if}
<!-- Error message - shrink-0 to stay visible -->
{#if errorMessage}
<div class="flex items-start gap-2 text-sm text-red-600 dark:text-red-400 p-3 bg-red-50 dark:bg-red-950/30 rounded-lg overflow-hidden shrink-0">
<AlertCircle class="w-4 h-4 shrink-0 mt-0.5" />
<span class="break-all">{errorMessage}</span>
</div>
{/if}
</div>
<Dialog.Footer class="shrink-0">
{#if status === 'updating'}
<Button variant="outline" disabled>
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
Updating...
</Button>
{:else}
<Button variant="outline" onclick={handleClose}>
Close
</Button>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>