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

215 lines
7.1 KiB
Svelte

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