mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-07 13:22:54 +00:00
Initial commit
This commit is contained in:
288
routes/images/ImageLayersView.svelte
Normal file
288
routes/images/ImageLayersView.svelte
Normal file
@@ -0,0 +1,288 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user