mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-09 21:29:04 +00:00
Initial commit
This commit is contained in:
9
routes/images/+page.server.ts
Normal file
9
routes/images/+page.server.ts
Normal 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
1044
routes/images/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
37
routes/images/ImageHistoryModal.svelte
Normal file
37
routes/images/ImageHistoryModal.svelte
Normal 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>
|
||||
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>
|
||||
369
routes/images/ImagePullProgressPopover.svelte
Normal file
369
routes/images/ImagePullProgressPopover.svelte
Normal 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} />
|
||||
285
routes/images/ImageScanModal.svelte
Normal file
285
routes/images/ImageScanModal.svelte
Normal 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>
|
||||
292
routes/images/PushToRegistryModal.svelte
Normal file
292
routes/images/PushToRegistryModal.svelte
Normal 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>
|
||||
214
routes/images/ScanResultsView.svelte
Normal file
214
routes/images/ScanResultsView.svelte
Normal 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}
|
||||
756
routes/images/VulnerabilityScanModal.svelte
Normal file
756
routes/images/VulnerabilityScanModal.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user