mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-07 05:40:11 +00:00
215 lines
7.1 KiB
Svelte
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}
|