mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-05 05:39:04 +00:00
289 lines
9.9 KiB
Svelte
289 lines
9.9 KiB
Svelte
<script lang="ts">
|
|
import { Badge } from '$lib/components/ui/badge';
|
|
import { Loader2, Layers, ChevronDown, ChevronRight } from 'lucide-svelte';
|
|
import { currentEnvironment, appendEnvParam } from '$lib/stores/environment';
|
|
import { formatDateTime } from '$lib/stores/settings';
|
|
|
|
interface Props {
|
|
imageId: string;
|
|
imageName?: string;
|
|
visible?: boolean;
|
|
}
|
|
|
|
let { imageId, imageName, visible = true }: Props = $props();
|
|
|
|
interface HistoryLayer {
|
|
Id: string;
|
|
Created: number;
|
|
CreatedBy: string;
|
|
Size: number;
|
|
Comment: string;
|
|
Tags: string[] | null;
|
|
}
|
|
|
|
let loading = $state(false);
|
|
let error = $state('');
|
|
let history = $state<HistoryLayer[]>([]);
|
|
let expandedLayers = $state<Set<number>>(new Set());
|
|
let lastFetchedId = $state<string | null>(null);
|
|
|
|
// Calculate the maximum size for visualization scaling
|
|
const maxLayerSize = $derived(
|
|
history.length > 0 ? Math.max(...history.map(l => l.Size)) : 0
|
|
);
|
|
|
|
// Calculate total image size
|
|
const totalSize = $derived(
|
|
history.reduce((sum, layer) => sum + layer.Size, 0)
|
|
);
|
|
|
|
$effect(() => {
|
|
if (visible && imageId && imageId !== lastFetchedId) {
|
|
fetchHistory();
|
|
}
|
|
});
|
|
|
|
async function fetchHistory() {
|
|
loading = true;
|
|
error = '';
|
|
expandedLayers = new Set();
|
|
try {
|
|
const envId = $currentEnvironment?.id ?? null;
|
|
// URL-encode the imageId to handle sha256:... format and image names with special characters
|
|
const encodedId = encodeURIComponent(imageId);
|
|
const response = await fetch(appendEnvParam(`/api/images/${encodedId}/history`, envId));
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch image history');
|
|
}
|
|
history = await response.json();
|
|
lastFetchedId = imageId;
|
|
} catch (err: any) {
|
|
error = err.message || 'Failed to load image history';
|
|
console.error('Failed to fetch image history:', err);
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
function formatSize(bytes: number): string {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
|
}
|
|
|
|
function formatDate(timestamp: number): string {
|
|
return formatDateTime(new Date(timestamp * 1000));
|
|
}
|
|
|
|
function getBarWidth(size: number): number {
|
|
if (maxLayerSize === 0) return 0;
|
|
// Minimum 15% width to show text, max 100%
|
|
return Math.max(15, (size / maxLayerSize) * 100);
|
|
}
|
|
|
|
function getBarColor(index: number): string {
|
|
// Create a gradient of colors from bottom to top
|
|
const colors = [
|
|
'bg-blue-500',
|
|
'bg-cyan-500',
|
|
'bg-teal-500',
|
|
'bg-green-500',
|
|
'bg-lime-500',
|
|
'bg-yellow-500',
|
|
'bg-orange-500',
|
|
'bg-red-500',
|
|
'bg-pink-500',
|
|
'bg-purple-500',
|
|
'bg-indigo-500',
|
|
];
|
|
return colors[index % colors.length];
|
|
}
|
|
|
|
function toggleLayer(layerNum: number) {
|
|
const newSet = new Set(expandedLayers);
|
|
if (newSet.has(layerNum)) {
|
|
newSet.delete(layerNum);
|
|
} else {
|
|
newSet.add(layerNum);
|
|
}
|
|
expandedLayers = newSet;
|
|
}
|
|
|
|
function getShortCommand(cmd: string): string {
|
|
if (!cmd) return 'No command';
|
|
// Remove /bin/sh -c prefix
|
|
cmd = cmd.replace(/^\/bin\/sh -c\s+/, '');
|
|
// Take first meaningful part
|
|
const parts = cmd.split(/\s+/);
|
|
if (parts[0] === '#(nop)') {
|
|
// Dockerfile instruction like ADD, COPY, etc - show just the instruction
|
|
return parts[1] || parts[0];
|
|
}
|
|
// Regular command - show just the first word/command
|
|
return parts[0];
|
|
}
|
|
|
|
function highlightCommand(cmd: string): string {
|
|
if (!cmd) return '';
|
|
|
|
// Dockerfile instructions
|
|
const dockerInstructions = ['ADD', 'COPY', 'ENV', 'ARG', 'WORKDIR', 'RUN', 'CMD', 'ENTRYPOINT', 'EXPOSE', 'VOLUME', 'USER', 'LABEL', 'HEALTHCHECK', 'SHELL', 'ONBUILD', 'STOPSIGNAL', 'MAINTAINER', 'FROM'];
|
|
const dockerInstructionPattern = new RegExp(`\\b(${dockerInstructions.join('|')})\\b`, 'g');
|
|
|
|
// Shell keywords
|
|
const shellKeywords = ['if', 'then', 'else', 'elif', 'fi', 'for', 'while', 'do', 'done', 'case', 'esac', 'function', 'in', 'select'];
|
|
const shellKeywordPattern = new RegExp(`\\b(${shellKeywords.join('|')})\\b`, 'g');
|
|
|
|
// Common commands
|
|
const commands = ['apt-get', 'apk', 'yum', 'pip', 'npm', 'yarn', 'git', 'curl', 'wget', 'mkdir', 'cd', 'cp', 'mv', 'rm', 'chmod', 'chown', 'echo', 'cat', 'grep', 'sed', 'awk', 'tar', 'unzip', 'make', 'gcc', 'python', 'node', 'sh', 'bash'];
|
|
const commandPattern = new RegExp(`\\b(${commands.join('|')})\\b`, 'g');
|
|
|
|
let highlighted = cmd
|
|
// Strings in quotes
|
|
.replace(/("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g, '<span class="text-green-600 dark:text-green-400">$1</span>')
|
|
// Comments
|
|
.replace(/(#[^\n]*)/g, '<span class="text-gray-500 dark:text-gray-400 italic">$1</span>')
|
|
// Dockerfile instructions (must be before shell keywords to take precedence)
|
|
.replace(dockerInstructionPattern, '<span class="text-blue-600 dark:text-blue-400 font-semibold">$1</span>')
|
|
// Shell keywords
|
|
.replace(shellKeywordPattern, '<span class="text-purple-600 dark:text-purple-400">$1</span>')
|
|
// Commands
|
|
.replace(commandPattern, '<span class="text-cyan-600 dark:text-cyan-400">$1</span>')
|
|
// Flags (words starting with - or --)
|
|
.replace(/(\s)(--?[a-zA-Z0-9-]+)/g, '$1<span class="text-yellow-600 dark:text-yellow-400">$2</span>');
|
|
|
|
return highlighted;
|
|
}
|
|
</script>
|
|
|
|
<div class="space-y-4">
|
|
{#if loading && history.length === 0}
|
|
<div class="flex items-center justify-center py-8 flex-1">
|
|
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
{:else if error}
|
|
<div class="text-sm text-red-600 dark:text-red-400 p-3 bg-red-50 dark:bg-red-950 rounded">
|
|
{error}
|
|
</div>
|
|
{:else if history.length === 0}
|
|
<p class="text-muted-foreground text-sm text-center py-8">No layer history found</p>
|
|
{:else}
|
|
<!-- Summary -->
|
|
<div class="flex items-center justify-between p-3 bg-muted rounded-lg">
|
|
<div>
|
|
<p class="text-sm font-medium">Total layers: <span class="text-primary">{history.length}</span></p>
|
|
<p class="text-sm font-medium">Total size: <span class="text-primary">{formatSize(totalSize)}</span></p>
|
|
</div>
|
|
<Badge variant="secondary">
|
|
{imageId.startsWith('sha256:') ? imageId.slice(7, 19) : imageName || imageId}
|
|
</Badge>
|
|
</div>
|
|
|
|
<!-- Layer Stack with Expandable Details -->
|
|
<div class="space-y-1">
|
|
<h3 class="sticky top-0 z-10 bg-background text-sm font-semibold mb-2 pb-2 flex items-center gap-2">
|
|
<Layers class="w-4 h-4" />
|
|
Layer stack (bottom to top) - click to expand
|
|
</h3>
|
|
<div class="space-y-1">
|
|
{#each history.slice().reverse() as layer, index}
|
|
{@const layerNum = history.length - index}
|
|
{@const barWidth = getBarWidth(layer.Size)}
|
|
{@const barColor = getBarColor(history.length - index - 1)}
|
|
{@const isExpanded = expandedLayers.has(layerNum)}
|
|
<div class="border border-border rounded-lg overflow-hidden">
|
|
<!-- Layer Bar (Clickable) -->
|
|
<button
|
|
type="button"
|
|
onclick={() => toggleLayer(layerNum)}
|
|
class="w-full flex items-center gap-2 p-2 hover:bg-muted/30 transition-colors"
|
|
>
|
|
<div class="flex items-center gap-2 flex-1">
|
|
<!-- Expand Icon -->
|
|
{#if isExpanded}
|
|
<ChevronDown class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
|
{:else}
|
|
<ChevronRight class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
|
{/if}
|
|
|
|
<!-- Layer Number -->
|
|
<div class="w-6 text-xs text-muted-foreground text-right font-mono shrink-0">#{layerNum}</div>
|
|
|
|
<!-- Size Bar -->
|
|
<div class="flex-1 h-6 bg-muted rounded-sm overflow-hidden relative">
|
|
<div
|
|
class="{barColor} h-full transition-all duration-300 flex items-center px-2 gap-2"
|
|
style="width: {barWidth}%"
|
|
>
|
|
<span class="text-xs text-white font-semibold shrink-0 drop-shadow-[0_1px_2px_rgba(0,0,0,0.8)]">
|
|
{layer.Size > 0 ? formatSize(layer.Size) : '0 B'}
|
|
</span>
|
|
<span class="text-xs text-white truncate drop-shadow-[0_1px_2px_rgba(0,0,0,0.8)]">
|
|
{getShortCommand(layer.CreatedBy)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Size -->
|
|
<div class="w-20 text-xs text-muted-foreground text-right shrink-0">
|
|
{formatSize(layer.Size)}
|
|
</div>
|
|
|
|
<!-- Size Badge -->
|
|
<Badge variant={layer.Size > 1024 * 1024 * 100 ? 'destructive' : 'secondary'} class="text-xs shrink-0">
|
|
{layer.Size > 1024 * 1024 * 100 ? 'Large' : 'Normal'}
|
|
</Badge>
|
|
</div>
|
|
</button>
|
|
|
|
<!-- Expanded Details -->
|
|
{#if isExpanded}
|
|
<div class="px-4 pb-3 space-y-3 border-t border-border bg-muted/20">
|
|
<div class="grid grid-cols-2 gap-2 pt-3 text-sm">
|
|
<div>
|
|
<p class="text-xs text-muted-foreground">Created</p>
|
|
<p class="text-xs">{formatDate(layer.Created)}</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs text-muted-foreground">Size</p>
|
|
<p class="text-xs font-mono">{formatSize(layer.Size)}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{#if layer.CreatedBy}
|
|
<div class="space-y-1">
|
|
<p class="text-xs font-medium text-muted-foreground">Command</p>
|
|
<code class="block text-xs bg-muted p-2 rounded overflow-x-auto whitespace-pre-wrap break-all">
|
|
{@html highlightCommand(layer.CreatedBy)}
|
|
</code>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if layer.Comment}
|
|
<div class="space-y-1">
|
|
<p class="text-xs font-medium text-muted-foreground">Comment</p>
|
|
<p class="text-xs">{layer.Comment}</p>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if layer.Tags && layer.Tags.length > 0}
|
|
<div class="space-y-1">
|
|
<p class="text-xs font-medium text-muted-foreground">Tags</p>
|
|
<div class="flex flex-wrap gap-1">
|
|
{#each layer.Tags as tag}
|
|
<Badge variant="outline" class="text-xs">{tag}</Badge>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|