mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-03 13:18:56 +00:00
333 lines
9.0 KiB
Svelte
333 lines
9.0 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';
|
|
|
|
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>;
|
|
onClose: () => void;
|
|
onComplete: () => void;
|
|
}
|
|
|
|
let {
|
|
open = $bindable(),
|
|
title,
|
|
operation,
|
|
entityType,
|
|
items,
|
|
envId,
|
|
options = {},
|
|
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 abortController: AbortController | null = null;
|
|
|
|
// 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(() => {
|
|
if (abortController) {
|
|
abortController.abort();
|
|
}
|
|
});
|
|
|
|
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;
|
|
|
|
abortController = new AbortController();
|
|
|
|
try {
|
|
const response = await fetch(`/api/batch${envId ? `?env=${envId}` : ''}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
operation,
|
|
entityType,
|
|
items,
|
|
options
|
|
}),
|
|
signal: abortController.signal
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.error || 'Request failed');
|
|
}
|
|
|
|
if (!response.body) {
|
|
throw new Error('No response body');
|
|
}
|
|
|
|
const reader = response.body.getReader();
|
|
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\n');
|
|
buffer = lines.pop() || '';
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith('data: ')) {
|
|
try {
|
|
const event: BatchEvent = JSON.parse(line.slice(6));
|
|
handleEvent(event);
|
|
} catch {
|
|
// Ignore parse errors
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (error: any) {
|
|
if (error.name === 'AbortError') {
|
|
// User cancelled - mark remaining as cancelled
|
|
let cancelled = 0;
|
|
itemStates = itemStates.map(item => {
|
|
if (item.status === 'pending' || item.status === 'processing') {
|
|
cancelled++;
|
|
return { ...item, status: 'cancelled' as ItemStatus };
|
|
}
|
|
return item;
|
|
});
|
|
cancelledCount = cancelled;
|
|
} else {
|
|
console.error('Batch operation error:', error);
|
|
}
|
|
} finally {
|
|
isRunning = false;
|
|
isComplete = true;
|
|
abortController = null;
|
|
}
|
|
}
|
|
|
|
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() {
|
|
if (abortController) {
|
|
abortController.abort();
|
|
}
|
|
}
|
|
|
|
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}
|
|
{: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>
|