mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-05 21:29:04 +00:00
Initial commit
This commit is contained in:
506
lib/components/PullTab.svelte
Normal file
506
lib/components/PullTab.svelte
Normal file
@@ -0,0 +1,506 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user