Files
dockhand-upstream/src/lib/components/BatchOperationModal.svelte
T
2026-05-30 08:42:21 +02:00

317 lines
8.8 KiB
Svelte

<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Progress } from '$lib/components/ui/progress';
import { Check, X, Loader2, Circle, Ban } from 'lucide-svelte';
import { onDestroy } from 'svelte';
import { formatBytes } from '$lib/utils/format';
const progressText: Record<string, string> = {
remove: 'removing',
start: 'starting',
stop: 'stopping',
restart: 'restarting',
down: 'stopping'
};
// Local type definitions (matching server types)
type ItemStatus = 'pending' | 'processing' | 'success' | 'error' | 'cancelled';
type BatchEvent =
| { type: 'start'; total: number }
| { type: 'progress'; id: string; name: string; status: ItemStatus; message?: string; error?: string; current: number; total: number }
| { type: 'complete'; summary: { total: number; success: number; failed: number } }
| { type: 'error'; error: string };
interface Props {
open: boolean;
title: string;
operation: string;
entityType: 'containers' | 'images' | 'volumes' | 'networks' | 'stacks';
items: Array<{ id: string; name: string }>;
envId?: number;
options?: Record<string, any>;
totalSize?: number;
onClose: () => void;
onComplete: () => void;
}
let {
open = $bindable(),
title,
operation,
entityType,
items,
envId,
options = {},
totalSize,
onClose,
onComplete
}: Props = $props();
// State
type ItemState = {
id: string;
name: string;
status: ItemStatus;
error?: string;
};
let itemStates = $state<ItemState[]>([]);
let isRunning = $state(false);
let isComplete = $state(false);
let successCount = $state(0);
let failCount = $state(0);
let cancelledCount = $state(0);
let cancelled = false;
// Progress calculation
const progress = $derived(() => {
if (itemStates.length === 0) return 0;
const completed = itemStates.filter(i => i.status === 'success' || i.status === 'error' || i.status === 'cancelled').length;
return Math.round((completed / itemStates.length) * 100);
});
// Initialize when modal opens
$effect(() => {
if (open && items.length > 0 && !isRunning && !isComplete) {
startOperation();
}
});
// Cleanup on destroy
onDestroy(() => {
cancelled = true;
});
async function startOperation() {
// Initialize item states
itemStates = items.map(item => ({
id: item.id,
name: item.name,
status: 'pending' as ItemStatus
}));
isRunning = true;
isComplete = false;
successCount = 0;
failCount = 0;
cancelledCount = 0;
cancelled = false;
try {
const response = await fetch(`/api/batch${envId ? `?env=${envId}` : ''}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ operation, entityType, items, options })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Request failed');
}
const data = await response.json();
const { jobId } = data;
// Poll job for progress events
let cursor = 0;
while (!cancelled) {
const jobRes = await fetch(`/api/jobs/${jobId}`);
if (!jobRes.ok) break;
const job = await jobRes.json();
// Process new lines since last poll
const newLines = job.lines.slice(cursor);
cursor = job.lines.length;
for (const line of newLines) {
handleEvent(line.data as BatchEvent);
}
if (job.status !== 'running') break;
await new Promise((r) => setTimeout(r, 500));
}
if (cancelled) {
// Mark remaining items as cancelled
let cancelCount = 0;
itemStates = itemStates.map(item => {
if (item.status === 'pending' || item.status === 'processing') {
cancelCount++;
return { ...item, status: 'cancelled' as ItemStatus };
}
return item;
});
cancelledCount = cancelCount;
}
} catch (error: any) {
console.error('Batch operation error:', error);
} finally {
isRunning = false;
isComplete = true;
}
}
function handleEvent(event: BatchEvent) {
switch (event.type) {
case 'progress':
itemStates = itemStates.map(item =>
item.id === event.id
? { ...item, status: event.status, error: event.error }
: item
);
if (event.status === 'success') successCount++;
if (event.status === 'error') failCount++;
break;
case 'complete':
successCount = event.summary.success;
failCount = event.summary.failed;
break;
}
}
function handleCancel() {
cancelled = true;
}
function handleClose() {
if (isRunning) {
// Confirm before closing during operation
if (!confirm('Operation is still running. Cancel and close?')) {
return;
}
handleCancel();
}
open = false;
// Reset state for next use
itemStates = [];
isRunning = false;
isComplete = false;
successCount = 0;
failCount = 0;
cancelledCount = 0;
onClose();
if (isComplete) {
onComplete();
}
}
function handleOk() {
open = false;
itemStates = [];
isRunning = false;
isComplete = false;
successCount = 0;
failCount = 0;
cancelledCount = 0;
onClose();
onComplete();
}
</script>
<Dialog.Root bind:open onOpenChange={(isOpen) => !isOpen && handleClose()}>
<Dialog.Content class="w-full max-w-lg" onInteractOutside={(e) => isRunning && e.preventDefault()}>
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>
{#if isRunning}
Processing {items.length} {entityType}...
{:else if isComplete}
Completed: {successCount} succeeded{#if failCount > 0}, {failCount} failed{/if}{#if cancelledCount > 0}, {cancelledCount} cancelled{/if}{#if totalSize && successCount > 0} ({formatBytes(totalSize)}){/if}
{:else}
Preparing to {operation} {items.length} {entityType}...
{/if}
</Dialog.Description>
</Dialog.Header>
<!-- Progress bar -->
<div class="py-2">
<Progress value={progress()} class="h-2" />
<div class="text-xs text-muted-foreground mt-1 text-right">
{progress()}%
</div>
</div>
<!-- Items list -->
<div class="max-h-80 overflow-y-auto border rounded-md">
{#each itemStates as item (item.id)}
<div class="px-3 py-2 border-b last:border-b-0 text-sm {item.status === 'error' ? 'bg-red-50 dark:bg-red-950/20' : ''} {item.status === 'cancelled' ? 'bg-amber-50 dark:bg-amber-950/20' : ''}">
<div class="flex items-center gap-2">
<!-- Status icon -->
<div class="w-5 h-5 flex items-center justify-center flex-shrink-0">
{#if item.status === 'pending'}
<Circle class="w-4 h-4 text-muted-foreground" />
{:else if item.status === 'processing'}
<Loader2 class="w-4 h-4 text-blue-500 animate-spin" />
{:else if item.status === 'success'}
<Check class="w-4 h-4 text-green-500" />
{:else if item.status === 'error'}
<X class="w-4 h-4 text-red-500" />
{:else if item.status === 'cancelled'}
<Ban class="w-4 h-4 text-amber-500" />
{/if}
</div>
<!-- Item name -->
<span class="flex-1 truncate font-mono text-xs" title={item.name}>
{item.name}
</span>
<!-- Status text -->
<span class="text-xs text-muted-foreground flex-shrink-0">
{#if item.status === 'pending'}
pending
{:else if item.status === 'processing'}
{progressText[operation] ?? operation}...
{:else if item.status === 'success'}
done
{:else if item.status === 'error'}
<span class="text-red-500">failed</span>
{:else if item.status === 'cancelled'}
<span class="text-amber-500">cancelled</span>
{/if}
</span>
</div>
<!-- Error message on separate line -->
{#if item.status === 'error' && item.error}
<div class="mt-1 ml-7 text-xs text-red-600 dark:text-red-400 break-words">
{item.error}
</div>
{/if}
</div>
{/each}
</div>
<!-- Footer: Summary + Button in one row -->
<div class="flex items-center justify-between pt-2">
<div class="flex items-center gap-3 text-sm">
<div class="flex items-center gap-1" title="Succeeded">
<Check class="w-4 h-4 text-green-500" />
<span class="tabular-nums">{successCount}</span>
</div>
<div class="flex items-center gap-1" title="Failed">
<X class="w-4 h-4 text-red-500" />
<span class="tabular-nums">{failCount}</span>
</div>
<div class="flex items-center gap-1" title="Cancelled">
<Ban class="w-4 h-4 text-amber-500" />
<span class="tabular-nums">{cancelledCount}</span>
</div>
<div class="flex items-center gap-1 text-muted-foreground" title="Pending">
<Circle class="w-4 h-4" />
<span class="tabular-nums">{items.length - successCount - failCount - cancelledCount}</span>
</div>
</div>
{#if isRunning}
<Button variant="outline" size="sm" onclick={handleCancel}>
Cancel
</Button>
{:else}
<Button size="sm" onclick={handleOk}>
OK
</Button>
{/if}
</div>
</Dialog.Content>
</Dialog.Root>