mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-09 05:39:05 +00:00
Initial commit
This commit is contained in:
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}
|
||||
Reference in New Issue
Block a user