Files
dockhand/routes/images/VulnerabilityScanModal.svelte
Jarek Krochmalski 62e3c6439e Initial commit
2025-12-28 21:16:03 +01:00

757 lines
28 KiB
Svelte

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