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:
Anso
2026-05-25 23:44:31 -04:00
committed by GitHub
parent 80499ee18d
commit 42e8d3a78c
8 changed files with 352 additions and 85 deletions
@@ -86,11 +86,19 @@ function scan(overrides: Partial<VulnerabilityScan> = {}): VulnerabilityScan {
};
}
function listResponse(items: VulnerabilityScan[], total?: number): Response {
function listResponse(
items: VulnerabilityScan[],
opts: { total?: number; cappedImageRefs?: string[]; perImageLimit?: number } = {},
): Response {
return {
ok: true,
status: 200,
json: async () => ({ items, total: total ?? items.length }),
json: async () => ({
items,
total: opts.total ?? items.length,
cappedImageRefs: opts.cappedImageRefs ?? [],
perImageLimit: opts.perImageLimit ?? 50,
}),
} as unknown as Response;
}
@@ -116,7 +124,7 @@ describe('SecurityHistoryView', () => {
});
it('advances offset when the user pages forward', async () => {
mockedFetch.mockResolvedValue(listResponse([scan()], 250));
mockedFetch.mockResolvedValue(listResponse([scan()], { total: 250 }));
const user = userEvent.setup();
render(<SecurityHistoryView open onClose={vi.fn()} />);
@@ -228,4 +236,21 @@ describe('SecurityHistoryView', () => {
expect(screen.getByRole('button', { name: /Compare \(2\/2\)/ })).toBeEnabled();
});
it('renders the cap hint only for images flagged in cappedImageRefs', async () => {
mockedFetch.mockResolvedValue(
listResponse(
[
scan({ id: 1, image_ref: 'hot:latest', scanned_at: 1000 }),
scan({ id: 2, image_ref: 'cool:latest', scanned_at: 2000 }),
],
{ cappedImageRefs: ['hot:latest'], perImageLimit: 50 },
),
);
render(<SecurityHistoryView open onClose={vi.fn()} />);
const cappedHint = await screen.findByText(/Capped at 50 . older scans pruned/);
expect(cappedHint).toBeInTheDocument();
expect(screen.queryAllByText(/Capped at 50/)).toHaveLength(1);
});
});