Files
dockhand-upstream/src/routes/images/VulnerabilitiesTab.svelte
T
2026-07-11 15:31:52 +02:00

236 lines
8.8 KiB
Svelte

<script lang="ts">
import { DataGrid } from '$lib/components/data-grid';
import { Badge } from '$lib/components/ui/badge';
import { appendEnvParam } from '$lib/stores/environment';
import { getSeverityColor } from '$lib/utils/vulnerability';
import type { Finding, FindingContainer, VulnerabilitySummary as Summary, SortField } from '$lib/utils/vulnerability';
import { formatDateTime } from '$lib/stores/settings';
import { goto } from '$app/navigation';
import ContainerInspectModal from '../containers/ContainerInspectModal.svelte';
import SeveritySummaryPills from '$lib/components/SeveritySummaryPills.svelte';
import {
ShieldCheck, ExternalLink,
ArrowUp, ArrowDown, ArrowUpDown, ShieldAlert, Box, Layers, NotepadText
} from 'lucide-svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
interface Props {
envId: number | null;
scannerEnabled: boolean;
// Server-windowed data + sort state are owned by the page (toolbar is in the header).
findings: Finding[];
summary: Summary;
loading: boolean;
/** Sliding-window: absolute index of findings[0] and the full row count. */
dataOffset?: number;
virtualTotal?: number;
sortField?: SortField;
sortDirection?: 'asc' | 'desc';
/** The grid scrolled outside the loaded window — shift it to this row. */
onWindowShift?: (targetStart: number) => void;
/** A grid header sort click — the page refetches from the server. */
onSortChange?: (field: SortField, direction: 'asc' | 'desc') => void;
/** Reports the visible row range for the header counter. */
onRangeChange?: (start: number, end: number) => void;
}
let {
envId,
scannerEnabled,
findings,
summary,
loading,
dataOffset = 0,
virtualTotal,
sortField = 'severity',
sortDirection = 'asc',
onWindowShift,
onSortChange,
onRangeChange
}: Props = $props();
// Container inspect modal
let showInspectModal = $state(false);
let inspectContainerId = $state('');
let inspectContainerName = $state('');
function inspectContainer(c: FindingContainer) {
inspectContainerId = c.id;
inspectContainerName = c.name;
showInspectModal = true;
}
function openStack(stack: string) {
goto(appendEnvParam(`/stacks?search=${encodeURIComponent(stack)}`, envId));
}
function toggleSort(field: SortField) {
const dir = field === sortField && sortDirection === 'asc' ? 'desc' : 'asc';
onSortChange?.(field, dir);
}
</script>
<div class="flex-1 min-h-0 flex flex-col gap-2">
<!-- Summary row: scanned count + severity pills -->
<div class="shrink-0 flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<span>Images scanned: <span class="font-medium text-foreground">{summary.imagesScanned}/{summary.totalImages}</span></span>
<span>Total: <span class="font-medium text-foreground">{summary.total}</span></span>
<SeveritySummaryPills counts={summary} />
</div>
{#if !scannerEnabled}
<div class="flex-1 min-h-0 flex flex-col items-center justify-center text-center gap-2 text-muted-foreground">
<ShieldAlert class="w-10 h-10 opacity-40" />
<p class="text-sm">No vulnerability scanner is configured for this environment.</p>
<a href="/settings?tab=environments" class="text-sm text-primary hover:underline">Configure a scanner in settings</a>
</div>
{:else}
{#if !loading && findings.length === 0}
<div class="flex-1 min-h-0 flex flex-col items-center justify-center text-center gap-2 text-muted-foreground">
<ShieldCheck class="w-10 h-10 opacity-40" />
<p class="text-sm">No vulnerabilities found.</p>
<p class="text-xs">Click "Scan all images" to populate this view.</p>
</div>
{:else}
<div class="flex-1 min-h-0 flex flex-col">
<!-- Findings arrive already filtered + sorted + paged from the server. -->
<DataGrid
data={findings}
keyField="key"
gridId="vulnerabilities"
virtualScroll
windowed
loading={loading}
windowOffset={dataOffset}
windowTotal={virtualTotal}
onWindowShift={onWindowShift}
wrapperClass="border rounded-lg"
sortState={{ field: sortField, direction: sortDirection }}
onSortChange={(state) => onSortChange?.(state.field as SortField, state.direction)}
onVisibleRangeChange={(start, end) => onRangeChange?.(start, end)}
>
{#snippet headerCell(column, sortState)}
{#if column.sortable}
<button
type="button"
onclick={() => toggleSort((column.sortField ?? column.id) as SortField)}
class="flex items-center gap-1 hover:text-foreground transition-colors w-full"
>
{column.label}
{#if sortState?.field === (column.sortField ?? column.id)}
{#if sortState.direction === 'asc'}
<ArrowUp class="w-3 h-3" />
{:else}
<ArrowDown class="w-3 h-3" />
{/if}
{:else}
<ArrowUpDown class="w-3 h-3 opacity-30" />
{/if}
</button>
{:else}
{column.label}
{/if}
{/snippet}
{#snippet cell(column, finding)}
{#if column.id === 'cve'}
{#snippet cveLink(extra: Record<string, unknown> = {})}
<a
href={finding.link || `https://nvd.nist.gov/vuln/detail/${finding.cve}`}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 font-mono text-xs text-blue-500 hover:underline"
{...extra}
>
{finding.cve}
<ExternalLink class="w-3 h-3 opacity-60" />
</a>
{/snippet}
{#if finding.description}
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
{@render cveLink(props)}
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="right" class="w-[28rem] max-w-[90vw] text-left">
<div class="flex gap-2">
<NotepadText class="w-4 h-4 mt-0.5 shrink-0 opacity-70" />
<p class="text-xs leading-relaxed text-left [text-wrap:normal]">{finding.description}</p>
</div>
</Tooltip.Content>
</Tooltip.Root>
{:else}
{@render cveLink()}
{/if}
{:else if column.id === 'package'}
<span class="font-mono text-xs truncate" title={finding.package}>{finding.package}</span>
{:else if column.id === 'severity'}
<Badge variant="outline" class="{getSeverityColor(finding.severity)} text-xs py-0 px-1.5">
{finding.severity}
</Badge>
{:else if column.id === 'installed'}
<span class="font-mono text-xs truncate" title={finding.installedVersion}>{finding.installedVersion}</span>
{:else if column.id === 'fixed'}
{#if finding.fixedVersion}
<span class="font-mono text-xs truncate" title={finding.fixedVersion}>{finding.fixedVersion}</span>
{:else}
<span class="text-xs text-muted-foreground">No fix</span>
{/if}
{:else if column.id === 'image'}
<span class="font-mono text-xs truncate" title={finding.imageName}>{finding.imageName}</span>
{:else if column.id === 'container'}
{#if finding.containers && finding.containers.length}
<div class="flex items-center gap-1 min-w-0">
<button
type="button"
onclick={() => inspectContainer(finding.containers![0])}
class="inline-flex items-center gap-1 text-xs text-blue-500 hover:underline truncate"
title={`Inspect ${finding.containers[0].name}`}
>
<Box class="w-3 h-3 shrink-0 opacity-70" />
<span class="truncate">{finding.containers[0].name}</span>
</button>
{#if finding.containers.length > 1}
<Badge variant="secondary" class="text-2xs px-1 py-0 h-4 shrink-0" title={finding.containers.map((c) => c.name).join(', ')}>
+{finding.containers.length - 1}
</Badge>
{/if}
</div>
{:else}
<span class="text-xs text-muted-foreground">—</span>
{/if}
{:else if column.id === 'stack'}
{#if finding.stacks && finding.stacks.length}
<button
type="button"
onclick={() => openStack(finding.stacks![0])}
class="inline-flex items-center gap-1 text-xs text-blue-500 hover:underline truncate"
title={`View stack ${finding.stacks[0]}`}
>
<Layers class="w-3 h-3 shrink-0 opacity-70" />
<span class="truncate">{finding.stacks[0]}</span>
</button>
{:else}
<span class="text-xs text-muted-foreground">—</span>
{/if}
{:else if column.id === 'scannedAt'}
{#if finding.scannedAt}
<span class="text-xs text-muted-foreground whitespace-nowrap" title={finding.scannedAt}>{formatDateTime(finding.scannedAt)}</span>
{:else}
<span class="text-xs text-muted-foreground">—</span>
{/if}
{/if}
{/snippet}
</DataGrid>
</div>
{/if}
{/if}
</div>
<ContainerInspectModal
bind:open={showInspectModal}
containerId={inspectContainerId}
containerName={inspectContainerName}
/>