Initial commit

This commit is contained in:
Jarek Krochmalski
2025-12-28 21:16:03 +01:00
commit 62e3c6439e
552 changed files with 104858 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
import type { PageServerLoad } from './$types';
import { getScannerSettings } from '$lib/server/scanner';
export const load: PageServerLoad = async () => {
const { scanner } = await getScannerSettings();
return {
scannerEnabled: scanner !== 'none'
};
};

1044
routes/images/+page.svelte Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Layers } from 'lucide-svelte';
import ImageLayersView from './ImageLayersView.svelte';
interface Props {
open: boolean;
imageId: string;
imageName?: string;
}
let { open = $bindable(), imageId, imageName }: Props = $props();
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-w-5xl max-h-[90vh] flex flex-col min-h-[400px] !animate-none">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<Layers class="w-5 h-5" />
Image layers: <span class="text-muted-foreground font-normal">{imageName || imageId.slice(7, 19)}</span>
</Dialog.Title>
</Dialog.Header>
<div class="flex-1 overflow-auto space-y-4">
<ImageLayersView
imageId={imageId}
imageName={imageName}
visible={open}
/>
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (open = false)}>Close</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View 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>

View File

@@ -0,0 +1,369 @@
<script lang="ts">
import * as Popover from '$lib/components/ui/popover';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Download, CheckCircle2, XCircle, Loader2, AlertCircle, Shield } from 'lucide-svelte';
import type { Snippet } from 'svelte';
import { Progress } from '$lib/components/ui/progress';
import { tick } from 'svelte';
import ImageScanModal from './ImageScanModal.svelte';
interface Props {
imageName: string | (() => string);
envHasScanning?: boolean;
onComplete?: () => void;
envId?: number | null;
children: Snippet;
}
let { imageName, envHasScanning = false, onComplete, envId = null, children }: Props = $props();
// Resolve image name (can be string or function)
function getImageName(): string {
return typeof imageName === 'function' ? imageName() : imageName;
}
let displayImageName = $state('');
interface LayerProgress {
id: string;
status: string;
progress?: string;
current?: number;
total?: number;
order: number;
isComplete: boolean;
}
let open = $state(false);
let layers = $state<Map<string, LayerProgress>>(new Map());
let overallStatus = $state<'idle' | 'pulling' | 'complete' | 'error'>('idle');
let errorMessage = $state('');
let statusMessage = $state('');
let completedLayers = $state(0);
let totalLayers = $state(0);
let layerOrder = $state(0);
let layersContainer: HTMLDivElement | undefined;
// Scan modal state
let showScanModal = $state(false);
let scanImageName = $state('');
function getProgressPercentage(layer: LayerProgress): number {
if (!layer.current || !layer.total) return 0;
return Math.round((layer.current / layer.total) * 100);
}
function getStatusColor(status: string): string {
const statusLower = status.toLowerCase();
if (statusLower.includes('complete') || statusLower.includes('already exists') || statusLower.includes('pull complete')) {
return 'text-green-600 dark:text-green-400';
}
if (statusLower.includes('error') || statusLower.includes('failed')) {
return 'text-red-600 dark:text-red-400';
}
if (statusLower.includes('extracting') || statusLower.includes('downloading')) {
return 'text-blue-600 dark:text-blue-400';
}
return 'text-muted-foreground';
}
function getStatusIcon(status: string) {
const statusLower = status.toLowerCase();
if (statusLower.includes('complete') || statusLower.includes('already exists') || statusLower.includes('pull complete')) {
return CheckCircle2;
}
if (statusLower.includes('error') || statusLower.includes('failed')) {
return XCircle;
}
return Loader2;
}
function shouldSpin(status: string): boolean {
const statusLower = status.toLowerCase();
if (statusLower.includes('complete') || statusLower.includes('already exists') || statusLower.includes('pull complete')) {
return false;
}
if (statusLower.includes('error') || statusLower.includes('failed')) {
return false;
}
return true;
}
async function startPull() {
layers = new Map();
overallStatus = 'pulling';
errorMessage = '';
statusMessage = '';
completedLayers = 0;
totalLayers = 0;
layerOrder = 0;
displayImageName = getImageName();
open = true;
try {
const pullUrl = envId ? `/api/images/pull?env=${envId}` : '/api/images/pull';
const response = await fetch(pullUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: displayImageName })
});
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));
if (data.status === 'complete') {
overallStatus = 'complete';
onComplete?.();
// Trigger scan if scanning is enabled
if (envHasScanning) {
// Close popover and open scan modal after short delay
setTimeout(() => {
scanImageName = displayImageName;
open = false;
showScanModal = true;
}, 500);
}
} else if (data.status === 'error') {
overallStatus = 'error';
errorMessage = data.error || 'Unknown error occurred';
} else if (data.id) {
// Layer progress update - only process if id looks like a layer hash (12 hex chars)
const isLayerId = /^[a-f0-9]{12}$/i.test(data.id);
if (!isLayerId) {
if (data.status) {
statusMessage = `${data.id}: ${data.status}`;
}
continue;
}
const existing = layers.get(data.id);
const statusLower = (data.status || '').toLowerCase();
// Only count "Pull complete" or "Already exists" as truly complete
const isFullyComplete = statusLower === 'pull complete' || statusLower === 'already exists';
if (!existing) {
totalLayers++;
layerOrder++;
if (isFullyComplete) {
completedLayers++;
}
const layerProgress: LayerProgress = {
id: data.id,
status: data.status || 'Processing',
progress: data.progress,
current: data.progressDetail?.current,
total: data.progressDetail?.total,
order: layerOrder,
isComplete: isFullyComplete
};
layers.set(data.id, layerProgress);
layers = new Map(layers);
scrollToBottom();
} else {
// Check if layer transitioned to complete (only count once)
if (isFullyComplete && !existing.isComplete) {
completedLayers++;
}
const layerProgress: LayerProgress = {
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
};
layers.set(data.id, layerProgress);
layers = new Map(layers);
}
} else if (data.status) {
statusMessage = data.status;
}
} catch (e) {
console.error('Failed to parse SSE data:', e);
}
}
}
} catch (error: any) {
console.error('Failed to pull image:', error);
overallStatus = 'error';
errorMessage = error.message || 'Failed to pull image';
}
}
function handleOpenChange(isOpen: boolean) {
// Only allow closing when not pulling
if (!isOpen && overallStatus === 'pulling') {
return;
}
// Start pull when opening
if (isOpen && !open && (overallStatus === 'idle' || overallStatus === 'complete' || overallStatus === 'error')) {
startPull();
return; // startPull sets open = true
}
open = isOpen;
if (!isOpen) {
// Reset state when closed
overallStatus = 'idle';
layers = new Map();
completedLayers = 0;
totalLayers = 0;
layerOrder = 0;
errorMessage = '';
statusMessage = '';
}
}
const sortedLayers = $derived(
Array.from(layers.values()).sort((a, b) => a.order - b.order)
);
// Auto-scroll to bottom when layers change
async function scrollToBottom() {
await tick();
if (layersContainer) {
layersContainer.scrollTop = layersContainer.scrollHeight;
}
}
</script>
<Popover.Root {open} onOpenChange={handleOpenChange}>
<Popover.Trigger asChild>
{@render children()}
</Popover.Trigger>
<Popover.Content class="w-[28rem] p-0 overflow-hidden flex flex-col max-h-96 z-[150]" align="end" sideOffset={8}>
<!-- Sticky Header -->
<div class="p-3 border-b shrink-0 space-y-2">
<div class="flex items-center gap-2 text-sm font-medium min-w-0">
<Download class="w-4 h-4 shrink-0" />
<span class="truncate">{displayImageName}</span>
</div>
<!-- Overall Progress -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
{#if overallStatus === 'idle'}
<Loader2 class="w-4 h-4 animate-spin text-muted-foreground" />
<span class="text-sm text-muted-foreground">Initializing...</span>
{:else if overallStatus === 'pulling'}
<Loader2 class="w-4 h-4 animate-spin text-blue-600" />
<span class="text-sm">Pulling...</span>
{:else if overallStatus === 'complete'}
<CheckCircle2 class="w-4 h-4 text-green-600" />
<span class="text-sm text-green-600">Complete!</span>
{:else if overallStatus === 'error'}
<XCircle class="w-4 h-4 text-red-600" />
<span class="text-sm text-red-600">Failed</span>
{/if}
</div>
{#if totalLayers > 0}
<Badge variant="secondary" class="text-xs">
{completedLayers}/{totalLayers}
</Badge>
{/if}
</div>
{#if statusMessage && (overallStatus === 'pulling' || overallStatus === 'complete')}
<p class="text-xs text-muted-foreground truncate">{statusMessage}</p>
{/if}
{#if totalLayers > 0}
<Progress value={(completedLayers / totalLayers) * 100} class="h-1.5" />
{/if}
{#if errorMessage}
<div class="flex items-start gap-2 text-xs text-red-600 dark:text-red-400">
<AlertCircle class="w-3 h-3 shrink-0 mt-0.5" />
<span class="break-all">{errorMessage}</span>
</div>
{/if}
</div>
<!-- Scrollable Layer Progress List -->
{#if sortedLayers.length > 0}
<div class="flex-1 overflow-auto min-h-0 p-2" bind:this={layersContainer}>
<div class="space-y-0.5">
{#each sortedLayers as layer (layer.id)}
{@const StatusIcon = getStatusIcon(layer.status)}
{@const percentage = getProgressPercentage(layer)}
{@const statusLower = layer.status.toLowerCase()}
{@const isDownloading = statusLower.includes('downloading')}
{@const isExtracting = statusLower.includes('extracting')}
<div class="flex items-center gap-2 py-1 px-1 rounded text-xs hover:bg-muted/50">
<StatusIcon
class="w-3 h-3 shrink-0 {getStatusColor(layer.status)} {shouldSpin(layer.status) ? 'animate-spin' : ''}"
/>
<code class="w-20 shrink-0 font-mono text-2xs">{layer.id.slice(0, 12)}</code>
<div class="flex-1 min-w-0">
{#if (isDownloading || isExtracting) && layer.current && layer.total}
<div class="flex items-center gap-1">
<div class="flex-1 bg-muted rounded-full h-1">
<div
class="{isExtracting ? 'bg-amber-500' : 'bg-blue-500'} h-1 rounded-full transition-all duration-200"
style="width: {percentage}%"
></div>
</div>
<span class="text-2xs text-muted-foreground w-8 text-right shrink-0">{percentage}%</span>
</div>
{:else}
<span class="text-2xs {getStatusColor(layer.status)} truncate block">
{layer.status}
</span>
{/if}
</div>
</div>
{/each}
</div>
</div>
{:else if overallStatus === 'complete'}
<div class="p-3">
<p class="text-xs text-muted-foreground text-center py-2">Image is up to date</p>
</div>
{/if}
<!-- Sticky Footer -->
{#if overallStatus === 'complete' || overallStatus === 'error'}
<div class="p-2 border-t shrink-0">
<Button
variant="outline"
size="sm"
class="w-full"
onclick={() => handleOpenChange(false)}
>
Close
</Button>
</div>
{/if}
</Popover.Content>
</Popover.Root>
<!-- Vulnerability Scan Modal -->
<ImageScanModal bind:open={showScanModal} imageName={scanImageName} {envId} />

View File

@@ -0,0 +1,285 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Download, CheckCircle2, XCircle, ShieldCheck, ShieldAlert, ShieldX, FileText, FileSpreadsheet } from 'lucide-svelte';
import { currentEnvironment } from '$lib/stores/environment';
import ScanTab from '$lib/components/ScanTab.svelte';
import type { ScanResult } from '$lib/components/ScanTab.svelte';
interface Props {
open: boolean;
imageName: string;
envId?: number | null;
onClose?: () => void;
onComplete?: () => void;
}
let { open = $bindable(), imageName, envId, onClose, onComplete }: Props = $props();
// Component ref
let scanTabRef = $state<ScanTab | undefined>();
// Track status and results from ScanTab
let scanStatus = $state<'idle' | 'scanning' | 'complete' | 'error'>('idle');
let scanResults = $state<ScanResult[]>([]);
let duration = $state(0);
let hasStarted = $state(false);
$effect(() => {
if (open && imageName && !hasStarted) {
hasStarted = true;
}
if (!open && hasStarted) {
// Reset when modal closes
hasStarted = false;
scanStatus = 'idle';
scanResults = [];
duration = 0;
scanTabRef?.reset();
}
});
function handleScanComplete(results: ScanResult[]) {
scanResults = results;
if (results.length > 0 && results[0].scanDuration) {
duration = results[0].scanDuration;
}
onComplete?.();
}
function handleScanError(_error: string) {
// Error is handled by ScanTab display
}
function handleStatusChange(status: 'idle' | 'scanning' | 'complete' | 'error') {
scanStatus = status;
}
function handleClose() {
if (scanStatus !== 'scanning') {
open = false;
onClose?.();
}
}
// Export functions
function downloadFile(content: string, filename: string, mimeType: string) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function sanitizeFilename(name: string): string {
return name.replace(/[/:]/g, '-').replace(/[^a-zA-Z0-9.-]/g, '_');
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function exportToCSV() {
const activeResult = scanResults[0];
if (!activeResult) return;
const headers = ['CVE ID', 'Severity', 'Package', 'Installed Version', 'Fixed Version', 'Description', 'Link'];
const rows = activeResult.vulnerabilities.map(v => [
v.id,
v.severity,
v.package,
v.version,
v.fixedVersion || '',
(v.description || '').replace(/"/g, '""'),
v.link || ''
]);
const csvContent = [
headers.join(','),
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
].join('\n');
const filename = `vuln-report-${sanitizeFilename(imageName)}-${activeResult.scanner}-${new Date().toISOString().split('T')[0]}.csv`;
downloadFile(csvContent, filename, 'text/csv');
}
function exportToMarkdown() {
const activeResult = scanResults[0];
if (!activeResult) return;
const summaryParts = [];
if (activeResult.summary.critical > 0) summaryParts.push(`**${activeResult.summary.critical} Critical**`);
if (activeResult.summary.high > 0) summaryParts.push(`**${activeResult.summary.high} High**`);
if (activeResult.summary.medium > 0) summaryParts.push(`${activeResult.summary.medium} Medium`);
if (activeResult.summary.low > 0) summaryParts.push(`${activeResult.summary.low} Low`);
if (activeResult.summary.negligible > 0) summaryParts.push(`${activeResult.summary.negligible} Negligible`);
if (activeResult.summary.unknown > 0) summaryParts.push(`${activeResult.summary.unknown} Unknown`);
let md = `# Vulnerability Scan Report\n\n`;
md += `**Image:** \`${imageName}\`\n\n`;
md += `**Scanner:** ${activeResult.scanner === 'grype' ? 'Grype (Anchore)' : 'Trivy (Aqua Security)'}\n\n`;
md += `**Duration:** ${formatDuration(activeResult.scanDuration || duration)}\n\n`;
md += `## Summary\n\n`;
md += summaryParts.length > 0 ? summaryParts.join(' | ') : 'No vulnerabilities found';
md += `\n\n**Total:** ${activeResult.vulnerabilities.length} vulnerabilities\n\n`;
if (activeResult.vulnerabilities.length > 0) {
const bySeverity: Record<string, typeof activeResult.vulnerabilities> = {};
for (const vuln of activeResult.vulnerabilities) {
const sev = vuln.severity.toLowerCase();
if (!bySeverity[sev]) bySeverity[sev] = [];
bySeverity[sev].push(vuln);
}
const severityOrder = ['critical', 'high', 'medium', 'low', 'negligible', 'unknown'];
for (const severity of severityOrder) {
const vulns = bySeverity[severity];
if (!vulns || vulns.length === 0) continue;
md += `## ${severity.charAt(0).toUpperCase() + severity.slice(1)} (${vulns.length})\n\n`;
for (const vuln of vulns) {
md += `### ${vuln.id}\n\n`;
md += `- **Package:** \`${vuln.package}\`\n`;
md += `- **Installed:** \`${vuln.version}\`\n`;
if (vuln.fixedVersion) {
md += `- **Fixed in:** \`${vuln.fixedVersion}\`\n`;
} else {
md += `- **Fixed in:** *No fix available*\n`;
}
if (vuln.link) {
md += `- **Reference:** [${vuln.id}](${vuln.link})\n`;
}
if (vuln.description) {
md += `\n${vuln.description}\n`;
}
md += `\n`;
}
}
}
md += `---\n\n*Report generated by Dockhand*\n`;
const filename = `vuln-report-${sanitizeFilename(imageName)}-${activeResult.scanner}-${new Date().toISOString().split('T')[0]}.md`;
downloadFile(md, filename, 'text/markdown');
}
function exportToJSON() {
const activeResult = scanResults[0];
if (!activeResult) return;
const report = {
image: imageName,
scanner: activeResult.scanner,
scanDuration: activeResult.scanDuration || duration,
summary: activeResult.summary,
vulnerabilities: activeResult.vulnerabilities
};
const jsonContent = JSON.stringify(report, null, 2);
const filename = `vuln-report-${sanitizeFilename(imageName)}-${activeResult.scanner}-${new Date().toISOString().split('T')[0]}.json`;
downloadFile(jsonContent, filename, 'application/json');
}
const totalVulnerabilities = $derived(
scanResults.reduce((total, r) => total + r.vulnerabilities.length, 0)
);
const hasCriticalOrHigh = $derived(
scanResults.some(r => r.summary.critical > 0 || r.summary.high > 0)
);
const effectiveEnvId = $derived(envId ?? $currentEnvironment?.id ?? null);
</script>
<Dialog.Root bind:open onOpenChange={handleClose}>
<Dialog.Content class="max-w-4xl h-[85vh] flex flex-col">
<Dialog.Header class="shrink-0 pb-2">
<Dialog.Title class="flex items-center gap-2">
{#if scanStatus === 'complete' && scanResults.length > 0}
{#if hasCriticalOrHigh}
<ShieldX class="w-5 h-5 text-red-500" />
{:else if totalVulnerabilities > 0}
<ShieldAlert class="w-5 h-5 text-yellow-500" />
{:else}
<ShieldCheck class="w-5 h-5 text-green-500" />
{/if}
{:else if scanStatus === 'complete'}
<CheckCircle2 class="w-5 h-5 text-green-500" />
{:else if scanStatus === 'error'}
<XCircle class="w-5 h-5 text-red-500" />
{:else}
<ShieldCheck class="w-5 h-5" />
{/if}
Vulnerability scan
<code class="text-sm font-normal bg-muted px-1.5 py-0.5 rounded ml-1">{imageName}</code>
</Dialog.Title>
</Dialog.Header>
<div class="flex-1 min-h-0 flex flex-col overflow-hidden py-2">
<ScanTab
bind:this={scanTabRef}
{imageName}
envId={effectiveEnvId}
autoStart={hasStarted}
onComplete={handleScanComplete}
onError={handleScanError}
onStatusChange={handleStatusChange}
/>
</div>
<Dialog.Footer class="shrink-0 flex justify-between">
<div class="flex gap-2">
{#if scanStatus === 'error'}
<Button variant="outline" onclick={() => scanTabRef?.startScan()}>
Retry
</Button>
{/if}
{#if scanStatus === 'complete' && scanResults.length > 0 && totalVulnerabilities > 0}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button variant="outline" {...props}>
<Download class="w-4 h-4 mr-2" />
Export
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start">
<DropdownMenu.Item onclick={exportToMarkdown}>
<FileText class="w-4 h-4 mr-2 text-blue-500" />
Markdown report (.md)
</DropdownMenu.Item>
<DropdownMenu.Item onclick={exportToCSV}>
<FileSpreadsheet class="w-4 h-4 mr-2 text-green-500" />
CSV spreadsheet (.csv)
</DropdownMenu.Item>
<DropdownMenu.Item onclick={exportToJSON}>
<FileText class="w-4 h-4 mr-2 text-amber-500" />
JSON data (.json)
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
</div>
<Button
variant={scanStatus === 'complete' ? 'default' : 'secondary'}
onclick={handleClose}
disabled={scanStatus === 'scanning'}
>
{#if scanStatus === 'scanning'}
Scanning...
{:else}
Close
{/if}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,292 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Badge } from '$lib/components/ui/badge';
import * as Select from '$lib/components/ui/select';
import { CheckCircle2, XCircle, Upload, Server, Settings2, Icon, ArrowBigRight } from 'lucide-svelte';
import { whale } from '@lucide/lab';
import { currentEnvironment } from '$lib/stores/environment';
import PushTab from '$lib/components/PushTab.svelte';
interface Registry {
id: number;
name: string;
url: string;
username?: string;
hasCredentials: boolean;
is_default: boolean;
}
interface Props {
open: boolean;
imageId: string;
imageName: string;
registries: Registry[];
envId?: number | null;
onClose?: () => void;
onComplete?: () => void;
}
let {
open = $bindable(),
imageId,
imageName,
registries,
envId,
onClose,
onComplete
}: Props = $props();
// Component ref
let pushTabRef = $state<PushTab | undefined>();
// Step state: configure → push
let currentStep = $state<'configure' | 'push'>('configure');
// Configuration
let targetRegistryId = $state<number | null>(null);
let customTag = $state('');
// Status
let pushStatus = $state<'idle' | 'pushing' | 'complete' | 'error'>('idle');
let pushStarted = $state(false);
// Computed - allow Docker Hub if it has credentials
const pushableRegistries = $derived(registries.filter(r => !isDockerHub(r) || r.hasCredentials));
const targetRegistry = $derived(registries.find(r => r.id === targetRegistryId));
const targetImageName = $derived(() => {
if (!targetRegistryId || !targetRegistry) return customTag || 'image:latest';
const tag = customTag ? (customTag.includes(':') ? customTag : customTag + ':latest') : 'image:latest';
// Docker Hub doesn't need host prefix
if (isDockerHub(targetRegistry)) {
return tag;
}
const host = new URL(targetRegistry.url).host;
return `${host}/${tag}`;
});
const isProcessing = $derived(pushStatus === 'pushing');
function isDockerHub(registry: Registry): boolean {
const url = registry.url.toLowerCase();
return url.includes('docker.io') || url.includes('hub.docker.com') || url.includes('registry.hub.docker.com');
}
// Extract base image name (without registry prefix)
function getBaseImageName(): string {
const nameWithoutRegistry = imageName.includes('/')
? imageName.split('/').slice(-1)[0]
: imageName;
return nameWithoutRegistry.includes(':') ? nameWithoutRegistry : `${nameWithoutRegistry}:latest`;
}
$effect(() => {
if (!open) {
// Reset when modal closes
currentStep = 'configure';
targetRegistryId = null;
customTag = '';
pushStatus = 'idle';
pushStarted = false;
pushTabRef?.reset();
} else {
// Set initial values when modal opens
// Only preselect if there's exactly one registry
targetRegistryId = pushableRegistries.length === 1 ? pushableRegistries[0].id : null;
// Pre-fill target tag with source image name
customTag = getBaseImageName();
}
});
// Auto-prefix with username when Docker Hub registry is selected
$effect(() => {
if (targetRegistry && isDockerHub(targetRegistry) && targetRegistry.username) {
const baseImage = getBaseImageName();
const currentBase = customTag.includes('/') ? customTag.split('/').slice(-1)[0] : customTag;
// Only update if the base image matches and doesn't already have the correct prefix
if (!customTag.startsWith(`${targetRegistry.username}/`)) {
customTag = `${targetRegistry.username}/${currentBase || baseImage}`;
}
}
});
function startPush() {
currentStep = 'push';
pushStarted = true;
setTimeout(() => pushTabRef?.startPush(), 100);
}
function handlePushComplete(_targetTag: string) {
pushStatus = 'complete';
onComplete?.();
}
function handlePushError(_error: string) {
pushStatus = 'error';
}
function handlePushStatusChange(status: 'idle' | 'pushing' | 'complete' | 'error') {
pushStatus = status;
}
function handleClose() {
if (!isProcessing) {
open = false;
onClose?.();
}
}
const effectiveEnvId = $derived(envId ?? $currentEnvironment?.id ?? null);
</script>
<Dialog.Root bind:open onOpenChange={handleClose}>
<Dialog.Content class="max-w-3xl h-[70vh] flex flex-col">
<Dialog.Header class="shrink-0 pb-2">
<Dialog.Title class="flex items-center gap-2">
{#if pushStatus === 'complete'}
<CheckCircle2 class="w-5 h-5 text-green-500" />
{:else if pushStatus === 'error'}
<XCircle class="w-5 h-5 text-red-500" />
{:else}
<Upload class="w-5 h-5" />
{/if}
Push to registry
<code class="text-sm font-normal bg-muted px-1.5 py-0.5 rounded ml-1">{imageName}</code>
</Dialog.Title>
</Dialog.Header>
<!-- Step tabs -->
<div class="flex items-center border-b shrink-0">
<button
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors cursor-pointer {currentStep === 'configure' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => { if (!isProcessing && currentStep !== 'configure') currentStep = 'configure'; }}
disabled={isProcessing}
>
<Settings2 class="w-3.5 h-3.5 inline mr-1.5" />
Configure
</button>
<ArrowBigRight class="w-3.5 h-3.5 text-muted-foreground/50 shrink-0" />
<button
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors cursor-pointer {currentStep === 'push' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => { if (!isProcessing && pushStatus !== 'idle') currentStep = 'push'; }}
disabled={isProcessing || pushStatus === 'idle'}
>
<Upload class="w-3.5 h-3.5 inline mr-1.5" />
Push
{#if pushStatus === 'complete'}
<CheckCircle2 class="w-3.5 h-3.5 inline ml-1 text-green-500" />
{:else if pushStatus === 'error'}
<XCircle class="w-3.5 h-3.5 inline ml-1 text-red-500" />
{:else}
<CheckCircle2 class="w-3.5 h-3.5 inline ml-1 invisible" />
{/if}
</button>
</div>
<div class="flex-1 min-h-0 flex flex-col overflow-hidden py-4">
<!-- Configuration Step -->
<div class="space-y-4 px-1" class:hidden={currentStep !== 'configure'}>
<div class="space-y-2">
<Label>Source image</Label>
<div class="p-2 bg-muted rounded text-sm">
<code class="break-all">{imageName}</code>
</div>
</div>
<div class="space-y-2">
<Label>Target registry</Label>
<Select.Root type="single" value={targetRegistryId ? String(targetRegistryId) : undefined} onValueChange={(v) => targetRegistryId = Number(v)}>
<Select.Trigger class="w-full h-9 justify-start">
{#if targetRegistry}
{#if isDockerHub(targetRegistry)}
<Icon iconNode={whale} class="w-4 h-4 mr-2 text-muted-foreground" />
{:else}
<Server class="w-4 h-4 mr-2 text-muted-foreground" />
{/if}
<span class="flex-1 text-left">{targetRegistry.name}{targetRegistry.hasCredentials ? ' (auth)' : ''}</span>
{:else}
<span class="text-muted-foreground">Select registry</span>
{/if}
</Select.Trigger>
<Select.Content>
{#each pushableRegistries as registry}
<Select.Item value={String(registry.id)} label={registry.name}>
{#if isDockerHub(registry)}
<Icon iconNode={whale} class="w-4 h-4 mr-2 text-muted-foreground" />
{:else}
<Server class="w-4 h-4 mr-2 text-muted-foreground" />
{/if}
{registry.name}
{#if registry.hasCredentials}
<Badge variant="outline" class="ml-2 text-xs">auth</Badge>
{/if}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if pushableRegistries.length === 0}
<p class="text-xs text-muted-foreground">No target registries available. Add a private registry in Settings.</p>
{/if}
</div>
<div class="space-y-2">
<Label>Image name/tag</Label>
<Input
bind:value={customTag}
placeholder="myimage:latest"
/>
<p class="text-xs text-muted-foreground">
Will be pushed as: <code class="bg-muted px-1 py-0.5 rounded">{targetImageName()}</code>
</p>
</div>
</div>
<!-- Push Step -->
<div class="flex flex-col flex-1 min-h-0" class:hidden={currentStep !== 'push'}>
<PushTab
bind:this={pushTabRef}
sourceImageName={imageName}
registryId={targetRegistryId ?? 0}
newTag={customTag}
registryName={targetRegistry?.name || 'registry'}
envId={effectiveEnvId}
autoStart={pushStarted && pushStatus === 'idle'}
onComplete={handlePushComplete}
onError={handlePushError}
onStatusChange={handlePushStatusChange}
/>
</div>
</div>
<Dialog.Footer class="shrink-0 flex justify-between">
<div>
{#if currentStep === 'push' && pushStatus === 'error'}
<Button variant="outline" onclick={() => pushTabRef?.startPush()}>
Retry push
</Button>
{/if}
</div>
<div class="flex gap-2">
<Button
variant="outline"
onclick={handleClose}
disabled={isProcessing}
>
{pushStatus === 'complete' ? 'Done' : 'Cancel'}
</Button>
{#if currentStep === 'configure'}
<Button
onclick={startPush}
disabled={!targetRegistryId || pushableRegistries.length === 0}
>
<Upload class="w-4 h-4 mr-2" />
Push
</Button>
{/if}
</div>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,214 @@
<script lang="ts">
import { Badge } from '$lib/components/ui/badge';
import { CheckCircle2, ExternalLink } from 'lucide-svelte';
interface ScanResult {
scanner: 'grype' | 'trivy';
imageId?: string;
imageName?: string;
scanDuration?: number;
summary: {
critical: number;
high: number;
medium: number;
low: number;
negligible: number;
unknown: number;
};
vulnerabilities: Array<{
id: string;
severity: string;
package: string;
version: string;
fixedVersion?: string;
description?: string;
link?: string;
}>;
}
interface Props {
results: ScanResult[];
compact?: boolean;
}
let { results, compact = false }: Props = $props();
let activeTab = $state<'grype' | 'trivy'>(results[0]?.scanner || 'grype');
let expandedVulns = $state<Set<string>>(new Set());
const activeResult = $derived(results.find(r => r.scanner === activeTab) || results[0]);
function formatDuration(ms?: number): string {
if (!ms) return '-';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function getSeverityColor(severity: string): string {
switch (severity.toLowerCase()) {
case 'critical':
return 'bg-red-500/10 text-red-500 border-red-500/30';
case 'high':
return 'bg-orange-500/10 text-orange-500 border-orange-500/30';
case 'medium':
return 'bg-yellow-500/10 text-yellow-600 border-yellow-500/30';
case 'low':
return 'bg-blue-500/10 text-blue-500 border-blue-500/30';
case 'negligible':
case 'unknown':
default:
return 'bg-gray-500/10 text-gray-500 border-gray-500/30';
}
}
function toggleVulnDetails(id: string) {
if (expandedVulns.has(id)) {
expandedVulns.delete(id);
} else {
expandedVulns.add(id);
}
expandedVulns = new Set(expandedVulns);
}
</script>
{#if results.length === 0}
<div class="text-sm text-muted-foreground">No scan results available</div>
{:else}
<div class="flex flex-col gap-2 h-full">
<!-- Scanner tabs (only if multiple results) -->
{#if results.length > 1}
<div class="flex gap-1 border-b shrink-0">
{#each results as r}
<button
class="px-3 py-1.5 text-xs font-medium border-b-2 transition-colors cursor-pointer {activeTab === r.scanner ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => activeTab = r.scanner}
>
{r.scanner === 'grype' ? 'Grype' : 'Trivy'}
{#if r.summary.critical > 0 || r.summary.high > 0}
<Badge variant="outline" class="ml-1.5 bg-red-500/10 text-red-500 border-red-500/30 text-xs py-0">
{r.summary.critical + r.summary.high}
</Badge>
{:else}
<Badge variant="outline" class="ml-1.5 bg-green-500/10 text-green-500 border-green-500/30 text-xs py-0">
{r.vulnerabilities.length}
</Badge>
{/if}
</button>
{/each}
</div>
{/if}
{#if activeResult}
<!-- Summary badges (compact) -->
<div class="flex flex-wrap items-center gap-1.5 shrink-0">
{#if activeResult.summary.critical > 0}
<Badge variant="outline" class="bg-red-500/10 text-red-500 border-red-500/30 text-xs py-0">
{activeResult.summary.critical} Critical
</Badge>
{/if}
{#if activeResult.summary.high > 0}
<Badge variant="outline" class="bg-orange-500/10 text-orange-500 border-orange-500/30 text-xs py-0">
{activeResult.summary.high} High
</Badge>
{/if}
{#if activeResult.summary.medium > 0}
<Badge variant="outline" class="bg-yellow-500/10 text-yellow-600 border-yellow-500/30 text-xs py-0">
{activeResult.summary.medium} Medium
</Badge>
{/if}
{#if activeResult.summary.low > 0}
<Badge variant="outline" class="bg-blue-500/10 text-blue-500 border-blue-500/30 text-xs py-0">
{activeResult.summary.low} Low
</Badge>
{/if}
{#if activeResult.summary.negligible > 0}
<Badge variant="outline" class="bg-gray-500/10 text-gray-500 border-gray-500/30 text-xs py-0">
{activeResult.summary.negligible} Negligible
</Badge>
{/if}
{#if activeResult.summary.unknown > 0}
<Badge variant="outline" class="bg-gray-500/10 text-gray-500 border-gray-500/30 text-xs py-0">
{activeResult.summary.unknown} Unknown
</Badge>
{/if}
{#if activeResult.vulnerabilities.length === 0}
<Badge variant="outline" class="bg-green-500/10 text-green-500 border-green-500/30 text-xs py-0">
<CheckCircle2 class="w-3 h-3 mr-1" />
No vulnerabilities
</Badge>
{/if}
<span class="text-xs text-muted-foreground ml-2">
{activeResult.scanner === 'grype' ? 'Grype' : 'Trivy'}{activeResult.vulnerabilities.length} total
{#if activeResult.scanDuration}{formatDuration(activeResult.scanDuration)}{/if}
</span>
</div>
<!-- Vulnerability list (takes remaining space) -->
{#if activeResult.vulnerabilities.length > 0 && !compact}
<div class="border rounded-lg overflow-hidden flex-1 min-h-0 overflow-y-auto">
<table class="w-full text-xs">
<thead class="bg-muted sticky top-0">
<tr>
<th class="text-left py-1.5 px-2 font-medium w-[22%]">CVE ID</th>
<th class="text-left py-1.5 px-2 font-medium w-[12%]">Severity</th>
<th class="text-left py-1.5 px-2 font-medium w-[28%]">Package</th>
<th class="text-left py-1.5 px-2 font-medium w-[18%]">Installed</th>
<th class="text-left py-1.5 px-2 font-medium w-[20%]">Fixed in</th>
</tr>
</thead>
<tbody>
{#each activeResult.vulnerabilities as vuln, i}
<tr
class="border-t border-muted hover:bg-muted/30 cursor-pointer transition-colors"
onclick={() => toggleVulnDetails(vuln.id + i)}
>
<td class="py-1 px-2">
<div class="flex items-center gap-1">
<code class="text-xs">{vuln.id}</code>
{#if vuln.link}
<a
href={vuln.link}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => e.stopPropagation()}
class="text-muted-foreground hover:text-foreground"
>
<ExternalLink class="w-2.5 h-2.5" />
</a>
{/if}
</div>
</td>
<td class="py-1 px-2">
<Badge variant="outline" class="{getSeverityColor(vuln.severity)} text-xs py-0 px-1.5">
{vuln.severity}
</Badge>
</td>
<td class="py-1 px-2">
<code class="text-xs">{vuln.package}</code>
</td>
<td class="py-1 px-2">
<code class="text-xs text-muted-foreground">{vuln.version}</code>
</td>
<td class="py-1 px-2">
{#if vuln.fixedVersion}
<code class="text-xs text-green-600">{vuln.fixedVersion}</code>
{:else}
<span class="text-xs text-muted-foreground italic">-</span>
{/if}
</td>
</tr>
{#if expandedVulns.has(vuln.id + i) && vuln.description}
<tr class="bg-muted/20">
<td colspan="5" class="py-1.5 px-2">
<p class="text-xs text-muted-foreground">{vuln.description}</p>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{/if}
{/if}
</div>
{/if}

View File

@@ -0,0 +1,756 @@
<script lang="ts">
import { tick } from 'svelte';
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { ShieldCheck, ShieldAlert, ShieldX, AlertTriangle, Info, ExternalLink, Loader2, CheckCircle2, XCircle, Terminal, Download, FileText, FileSpreadsheet, Sun, Moon } from 'lucide-svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
interface Props {
open: boolean;
imageName: string;
envId?: number | null;
onOpenChange?: (open: boolean) => void;
}
let { open = $bindable(), imageName, envId, onOpenChange }: Props = $props();
type ScanStage = 'idle' | 'checking' | 'pulling-scanner' | 'scanning' | 'parsing' | 'complete' | 'error';
interface VulnerabilitySummary {
critical: number;
high: number;
medium: number;
low: number;
negligible: number;
unknown: number;
}
interface Vulnerability {
id: string;
severity: string;
package: string;
version: string;
fixedVersion?: string;
description?: string;
link?: string;
scanner: 'grype' | 'trivy';
}
interface ScanResult {
imageId: string;
imageName: string;
scanner: 'grype' | 'trivy';
scannedAt: string;
vulnerabilities: Vulnerability[];
summary: VulnerabilitySummary;
scanDuration: number;
error?: string;
}
let stage = $state<ScanStage>('idle');
let message = $state('');
let progress = $state(0);
let error = $state('');
let result = $state<ScanResult | null>(null);
let results = $state<ScanResult[]>([]);
let scanner = $state<'grype' | 'trivy' | null>(null);
let expandedVulns = $state<Set<string>>(new Set());
let outputLinesByScanner = $state<Record<string, string[]>>({ grype: [], trivy: [], general: [] });
let outputContainer: HTMLDivElement | undefined;
let activeTab = $state<'grype' | 'trivy'>('grype');
let scannerErrors = $state<Record<string, string>>({});
let logDarkMode = $state(true);
// Get output lines for active scanner (or all if scanning)
const activeOutputLines = $derived(
stage === 'complete' && results.length > 1
? outputLinesByScanner[activeTab] || []
: [...outputLinesByScanner.general, ...outputLinesByScanner.grype, ...outputLinesByScanner.trivy]
);
// Computed total vulnerabilities across all results
const totalVulnerabilities = $derived(
results.length > 0
? results.reduce((sum, r) => sum + r.summary.critical + r.summary.high + r.summary.medium + r.summary.low + r.summary.negligible + r.summary.unknown, 0)
: (result ? result.summary.critical + result.summary.high + result.summary.medium + result.summary.low + result.summary.negligible + result.summary.unknown : 0)
);
// Get active result for display
const activeResult = $derived(
results.length > 1
? results.find(r => r.scanner === activeTab) || results[0]
: results[0] || result
);
// Reset state when modal opens
$effect(() => {
if (open) {
stage = 'idle';
message = '';
progress = 0;
error = '';
result = null;
results = [];
expandedVulns = new Set();
outputLinesByScanner = { grype: [], trivy: [], general: [] };
activeTab = 'grype';
scannerErrors = {};
startScan();
}
});
async function startScan() {
stage = 'checking';
message = 'Starting vulnerability scan...';
progress = 5;
error = '';
result = null;
try {
const url = envId ? `/api/images/scan?env=${envId}` : '/api/images/scan';
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageName })
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
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.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
handleProgress(data);
} catch (e) {
// Ignore parse errors
}
}
}
}
} catch (err) {
error = err instanceof Error ? err.message : String(err);
stage = 'error';
message = `Scan failed: ${error}`;
}
}
async function scrollOutputToBottom() {
await tick();
if (outputContainer) {
outputContainer.scrollTop = outputContainer.scrollHeight;
}
}
function handleProgress(data: any) {
// Don't overwrite stage with 'error' for individual scanner failures when using 'both' mode
// The scanner sends 'error' stage per-scanner, but we want to continue scanning
if (data.stage === 'error' && data.scanner) {
// Store individual scanner error, don't change global stage yet
scannerErrors[data.scanner] = data.error || data.message || 'Unknown error';
} else {
stage = data.stage || stage;
}
message = data.message || message;
progress = data.progress ?? progress;
scanner = data.scanner || scanner;
if (data.result) {
result = data.result;
}
if (data.results && Array.isArray(data.results)) {
results = data.results;
if (results.length > 0) {
activeTab = results[0].scanner;
}
}
if (data.error && !data.scanner) {
// Global error (not per-scanner)
error = data.error;
}
// Store output by scanner with prefix for coloring
const targetScanner = data.scanner || 'general';
const prefix = targetScanner === 'grype' ? '[grype] ' : targetScanner === 'trivy' ? '[trivy] ' : '[dockhand] ';
if (data.output) {
outputLinesByScanner[targetScanner] = [...(outputLinesByScanner[targetScanner] || []), prefix + data.output];
scrollOutputToBottom();
}
if (data.message && !data.output) {
outputLinesByScanner[targetScanner] = [...(outputLinesByScanner[targetScanner] || []), prefix + data.message];
scrollOutputToBottom();
}
}
function toggleVulnDetails(vulnId: string) {
const newSet = new Set(expandedVulns);
if (newSet.has(vulnId)) {
newSet.delete(vulnId);
} else {
newSet.add(vulnId);
}
expandedVulns = newSet;
}
function toggleLogTheme() {
logDarkMode = !logDarkMode;
}
function getSeverityColor(severity: string): string {
switch (severity.toLowerCase()) {
case 'critical': return 'bg-red-500/10 text-red-500 border-red-500/30';
case 'high': return 'bg-orange-500/10 text-orange-500 border-orange-500/30';
case 'medium': return 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-500 border-yellow-500/30';
case 'low': return 'bg-blue-500/10 text-blue-500 border-blue-500/30';
case 'negligible': return 'bg-gray-500/10 text-gray-500 border-gray-500/30';
default: return 'bg-gray-500/10 text-gray-500 border-gray-500/30';
}
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function downloadFile(content: string, filename: string, mimeType: string) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function sanitizeFilename(name: string): string {
return name.replace(/[/:]/g, '-').replace(/[^a-zA-Z0-9.-]/g, '_');
}
function exportToCSV() {
if (!activeResult) return;
const headers = ['CVE ID', 'Severity', 'Package', 'Installed Version', 'Fixed Version', 'Description', 'Link'];
const rows = activeResult.vulnerabilities.map(v => [
v.id,
v.severity,
v.package,
v.version,
v.fixedVersion || '',
(v.description || '').replace(/"/g, '""'),
v.link || ''
]);
const csvContent = [
headers.join(','),
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
].join('\n');
const filename = `vuln-report-${sanitizeFilename(imageName)}-${activeResult.scanner}-${new Date().toISOString().split('T')[0]}.csv`;
downloadFile(csvContent, filename, 'text/csv');
}
function exportToMarkdown() {
if (!activeResult) return;
const scanDate = new Date(activeResult.scannedAt).toLocaleString();
const summaryParts = [];
if (activeResult.summary.critical > 0) summaryParts.push(`**${activeResult.summary.critical} Critical**`);
if (activeResult.summary.high > 0) summaryParts.push(`**${activeResult.summary.high} High**`);
if (activeResult.summary.medium > 0) summaryParts.push(`${activeResult.summary.medium} Medium`);
if (activeResult.summary.low > 0) summaryParts.push(`${activeResult.summary.low} Low`);
if (activeResult.summary.negligible > 0) summaryParts.push(`${activeResult.summary.negligible} Negligible`);
if (activeResult.summary.unknown > 0) summaryParts.push(`${activeResult.summary.unknown} Unknown`);
let md = `# Vulnerability Scan Report\n\n`;
md += `**Image:** \`${imageName}\`\n\n`;
md += `**Scanner:** ${activeResult.scanner === 'grype' ? 'Grype (Anchore)' : 'Trivy (Aqua Security)'}\n\n`;
md += `**Scan Date:** ${scanDate}\n\n`;
md += `**Duration:** ${formatDuration(activeResult.scanDuration)}\n\n`;
md += `## Summary\n\n`;
md += summaryParts.length > 0 ? summaryParts.join(' | ') : 'No vulnerabilities found';
md += `\n\n**Total:** ${activeResult.vulnerabilities.length} vulnerabilities\n\n`;
if (activeResult.vulnerabilities.length > 0) {
// Group by severity for better readability
const bySeverity: Record<string, Vulnerability[]> = {};
for (const vuln of activeResult.vulnerabilities) {
const sev = vuln.severity.toLowerCase();
if (!bySeverity[sev]) bySeverity[sev] = [];
bySeverity[sev].push(vuln);
}
const severityOrder = ['critical', 'high', 'medium', 'low', 'negligible', 'unknown'];
for (const severity of severityOrder) {
const vulns = bySeverity[severity];
if (!vulns || vulns.length === 0) continue;
md += `## ${severity.charAt(0).toUpperCase() + severity.slice(1)} (${vulns.length})\n\n`;
for (const vuln of vulns) {
md += `### ${vuln.id}\n\n`;
md += `- **Package:** \`${vuln.package}\`\n`;
md += `- **Installed:** \`${vuln.version}\`\n`;
if (vuln.fixedVersion) {
md += `- **Fixed in:** \`${vuln.fixedVersion}\`\n`;
} else {
md += `- **Fixed in:** *No fix available*\n`;
}
if (vuln.link) {
md += `- **Reference:** [${vuln.id}](${vuln.link})\n`;
}
if (vuln.description) {
md += `\n${vuln.description}\n`;
}
md += `\n`;
}
}
}
md += `---\n\n*Report generated by Dockhand*\n`;
const filename = `vuln-report-${sanitizeFilename(imageName)}-${activeResult.scanner}-${new Date().toISOString().split('T')[0]}.md`;
downloadFile(md, filename, 'text/markdown');
}
function exportToJSON() {
if (!activeResult) return;
const report = {
image: imageName,
scanner: activeResult.scanner,
scannedAt: activeResult.scannedAt,
scanDuration: activeResult.scanDuration,
summary: activeResult.summary,
vulnerabilities: activeResult.vulnerabilities
};
const jsonContent = JSON.stringify(report, null, 2);
const filename = `vuln-report-${sanitizeFilename(imageName)}-${activeResult.scanner}-${new Date().toISOString().split('T')[0]}.json`;
downloadFile(jsonContent, filename, 'application/json');
}
</script>
<Dialog.Root bind:open onOpenChange={onOpenChange}>
<Dialog.Content class="max-w-6xl h-[90vh] flex flex-col">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
{#if stage === 'complete' && activeResult}
{#if activeResult.summary.critical > 0 || activeResult.summary.high > 0}
<ShieldX class="w-5 h-5 text-red-500" />
{:else if activeResult.summary.medium > 0}
<ShieldAlert class="w-5 h-5 text-yellow-500" />
{:else}
<ShieldCheck class="w-5 h-5 text-green-500" />
{/if}
{:else if stage === 'error'}
<XCircle class="w-5 h-5 text-red-500" />
{:else}
<ShieldCheck class="w-5 h-5" />
{/if}
Vulnerability scan
</Dialog.Title>
<Dialog.Description class="space-y-1">
<div>Scanning <code class="text-xs bg-muted px-1.5 py-0.5 rounded">{imageName}</code></div>
{#if activeResult?.imageId}
<div class="text-xs text-muted-foreground font-mono">SHA: {activeResult.imageId.replace('sha256:', '')}</div>
{/if}
</Dialog.Description>
</Dialog.Header>
<div class="flex-1 min-h-0 flex flex-col overflow-hidden">
{#if stage !== 'complete' && stage !== 'error'}
<!-- Scanning in progress -->
<div class="flex flex-col flex-1 min-h-0 py-4 gap-4">
<div class="flex items-center gap-3 shrink-0">
<Loader2 class="w-5 h-5 animate-spin text-primary" />
<span class="text-sm">{message}</span>
</div>
<div class="h-2 bg-muted rounded-full overflow-hidden shrink-0">
<div
class="h-full bg-primary transition-all duration-300"
style="width: {progress}%"
></div>
</div>
{#if scanner}
<p class="text-xs text-muted-foreground shrink-0">
Using {scanner === 'grype' ? 'Grype (Anchore)' : 'Trivy (Aqua Security)'} scanner
</p>
{/if}
<!-- Output Log -->
<div class="flex flex-col flex-1 min-h-0">
<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>Scanner output</span>
</div>
<button type="button" onclick={toggleLogTheme} class="p-1 rounded hover:bg-muted transition-colors" 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 activeOutputLines as line}
<div class="whitespace-pre-wrap break-all leading-relaxed flex items-start gap-1.5">
{#if line.startsWith('[grype]')}
<span class="inline-flex items-center px-1 rounded text-2xs font-medium bg-violet-500 text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]">grype</span>
<span>{line.slice(8)}</span>
{:else if line.startsWith('[trivy]')}
<span class="inline-flex items-center px-1 rounded text-2xs font-medium bg-teal-500 text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]">trivy</span>
<span>{line.slice(8)}</span>
{:else if line.startsWith('[dockhand]')}
<span class="inline-flex items-center px-1 rounded text-2xs font-medium bg-slate-500 text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]">dockhand</span>
<span>{line.slice(11)}</span>
{:else}
<span>{line}</span>
{/if}
</div>
{/each}
</div>
</div>
</div>
{:else if stage === 'error'}
<!-- Error state -->
<div class="flex flex-col flex-1 min-h-0 py-4 gap-4">
<!-- Show individual scanner errors if available -->
{#if Object.keys(scannerErrors).length > 0}
<div class="flex flex-col gap-2 shrink-0">
{#each Object.entries(scannerErrors) as [scannerName, scannerError]}
<div class="p-3 rounded-lg bg-destructive/10 border border-destructive/30">
<div class="flex items-start gap-3">
<XCircle class="w-4 h-4 text-destructive mt-0.5 shrink-0" />
<div class="min-w-0">
<h4 class="font-medium text-destructive text-sm">{scannerName === 'grype' ? 'Grype' : 'Trivy'} failed</h4>
<p class="text-xs text-muted-foreground mt-1 break-words">{scannerError}</p>
</div>
</div>
</div>
{/each}
</div>
{:else}
<div class="p-4 rounded-lg bg-destructive/10 border border-destructive/30 shrink-0">
<div class="flex items-start gap-3">
<XCircle class="w-5 h-5 text-destructive mt-0.5" />
<div>
<h4 class="font-medium text-destructive">Scan failed</h4>
<p class="text-sm text-muted-foreground mt-1">{error}</p>
</div>
</div>
</div>
{/if}
<Button class="w-fit shrink-0" onclick={startScan}>
Retry scan
</Button>
<!-- Output Log -->
<div class="flex flex-col flex-1 min-h-0">
<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>Scanner output</span>
</div>
<button type="button" onclick={toggleLogTheme} class="p-1 rounded hover:bg-muted transition-colors" 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 activeOutputLines as line}
<div class="whitespace-pre-wrap break-all leading-relaxed flex items-start gap-1.5">
{#if line.startsWith('[grype]')}
<span class="inline-flex items-center px-1 rounded text-2xs font-medium bg-violet-500 text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]">grype</span>
<span>{line.slice(8)}</span>
{:else if line.startsWith('[trivy]')}
<span class="inline-flex items-center px-1 rounded text-2xs font-medium bg-teal-500 text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]">trivy</span>
<span>{line.slice(8)}</span>
{:else if line.startsWith('[dockhand]')}
<span class="inline-flex items-center px-1 rounded text-2xs font-medium bg-slate-500 text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]">dockhand</span>
<span>{line.slice(11)}</span>
{:else}
<span>{line}</span>
{/if}
</div>
{/each}
</div>
</div>
</div>
{:else if stage === 'complete' && activeResult}
<!-- Results -->
<div class="flex flex-col flex-1 min-h-0 py-2 gap-4">
<!-- Scanner tabs (only if multiple results) -->
{#if results.length > 1}
<div class="flex gap-1 border-b shrink-0">
{#each results as r}
<button
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors {activeTab === r.scanner ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => activeTab = r.scanner}
>
{r.scanner === 'grype' ? 'Grype' : 'Trivy'}
{#if r.summary.critical > 0 || r.summary.high > 0}
<Badge variant="outline" class="ml-2 bg-red-500/10 text-red-500 border-red-500/30 text-xs">
{r.summary.critical + r.summary.high}
</Badge>
{:else}
<Badge variant="outline" class="ml-2 bg-green-500/10 text-green-500 border-green-500/30 text-xs">
{r.vulnerabilities.length}
</Badge>
{/if}
</button>
{/each}
</div>
{/if}
<!-- Show any scanner errors that occurred (partial success) -->
{#if Object.keys(scannerErrors).length > 0}
<div class="flex flex-col gap-2 shrink-0">
{#each Object.entries(scannerErrors) as [scannerName, scannerError]}
<div class="p-2 rounded-lg bg-amber-500/10 border border-amber-500/30">
<div class="flex items-start gap-2">
<AlertTriangle class="w-4 h-4 text-amber-500 mt-0.5 shrink-0" />
<div class="min-w-0">
<span class="font-medium text-amber-600 dark:text-amber-500 text-sm">{scannerName === 'grype' ? 'Grype' : 'Trivy'} failed:</span>
<span class="text-xs text-muted-foreground ml-1">{scannerError}</span>
</div>
</div>
</div>
{/each}
</div>
{/if}
<!-- Summary -->
<div class="flex flex-wrap gap-2 shrink-0">
{#if activeResult.summary.critical > 0}
<Badge variant="outline" class="bg-red-500/10 text-red-500 border-red-500/30">
{activeResult.summary.critical} Critical
</Badge>
{/if}
{#if activeResult.summary.high > 0}
<Badge variant="outline" class="bg-orange-500/10 text-orange-500 border-orange-500/30">
{activeResult.summary.high} High
</Badge>
{/if}
{#if activeResult.summary.medium > 0}
<Badge variant="outline" class="bg-yellow-500/10 text-yellow-600 dark:text-yellow-500 border-yellow-500/30">
{activeResult.summary.medium} Medium
</Badge>
{/if}
{#if activeResult.summary.low > 0}
<Badge variant="outline" class="bg-blue-500/10 text-blue-500 border-blue-500/30">
{activeResult.summary.low} Low
</Badge>
{/if}
{#if activeResult.summary.negligible > 0}
<Badge variant="outline" class="bg-gray-500/10 text-gray-500 border-gray-500/30">
{activeResult.summary.negligible} Negligible
</Badge>
{/if}
{#if activeResult.summary.unknown > 0}
<Badge variant="outline" class="bg-gray-500/10 text-gray-500 border-gray-500/30">
{activeResult.summary.unknown} Unknown
</Badge>
{/if}
{#if activeResult.vulnerabilities.length === 0}
<Badge variant="outline" class="bg-green-500/10 text-green-500 border-green-500/30">
<CheckCircle2 class="w-3 h-3 mr-1" />
No vulnerabilities found
</Badge>
{/if}
</div>
<!-- Scan info -->
<div class="text-xs text-muted-foreground flex items-center gap-3 shrink-0">
<span>Scanner: {activeResult.scanner === 'grype' ? 'Grype' : 'Trivy'}</span>
<span>Duration: {formatDuration(activeResult.scanDuration)}</span>
<span>Total: {activeResult.vulnerabilities.length} vulnerabilities</span>
</div>
<!-- Vulnerability list -->
{#if activeResult.vulnerabilities.length > 0}
<div class="border rounded-lg flex-1 min-h-0 max-h-[40vh] overflow-y-auto">
<table class="w-full text-sm">
<thead class="bg-muted sticky top-0">
<tr>
<th class="text-left py-2 px-3 font-medium w-[20%]">CVE ID</th>
<th class="text-left py-2 px-3 font-medium w-[15%]">Severity</th>
<th class="text-left py-2 px-3 font-medium w-[25%]">Package</th>
<th class="text-left py-2 px-3 font-medium w-[20%]">Installed</th>
<th class="text-left py-2 px-3 font-medium w-[20%]">Fixed in</th>
</tr>
</thead>
<tbody>
{#each activeResult.vulnerabilities.slice(0, 100) as vuln, i}
<tr
class="border-t border-muted hover:bg-muted/30 cursor-pointer transition-colors"
onclick={() => toggleVulnDetails(vuln.id + i)}
>
<td class="py-2 px-3">
<div class="flex items-center gap-1.5">
<code class="text-xs">{vuln.id}</code>
{#if vuln.link}
<a
href={vuln.link}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => e.stopPropagation()}
class="text-muted-foreground hover:text-foreground"
>
<ExternalLink class="w-3 h-3" />
</a>
{/if}
</div>
</td>
<td class="py-2 px-3">
<Badge variant="outline" class={getSeverityColor(vuln.severity)}>
{vuln.severity}
</Badge>
</td>
<td class="py-2 px-3">
<code class="text-xs">{vuln.package}</code>
</td>
<td class="py-2 px-3">
<code class="text-xs text-muted-foreground">{vuln.version}</code>
</td>
<td class="py-2 px-3">
{#if vuln.fixedVersion}
<code class="text-xs text-green-600 dark:text-green-500">{vuln.fixedVersion}</code>
{:else}
<span class="text-xs text-muted-foreground italic">No fix available</span>
{/if}
</td>
</tr>
{#if expandedVulns.has(vuln.id + i) && vuln.description}
<tr class="bg-muted/20">
<td colspan="5" class="py-2 px-3">
<p class="text-xs text-muted-foreground">{vuln.description}</p>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
{#if activeResult.vulnerabilities.length > 100}
<div class="py-2 px-3 bg-muted/50 text-xs text-muted-foreground text-center">
Showing 100 of {activeResult.vulnerabilities.length} vulnerabilities
</div>
{/if}
</div>
{/if}
<!-- Output Log -->
<div class="flex flex-col flex-1 min-h-[120px]">
<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>Scanner output ({activeOutputLines.length} lines)</span>
</div>
<button type="button" onclick={toggleLogTheme} class="p-1 rounded hover:bg-muted transition-colors" 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 activeOutputLines as line}
<div class="whitespace-pre-wrap break-all leading-relaxed flex items-start gap-1.5">
{#if line.startsWith('[grype]')}
<span class="inline-flex items-center px-1 rounded text-2xs font-medium bg-violet-500 text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]">grype</span>
<span>{line.slice(8)}</span>
{:else if line.startsWith('[trivy]')}
<span class="inline-flex items-center px-1 rounded text-2xs font-medium bg-teal-500 text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]">trivy</span>
<span>{line.slice(8)}</span>
{:else if line.startsWith('[dockhand]')}
<span class="inline-flex items-center px-1 rounded text-2xs font-medium bg-slate-500 text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]">dockhand</span>
<span>{line.slice(11)}</span>
{:else}
<span>{line}</span>
{/if}
</div>
{/each}
</div>
</div>
</div>
{/if}
</div>
<Dialog.Footer class="flex justify-between">
{#if stage === 'complete'}
<div class="flex gap-2">
<Button variant="outline" onclick={startScan}>
Rescan
</Button>
{#if activeResult && activeResult.vulnerabilities.length > 0}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button variant="outline" {...props}>
<Download class="w-4 h-4 mr-2" />
Report
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start">
<DropdownMenu.Item onclick={exportToMarkdown}>
<FileText class="w-4 h-4 mr-2 text-blue-500" />
Markdown report (.md)
</DropdownMenu.Item>
<DropdownMenu.Item onclick={exportToCSV}>
<FileSpreadsheet class="w-4 h-4 mr-2 text-green-500" />
CSV spreadsheet (.csv)
</DropdownMenu.Item>
<DropdownMenu.Item onclick={exportToJSON}>
<FileText class="w-4 h-4 mr-2 text-amber-500" />
JSON data (.json)
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
</div>
{:else}
<div></div>
{/if}
<Button variant="secondary" onclick={() => open = false}>
Close
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>