mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 06:23:18 +00:00
feat(security): per-image scroll + retention cap in scan history (#1231)
* feat(security): per-image scroll + retention cap in scan history Long scan histories for hot images used to monopolise the Scan history sheet: a single image with dozens of scans pushed every other image off screen, and the underlying vulnerability_scans table grew without bound. Each image group's table now renders inside its own ScrollArea capped at max-h-64 (~6 rows visible) so a busy image scrolls independently while the list of images stays navigable. A new global setting scan_history_per_image_limit (default 50, min 5, max 1000) backs both a window-function query that caps the response per image_ref and a prune step that runs on the existing MonitorService cleanup tick. The response now carries cappedImageRefs + perImageLimit so the UI can render a "Capped at N · older scans pruned" hint on groups sitting at the ceiling without a second settings round-trip. Single-image deep-dive (imageRef query param) bypasses the cap so a user clicking into one image can still see its full history. The prune uses self-contained subqueries to avoid SQLITE_MAX_VARIABLE_NUMBER issues on first-run installs with large backlogs, and explicitly deletes child rows from vulnerability_details, secret_findings, and misconfig_findings inside a transaction since FK cascade is not enabled at the connection level. Settings → Developer → Data retention gains a "Scan history per image" field. * fix(security): skip searchDraft debounce on mount to stop page-reset race The searchDraft debounce useEffect fires once on initial mount with the unchanged value and, 300ms later, unconditionally calls setPage(0). When a user (or a test) paginates inside that 300ms window, the pending debounce silently undoes the page advance. CI surfaced this as a flaky 3rd fetch in the "advances offset when the user pages forward" test once the per-image cap work added enough state-update overhead to push the click past the 300ms threshold on the slower Linux jsdom run. Track searchDraft with a ref and exit the effect when the value has not actually changed, so the debounce only runs in response to real user typing.
This commit is contained in:
@@ -65,6 +65,7 @@ export function SecurityHistoryView({ open, onClose }: SecurityHistoryViewProps)
|
||||
const { activeNode } = useNodes();
|
||||
const [scans, setScans] = useState<VulnerabilityScan[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [capInfo, setCapInfo] = useState<{ perImageLimit: number; refs: Set<string> } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchDraft, setSearchDraft] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -88,6 +89,9 @@ export function SecurityHistoryView({ open, onClose }: SecurityHistoryViewProps)
|
||||
const items: VulnerabilityScan[] = Array.isArray(body?.items) ? body.items : [];
|
||||
setScans(items);
|
||||
setTotal(typeof body?.total === 'number' ? body.total : items.length);
|
||||
const limit = typeof body?.perImageLimit === 'number' ? body.perImageLimit : 0;
|
||||
const refs: string[] = Array.isArray(body?.cappedImageRefs) ? body.cappedImageRefs : [];
|
||||
setCapInfo(limit > 0 ? { perImageLimit: limit, refs: new Set(refs) } : null);
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'Could not load scan history');
|
||||
} finally {
|
||||
@@ -114,7 +118,13 @@ export function SecurityHistoryView({ open, onClose }: SecurityHistoryViewProps)
|
||||
// happen to match the previous values, so the fetch re-runs exactly once.
|
||||
}, [open, load, page, search, reloadToken]);
|
||||
|
||||
// Skip the initial mount: the effect fires once with the original
|
||||
// searchDraft, and unconditionally resetting page to 0 after 300ms races
|
||||
// with any pagination the user may have done in that window.
|
||||
const prevSearchDraftRef = useRef(searchDraft);
|
||||
useEffect(() => {
|
||||
if (prevSearchDraftRef.current === searchDraft) return;
|
||||
prevSearchDraftRef.current = searchDraft;
|
||||
const t = setTimeout(() => {
|
||||
setSearch(searchDraft);
|
||||
setPage(0);
|
||||
@@ -227,79 +237,89 @@ export function SecurityHistoryView({ open, onClose }: SecurityHistoryViewProps)
|
||||
) : (
|
||||
<ScrollArea block className="max-h-[60vh]">
|
||||
<div className="space-y-5">
|
||||
{groups.map((group) => (
|
||||
<div key={group.image_ref}>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="font-mono text-sm truncate" title={group.image_ref}>
|
||||
{group.image_ref}
|
||||
</span>
|
||||
<span className="text-xs text-stat-subtitle">
|
||||
{group.scans.length} scan{group.scans.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px]" />
|
||||
<TableHead className="w-[180px]">Scanned</TableHead>
|
||||
<TableHead className="w-[120px]">Trigger</TableHead>
|
||||
<TableHead className="w-[120px]">Highest</TableHead>
|
||||
<TableHead className="w-[90px] text-right">Total</TableHead>
|
||||
<TableHead className="w-[90px] text-right">Fixable</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{group.scans.map((scan) => {
|
||||
const isSelected = selected.includes(scan.id);
|
||||
return (
|
||||
<TableRow
|
||||
key={scan.id}
|
||||
className={cn(isSelected && 'bg-accent/30')}
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleSelect(scan.id)}
|
||||
aria-label={`Select scan ${scan.id}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{new Date(scan.scanned_at).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs capitalize">
|
||||
{scan.triggered_by}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{scan.highest_severity ? (
|
||||
<SeverityChip severity={scan.highest_severity} />
|
||||
) : (
|
||||
<span className="text-xs text-success font-mono">none</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs tabular-nums">
|
||||
{scan.total_vulnerabilities}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs tabular-nums text-success">
|
||||
{scan.fixable_count}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setInspectScanId(scan.id)}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
</TableCell>
|
||||
{groups.map((group) => {
|
||||
const isCapped = capInfo?.refs.has(group.image_ref) ?? false;
|
||||
return (
|
||||
<div key={group.image_ref}>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="font-mono text-sm truncate" title={group.image_ref}>
|
||||
{group.image_ref}
|
||||
</span>
|
||||
<span className="text-xs text-stat-subtitle">
|
||||
{group.scans.length} scan{group.scans.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
{isCapped && capInfo && (
|
||||
<span className="text-xs text-stat-subtitle italic">
|
||||
Capped at {capInfo.perImageLimit} · older scans pruned
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ScrollArea block className="max-h-64 border border-border/40 rounded-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px]" />
|
||||
<TableHead className="w-[180px]">Scanned</TableHead>
|
||||
<TableHead className="w-[120px]">Trigger</TableHead>
|
||||
<TableHead className="w-[120px]">Highest</TableHead>
|
||||
<TableHead className="w-[90px] text-right">Total</TableHead>
|
||||
<TableHead className="w-[90px] text-right">Fixable</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{group.scans.map((scan) => {
|
||||
const isSelected = selected.includes(scan.id);
|
||||
return (
|
||||
<TableRow
|
||||
key={scan.id}
|
||||
className={cn(isSelected && 'bg-accent/30')}
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleSelect(scan.id)}
|
||||
aria-label={`Select scan ${scan.id}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{new Date(scan.scanned_at).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs capitalize">
|
||||
{scan.triggered_by}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{scan.highest_severity ? (
|
||||
<SeverityChip severity={scan.highest_severity} />
|
||||
) : (
|
||||
<span className="text-xs text-success font-mono">none</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs tabular-nums">
|
||||
{scan.total_vulnerabilities}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs tabular-nums text-success">
|
||||
{scan.fixable_count}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setInspectScanId(scan.id)}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user