mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-02 21:19:05 +00:00
507 lines
15 KiB
Svelte
507 lines
15 KiB
Svelte
<script lang="ts">
|
|
import { tick } from 'svelte';
|
|
import { Badge } from '$lib/components/ui/badge';
|
|
import { Button } from '$lib/components/ui/button';
|
|
import { Input } from '$lib/components/ui/input';
|
|
import { Label } from '$lib/components/ui/label';
|
|
import { Progress } from '$lib/components/ui/progress';
|
|
import { CheckCircle2, XCircle, Loader2, AlertCircle, Terminal, Sun, Moon, Download } from 'lucide-svelte';
|
|
import { onMount } from 'svelte';
|
|
import { appendEnvParam } from '$lib/stores/environment';
|
|
|
|
interface LayerProgress {
|
|
id: string;
|
|
status: string;
|
|
progress?: string;
|
|
current?: number;
|
|
total?: number;
|
|
order: number;
|
|
isComplete: boolean;
|
|
}
|
|
|
|
type PullStatus = 'idle' | 'pulling' | 'complete' | 'error';
|
|
|
|
interface Props {
|
|
imageName?: string;
|
|
envId?: number | null;
|
|
autoStart?: boolean;
|
|
showImageInput?: boolean;
|
|
onComplete?: () => void;
|
|
onError?: (error: string) => void;
|
|
onStatusChange?: (status: PullStatus) => void;
|
|
onImageChange?: (image: string) => void;
|
|
}
|
|
|
|
let {
|
|
imageName: initialImageName = '',
|
|
envId = null,
|
|
autoStart = false,
|
|
showImageInput = true,
|
|
onComplete,
|
|
onError,
|
|
onStatusChange,
|
|
onImageChange
|
|
}: Props = $props();
|
|
|
|
let status = $state<PullStatus>('idle');
|
|
let image = $state(initialImageName);
|
|
let duration = $state(0);
|
|
|
|
// Notify parent of status changes
|
|
$effect(() => {
|
|
onStatusChange?.(status);
|
|
});
|
|
|
|
let layersMap = $state<Record<string, LayerProgress>>({});
|
|
let hasLayers = $state(false);
|
|
let errorMessage = $state('');
|
|
let statusMessage = $state('');
|
|
let completedLayers = $state(0);
|
|
let totalLayers = $state(0);
|
|
let layerOrder = $state(0);
|
|
let outputLines = $state<string[]>([]);
|
|
let outputContainer: HTMLDivElement | undefined;
|
|
let logDarkMode = $state(true);
|
|
let startTime = $state(0);
|
|
|
|
onMount(() => {
|
|
const saved = localStorage.getItem('logTheme');
|
|
if (saved !== null) {
|
|
logDarkMode = saved === 'dark';
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
if (initialImageName) {
|
|
image = initialImageName;
|
|
}
|
|
});
|
|
|
|
// Notify parent when image changes
|
|
$effect(() => {
|
|
onImageChange?.(image);
|
|
});
|
|
|
|
$effect(() => {
|
|
if (autoStart && image && status === 'idle') {
|
|
startPull();
|
|
}
|
|
});
|
|
|
|
function toggleLogTheme() {
|
|
logDarkMode = !logDarkMode;
|
|
localStorage.setItem('logTheme', logDarkMode ? 'dark' : 'light');
|
|
}
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
function formatDuration(ms: number): string {
|
|
if (ms < 1000) return `${ms}ms`;
|
|
return `${(ms / 1000).toFixed(1)}s`;
|
|
}
|
|
|
|
function getProgressPercentage(layer: LayerProgress): number {
|
|
if (!layer.current || !layer.total) return 0;
|
|
return Math.round((layer.current / layer.total) * 100);
|
|
}
|
|
|
|
async function scrollOutputToBottom() {
|
|
await tick();
|
|
if (outputContainer) {
|
|
outputContainer.scrollTop = outputContainer.scrollHeight;
|
|
}
|
|
}
|
|
|
|
function addOutputLine(line: string) {
|
|
outputLines = [...outputLines, line];
|
|
scrollOutputToBottom();
|
|
}
|
|
|
|
export function reset() {
|
|
status = 'idle';
|
|
image = initialImageName;
|
|
layersMap = {};
|
|
hasLayers = false;
|
|
errorMessage = '';
|
|
statusMessage = '';
|
|
completedLayers = 0;
|
|
totalLayers = 0;
|
|
layerOrder = 0;
|
|
outputLines = [];
|
|
duration = 0;
|
|
}
|
|
|
|
export function getImage() {
|
|
return image;
|
|
}
|
|
|
|
export function getStatus() {
|
|
return status;
|
|
}
|
|
|
|
export async function startPull() {
|
|
if (!image.trim()) return;
|
|
|
|
reset();
|
|
status = 'pulling';
|
|
startTime = Date.now();
|
|
|
|
addOutputLine(`[pull] Starting pull for ${image}`);
|
|
|
|
try {
|
|
const response = await fetch(appendEnvParam('/api/images/pull', envId), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ image: image.trim(), scanAfterPull: false })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to start pull');
|
|
}
|
|
|
|
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));
|
|
handlePullProgress(data);
|
|
} catch (e) {
|
|
// Ignore parse errors
|
|
}
|
|
}
|
|
}
|
|
|
|
if (status === 'pulling') {
|
|
duration = Date.now() - startTime;
|
|
status = 'complete';
|
|
addOutputLine(`[pull] Pull completed in ${formatDuration(duration)}`);
|
|
onComplete?.();
|
|
}
|
|
} catch (error: any) {
|
|
duration = Date.now() - startTime;
|
|
status = 'error';
|
|
errorMessage = error.message || 'Failed to pull image';
|
|
addOutputLine(`[error] ${errorMessage}`);
|
|
onError?.(errorMessage);
|
|
}
|
|
}
|
|
|
|
function handlePullProgress(data: any) {
|
|
// Filter out scan-related events (handled by ScanTab)
|
|
if (data.status === 'scanning' || data.status === 'scan-progress' || data.status === 'scan-complete' || data.status === 'scan-error') {
|
|
return;
|
|
}
|
|
|
|
if (data.status === 'complete') {
|
|
duration = Date.now() - startTime;
|
|
status = 'complete';
|
|
addOutputLine(`[pull] Pull completed in ${formatDuration(duration)}`);
|
|
onComplete?.();
|
|
} else if (data.status === 'error') {
|
|
duration = Date.now() - startTime;
|
|
status = 'error';
|
|
errorMessage = data.error || 'Unknown error occurred';
|
|
addOutputLine(`[error] ${errorMessage}`);
|
|
onError?.(errorMessage);
|
|
} else if (data.id) {
|
|
// Layer progress update
|
|
const isLayerId = /^[a-f0-9]{12}$/i.test(data.id);
|
|
if (!isLayerId) {
|
|
if (data.status) {
|
|
statusMessage = `${data.id}: ${data.status}`;
|
|
addOutputLine(`[pull] ${data.id}: ${data.status}`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const existing = layersMap[data.id];
|
|
const statusLower = (data.status || '').toLowerCase();
|
|
const isFullyComplete = statusLower === 'pull complete' || statusLower === 'already exists';
|
|
|
|
if (!existing) {
|
|
totalLayers++;
|
|
layerOrder++;
|
|
hasLayers = true;
|
|
if (isFullyComplete) {
|
|
completedLayers++;
|
|
}
|
|
|
|
// Use spread to ensure reactivity in Svelte 5
|
|
layersMap = {
|
|
...layersMap,
|
|
[data.id]: {
|
|
id: data.id,
|
|
status: data.status || 'Processing',
|
|
progress: data.progress,
|
|
current: data.progressDetail?.current,
|
|
total: data.progressDetail?.total,
|
|
order: layerOrder,
|
|
isComplete: isFullyComplete
|
|
}
|
|
};
|
|
|
|
if (isFullyComplete) {
|
|
addOutputLine(`[layer] ${data.id.slice(0, 12)}: ${data.status}`);
|
|
}
|
|
} else {
|
|
if (isFullyComplete && !existing.isComplete) {
|
|
completedLayers++;
|
|
addOutputLine(`[layer] ${data.id.slice(0, 12)}: ${data.status}`);
|
|
}
|
|
|
|
// Use spread to ensure reactivity in Svelte 5
|
|
layersMap = {
|
|
...layersMap,
|
|
[data.id]: {
|
|
id: data.id,
|
|
status: data.status || 'Processing',
|
|
progress: data.progress,
|
|
current: data.progressDetail?.current,
|
|
total: data.progressDetail?.total,
|
|
order: existing.order,
|
|
isComplete: existing.isComplete || isFullyComplete
|
|
}
|
|
};
|
|
}
|
|
} else if (data.status) {
|
|
statusMessage = data.status;
|
|
addOutputLine(`[pull] ${data.status}`);
|
|
}
|
|
}
|
|
|
|
const sortedLayers = $derived(
|
|
Object.values(layersMap).sort((a, b) => a.order - b.order)
|
|
);
|
|
|
|
const overallProgress = $derived(
|
|
totalLayers > 0 ? (completedLayers / totalLayers) * 100 : 0
|
|
);
|
|
|
|
const downloadStats = $derived.by(() => {
|
|
let totalBytes = 0;
|
|
let downloadedBytes = 0;
|
|
for (const layer of Object.values(layersMap)) {
|
|
if (layer.total) {
|
|
totalBytes += layer.total;
|
|
downloadedBytes += layer.current || 0;
|
|
}
|
|
}
|
|
return { totalBytes, downloadedBytes };
|
|
});
|
|
|
|
const isPulling = $derived(status === 'pulling');
|
|
</script>
|
|
|
|
<div class="flex flex-col gap-4 flex-1 min-h-0">
|
|
<!-- Image Input -->
|
|
{#if showImageInput}
|
|
<div class="space-y-2 shrink-0">
|
|
<Label for="pull-image" class="text-sm font-medium">Image name</Label>
|
|
<div class="flex gap-2">
|
|
<Input
|
|
id="pull-image"
|
|
bind:value={image}
|
|
placeholder="nginx:latest, ubuntu:22.04, postgres:16"
|
|
class="flex-1 h-10"
|
|
disabled={isPulling}
|
|
/>
|
|
<Button
|
|
onclick={startPull}
|
|
disabled={isPulling || !image.trim()}
|
|
class="h-10"
|
|
>
|
|
{#if isPulling}
|
|
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
|
|
Pulling...
|
|
{:else}
|
|
<Download class="w-4 h-4 mr-2" />
|
|
Pull
|
|
{/if}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Progress Section -->
|
|
{#if status !== 'idle'}
|
|
<div class="space-y-2 shrink-0">
|
|
<!-- Status -->
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-2">
|
|
{#if status === 'pulling'}
|
|
<Loader2 class="w-4 h-4 animate-spin text-blue-600" />
|
|
<span class="text-sm">Pulling layers...</span>
|
|
{:else if status === 'complete'}
|
|
<CheckCircle2 class="w-4 h-4 text-green-600" />
|
|
<span class="text-sm text-green-600">Pull completed!</span>
|
|
{:else if status === 'error'}
|
|
<XCircle class="w-4 h-4 text-red-600" />
|
|
<span class="text-sm text-red-600">Failed</span>
|
|
{/if}
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
{#if status === 'pulling' || status === 'complete'}
|
|
<Badge variant="secondary" class="text-xs min-w-20 text-center">
|
|
{#if totalLayers > 0}
|
|
{completedLayers} / {totalLayers} layers
|
|
{:else}
|
|
...
|
|
{/if}
|
|
</Badge>
|
|
{/if}
|
|
<span class="text-xs text-muted-foreground min-w-12">
|
|
{#if duration > 0}{formatDuration(duration)}{/if}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Progress Bar and Download Stats -->
|
|
{#if status === 'pulling'}
|
|
<div class="space-y-2">
|
|
<Progress value={overallProgress} class="h-2" />
|
|
<div class="text-xs text-muted-foreground h-4">
|
|
{#if downloadStats.totalBytes > 0}
|
|
Downloaded: {formatBytes(downloadStats.downloadedBytes)} / {formatBytes(downloadStats.totalBytes)}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Error Message -->
|
|
{#if errorMessage}
|
|
<div class="p-3 rounded-lg bg-destructive/10 border border-destructive/30">
|
|
<div class="flex items-start gap-2">
|
|
<AlertCircle class="w-4 h-4 text-destructive mt-0.5 shrink-0" />
|
|
<span class="text-sm text-destructive break-all">{errorMessage}</span>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Layer Progress Grid -->
|
|
{#if status === 'pulling' || status === 'complete' || hasLayers}
|
|
<div class="shrink-0 border rounded-lg h-36 overflow-auto">
|
|
<table class="w-full text-xs">
|
|
<thead class="bg-muted sticky top-0">
|
|
<tr>
|
|
<th class="text-left py-1.5 px-3 font-medium w-28">Layer ID</th>
|
|
<th class="text-left py-1.5 px-3 font-medium">Status</th>
|
|
<th class="text-right py-1.5 px-3 font-medium w-24">Progress</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each sortedLayers as layer (layer.id)}
|
|
{@const percentage = getProgressPercentage(layer)}
|
|
{@const statusLower = layer.status.toLowerCase()}
|
|
{@const isComplete = statusLower.includes('complete') || statusLower.includes('already exists')}
|
|
{@const isDownloading = statusLower.includes('downloading')}
|
|
{@const isExtracting = statusLower.includes('extracting')}
|
|
<tr class="border-t border-muted hover:bg-muted/30 transition-colors">
|
|
<td class="py-1.5 px-3">
|
|
<code class="font-mono text-2xs">{layer.id.slice(0, 12)}</code>
|
|
</td>
|
|
<td class="py-1.5 px-3">
|
|
<div class="flex items-center gap-2">
|
|
{#if isComplete}
|
|
<CheckCircle2 class="w-3 h-3 text-green-500 shrink-0" />
|
|
{:else if isDownloading || isExtracting}
|
|
<Loader2 class="w-3 h-3 text-blue-500 animate-spin shrink-0" />
|
|
{:else}
|
|
<Loader2 class="w-3 h-3 text-muted-foreground animate-spin shrink-0" />
|
|
{/if}
|
|
<span class={isComplete ? 'text-green-600' : isDownloading ? 'text-blue-600' : isExtracting ? 'text-amber-600' : 'text-muted-foreground'}>
|
|
{layer.status}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td class="py-1.5 px-3 text-right">
|
|
{#if (isDownloading || isExtracting) && layer.current && layer.total}
|
|
<div class="flex items-center gap-2 justify-end">
|
|
<div class="w-16 bg-muted rounded-full h-1.5">
|
|
<div
|
|
class="{isExtracting ? 'bg-amber-500' : 'bg-blue-500'} h-1.5 rounded-full transition-all duration-200"
|
|
style="width: {percentage}%"
|
|
></div>
|
|
</div>
|
|
<span class="text-muted-foreground w-8">{percentage}%</span>
|
|
</div>
|
|
{:else if isComplete}
|
|
<span class="text-green-600">Done</span>
|
|
{:else}
|
|
<span class="text-muted-foreground">-</span>
|
|
{/if}
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Output Log -->
|
|
<div class="flex-1 min-h-0 flex flex-col">
|
|
<div class="flex items-center justify-between text-xs text-muted-foreground mb-2 shrink-0">
|
|
<div class="flex items-center gap-2">
|
|
<Terminal class="w-3.5 h-3.5" />
|
|
<span>Output ({outputLines.length} lines)</span>
|
|
</div>
|
|
<button type="button" onclick={toggleLogTheme} class="p-1 rounded hover:bg-muted transition-colors cursor-pointer" title="Toggle log theme">
|
|
{#if logDarkMode}
|
|
<Sun class="w-3.5 h-3.5" />
|
|
{:else}
|
|
<Moon class="w-3.5 h-3.5" />
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
<div
|
|
bind:this={outputContainer}
|
|
class="{logDarkMode ? 'bg-zinc-950 text-zinc-300' : 'bg-zinc-100 text-zinc-700'} rounded-lg p-3 font-mono text-xs flex-1 min-h-0 overflow-auto"
|
|
>
|
|
{#each outputLines as line}
|
|
<div class="whitespace-pre-wrap break-all leading-relaxed flex items-start gap-1.5">
|
|
{#if line.startsWith('[pull]')}
|
|
<span class="inline-flex items-center px-1 rounded text-[8px] font-medium bg-blue-500 text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]">pull</span>
|
|
<span>{line.slice(7)}</span>
|
|
{:else if line.startsWith('[layer]')}
|
|
<span class="inline-flex items-center px-1 rounded text-[8px] font-medium bg-green-500 text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]">layer</span>
|
|
<span>{line.slice(8)}</span>
|
|
{:else if line.startsWith('[error]')}
|
|
<span class="inline-flex items-center px-1 rounded text-[8px] font-medium bg-red-500 text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]">error</span>
|
|
<span class="text-red-400">{line.slice(8)}</span>
|
|
{:else}
|
|
<span>{line}</span>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Idle state -->
|
|
{#if status === 'idle' && !showImageInput}
|
|
<div class="flex-1 flex items-center justify-center text-muted-foreground">
|
|
<p class="text-sm">Enter an image name to start pulling</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|