Files
dockhand/lib/components/PullTab.svelte
Jarek Krochmalski 62e3c6439e Initial commit
2025-12-28 21:16:03 +01:00

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>