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
+92 -72
View File
@@ -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>
)}
@@ -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);
});
});
@@ -30,13 +30,14 @@ function SectionSkeleton() {
);
}
type DeveloperFields = Pick<PatchableSettings, 'developer_mode' | 'metrics_retention_hours' | 'log_retention_days' | 'audit_retention_days'>;
type DeveloperFields = Pick<PatchableSettings, 'developer_mode' | 'metrics_retention_hours' | 'log_retention_days' | 'audit_retention_days' | 'scan_history_per_image_limit'>;
const DEFAULT_DEVELOPER: DeveloperFields = {
developer_mode: DEFAULT_SETTINGS.developer_mode,
metrics_retention_hours: DEFAULT_SETTINGS.metrics_retention_hours,
log_retention_days: DEFAULT_SETTINGS.log_retention_days,
audit_retention_days: DEFAULT_SETTINGS.audit_retention_days,
scan_history_per_image_limit: DEFAULT_SETTINGS.scan_history_per_image_limit,
};
export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
@@ -51,7 +52,8 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
settings.developer_mode !== serverSettingsRef.current.developer_mode ||
settings.metrics_retention_hours !== serverSettingsRef.current.metrics_retention_hours ||
settings.log_retention_days !== serverSettingsRef.current.log_retention_days ||
settings.audit_retention_days !== serverSettingsRef.current.audit_retention_days;
settings.audit_retention_days !== serverSettingsRef.current.audit_retention_days ||
settings.scan_history_per_image_limit !== serverSettingsRef.current.scan_history_per_image_limit;
useEffect(() => {
onDirtyChange?.(hasChanges);
@@ -85,6 +87,7 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
metrics_retention_hours: localData.metrics_retention_hours ?? DEFAULT_SETTINGS.metrics_retention_hours,
log_retention_days: localData.log_retention_days ?? DEFAULT_SETTINGS.log_retention_days,
audit_retention_days: localData.audit_retention_days ?? DEFAULT_SETTINGS.audit_retention_days,
scan_history_per_image_limit: localData.scan_history_per_image_limit ?? DEFAULT_SETTINGS.scan_history_per_image_limit,
};
setSettings(safe);
serverSettingsRef.current = { ...safe };
@@ -108,6 +111,7 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
metrics_retention_hours: settings.metrics_retention_hours,
log_retention_days: settings.log_retention_days,
audit_retention_days: settings.audit_retention_days,
scan_history_per_image_limit: settings.scan_history_per_image_limit,
};
setIsSaving(true);
try {
@@ -185,6 +189,23 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
</div>
</SettingsField>
<SettingsField
label="Scan history per image"
helper="How many vulnerability scans to keep per image. Older scans beyond the cap are pruned."
>
<div className="flex items-center gap-2">
<Input
type="number"
min={5}
max={1000}
value={settings.scan_history_per_image_limit}
onChange={(e) => onSettingChange('scan_history_per_image_limit', e.target.value)}
className="w-24"
/>
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">scans</span>
</div>
</SettingsField>
{isPaid && license?.variant === 'admiral' && (
<SettingsField
label="Audit log"
@@ -11,6 +11,7 @@ export interface PatchableSettings {
log_retention_days?: string;
audit_retention_days?: string;
mesh_auto_recreate?: '0' | '1';
scan_history_per_image_limit?: string;
}
export const DEFAULT_SETTINGS: PatchableSettings = {
@@ -26,6 +27,7 @@ export const DEFAULT_SETTINGS: PatchableSettings = {
log_retention_days: '30',
audit_retention_days: '90',
mesh_auto_recreate: '0',
scan_history_per_image_limit: '50',
};
export type SectionId =