mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +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:
@@ -16,13 +16,14 @@ beforeAll(async () => {
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
function seedScan(overrides: Partial<{
|
||||
node_id: number;
|
||||
image_ref: string;
|
||||
scanned_at: number;
|
||||
status: 'completed' | 'in_progress' | 'failed';
|
||||
}> = {}): number {
|
||||
const db = DatabaseService.getInstance();
|
||||
return db.createVulnerabilityScan({
|
||||
node_id: 1,
|
||||
node_id: overrides.node_id ?? 1,
|
||||
image_ref: overrides.image_ref ?? 'alpine:3.19',
|
||||
image_digest: `sha256:${Math.random().toString(16).slice(2)}`,
|
||||
scanned_at: overrides.scanned_at ?? Date.now(),
|
||||
@@ -93,3 +94,113 @@ describe('getVulnerabilityScans filters and pagination', () => {
|
||||
expect(page1.items[0].id).not.toBe(page2.items[0].id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVulnerabilityScans per-image cap', () => {
|
||||
it('caps rows per image_ref when no imageRef filter is set', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.updateGlobalSetting('scan_history_per_image_limit', '10');
|
||||
|
||||
for (let i = 0; i < 80; i++) seedScan({ image_ref: 'hot:latest', scanned_at: 1000 + i });
|
||||
for (let i = 0; i < 5; i++) seedScan({ image_ref: 'cool:latest', scanned_at: 1000 + i });
|
||||
|
||||
const result = db.getVulnerabilityScans(1, { limit: 500 });
|
||||
const hotRows = result.items.filter((s) => s.image_ref === 'hot:latest');
|
||||
const coolRows = result.items.filter((s) => s.image_ref === 'cool:latest');
|
||||
|
||||
expect(hotRows).toHaveLength(10);
|
||||
expect(coolRows).toHaveLength(5);
|
||||
expect(result.total).toBe(15);
|
||||
expect(result.cappedImageRefs).toEqual(['hot:latest']);
|
||||
expect(result.perImageLimit).toBe(10);
|
||||
});
|
||||
|
||||
it('bypasses the cap when imageRef targets a single image', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.updateGlobalSetting('scan_history_per_image_limit', '10');
|
||||
|
||||
for (let i = 0; i < 30; i++) seedScan({ image_ref: 'hot:latest', scanned_at: 1000 + i });
|
||||
|
||||
const result = db.getVulnerabilityScans(1, { imageRef: 'hot:latest', limit: 500 });
|
||||
expect(result.items).toHaveLength(30);
|
||||
expect(result.total).toBe(30);
|
||||
expect(result.cappedImageRefs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pruneScanHistoryPerImage', () => {
|
||||
it('keeps the newest N rows per (node_id, image_ref) and deletes the rest', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
for (let i = 0; i < 60; i++) seedScan({ image_ref: 'hot:latest', scanned_at: 1000 + i });
|
||||
for (let i = 0; i < 5; i++) seedScan({ image_ref: 'cool:latest', scanned_at: 1000 + i });
|
||||
|
||||
const deleted = db.pruneScanHistoryPerImage(50);
|
||||
expect(deleted).toBe(10);
|
||||
|
||||
const after = db.getVulnerabilityScans(1, { imageRef: 'hot:latest', limit: 500 });
|
||||
expect(after.items).toHaveLength(50);
|
||||
const oldest = Math.min(...after.items.map((s) => s.scanned_at));
|
||||
expect(oldest).toBe(1010);
|
||||
|
||||
const cool = db.getVulnerabilityScans(1, { imageRef: 'cool:latest', limit: 500 });
|
||||
expect(cool.items).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('is a no-op when no image exceeds the cap', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
for (let i = 0; i < 3; i++) seedScan({ image_ref: 'small:latest', scanned_at: 1000 + i });
|
||||
|
||||
const deleted = db.pruneScanHistoryPerImage(50);
|
||||
expect(deleted).toBe(0);
|
||||
});
|
||||
|
||||
it('partitions by node_id so two nodes scanning the same image keep independent histories', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.getDb()
|
||||
.prepare(`INSERT INTO nodes (id, name, type, compose_dir, is_default, status, created_at)
|
||||
VALUES (2, 'Peer', 'remote', '/tmp', 0, 'online', ?)`)
|
||||
.run(Date.now());
|
||||
|
||||
for (let i = 0; i < 60; i++) seedScan({ node_id: 1, image_ref: 'alpine:3.19', scanned_at: 1000 + i });
|
||||
for (let i = 0; i < 60; i++) seedScan({ node_id: 2, image_ref: 'alpine:3.19', scanned_at: 2000 + i });
|
||||
|
||||
const deleted = db.pruneScanHistoryPerImage(50);
|
||||
expect(deleted).toBe(20);
|
||||
|
||||
const node1 = db.getVulnerabilityScans(1, { imageRef: 'alpine:3.19', limit: 500 });
|
||||
const node2 = db.getVulnerabilityScans(2, { imageRef: 'alpine:3.19', limit: 500 });
|
||||
expect(node1.items).toHaveLength(50);
|
||||
expect(node2.items).toHaveLength(50);
|
||||
});
|
||||
|
||||
it('deletes child vulnerability_details rows for pruned scans', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const ids: number[] = [];
|
||||
for (let i = 0; i < 60; i++) {
|
||||
ids.push(seedScan({ image_ref: 'hot:latest', scanned_at: 1000 + i }));
|
||||
}
|
||||
const oldestScanId = ids[0];
|
||||
db.insertVulnerabilityDetails(oldestScanId, [{
|
||||
vulnerability_id: 'CVE-2020-0001',
|
||||
pkg_name: 'libfoo',
|
||||
installed_version: '1.0',
|
||||
fixed_version: '1.1',
|
||||
severity: 'HIGH',
|
||||
title: 'Test',
|
||||
description: null,
|
||||
primary_url: null,
|
||||
}]);
|
||||
|
||||
const beforeChildren = db.getDb()
|
||||
.prepare('SELECT COUNT(*) as cnt FROM vulnerability_details WHERE scan_id = ?')
|
||||
.get(oldestScanId) as { cnt: number };
|
||||
expect(beforeChildren.cnt).toBe(1);
|
||||
|
||||
const deleted = db.pruneScanHistoryPerImage(50);
|
||||
expect(deleted).toBe(10);
|
||||
|
||||
const afterChildren = db.getDb()
|
||||
.prepare('SELECT COUNT(*) as cnt FROM vulnerability_details WHERE scan_id = ?')
|
||||
.get(oldestScanId) as { cnt: number };
|
||||
expect(afterChildren.cnt).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ const ALLOWED_SETTING_KEYS = new Set([
|
||||
'log_retention_days',
|
||||
'audit_retention_days',
|
||||
'mesh_auto_recreate',
|
||||
'scan_history_per_image_limit',
|
||||
]);
|
||||
|
||||
// Bulk PATCH schema. All keys optional; present keys are fully validated.
|
||||
@@ -39,6 +40,7 @@ const SettingsPatchSchema = z.object({
|
||||
log_retention_days: z.coerce.number().int().min(1).max(365).transform(String),
|
||||
audit_retention_days: z.coerce.number().int().min(1).max(365).transform(String),
|
||||
mesh_auto_recreate: z.enum(['0', '1']),
|
||||
scan_history_per_image_limit: z.coerce.number().int().min(5).max(1000).transform(String),
|
||||
}).partial();
|
||||
|
||||
export const settingsRouter = Router();
|
||||
|
||||
@@ -1244,6 +1244,7 @@ export class DatabaseService {
|
||||
stmt.run('developer_mode', '0');
|
||||
stmt.run('metrics_retention_hours', '24');
|
||||
stmt.run('log_retention_days', '30');
|
||||
stmt.run('scan_history_per_image_limit', '50');
|
||||
stmt.run('trivy_auto_update', '0');
|
||||
stmt.run('trivy_last_notified_version', '');
|
||||
stmt.run('mesh_auto_recreate', '0');
|
||||
@@ -3402,7 +3403,7 @@ export class DatabaseService {
|
||||
public getVulnerabilityScans(
|
||||
nodeId: number,
|
||||
opts: { imageRef?: string; imageRefLike?: string; status?: VulnScanStatus; limit?: number; offset?: number } = {},
|
||||
): { items: VulnerabilityScan[]; total: number } {
|
||||
): { items: VulnerabilityScan[]; total: number; cappedImageRefs: string[]; perImageLimit: number } {
|
||||
const limit = Math.max(1, Math.min(opts.limit ?? 50, 500));
|
||||
const offset = Math.max(0, opts.offset ?? 0);
|
||||
const where = ['node_id = ?'];
|
||||
@@ -3420,17 +3421,100 @@ export class DatabaseService {
|
||||
params.push(opts.status);
|
||||
}
|
||||
const whereSql = where.join(' AND ');
|
||||
|
||||
// Grouped (history) view caps rows per image_ref so a hot image
|
||||
// cannot drown out the others. Single-image deep-dive (imageRef set)
|
||||
// bypasses the cap so users can drill past it.
|
||||
const applyPerImageCap = !opts.imageRef;
|
||||
const parsedLimit = parseInt(this.getGlobalSettings()['scan_history_per_image_limit'] ?? '50', 10);
|
||||
const perImageLimit = parsedLimit > 0 ? parsedLimit : 50;
|
||||
|
||||
if (!applyPerImageCap) {
|
||||
const total = (
|
||||
this.db
|
||||
.prepare(`SELECT COUNT(*) as cnt FROM vulnerability_scans WHERE ${whereSql}`)
|
||||
.get(...(params as never[])) as { cnt: number }
|
||||
).cnt;
|
||||
const items = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM vulnerability_scans WHERE ${whereSql} ORDER BY scanned_at DESC LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(...(params as never[]), limit, offset) as VulnerabilityScan[];
|
||||
return { items, total, cappedImageRefs: [], perImageLimit };
|
||||
}
|
||||
|
||||
const rankedCte = `WITH ranked AS (
|
||||
SELECT *, ROW_NUMBER() OVER (PARTITION BY image_ref ORDER BY scanned_at DESC) AS rn
|
||||
FROM vulnerability_scans
|
||||
WHERE ${whereSql}
|
||||
)`;
|
||||
const total = (
|
||||
this.db
|
||||
.prepare(`SELECT COUNT(*) as cnt FROM vulnerability_scans WHERE ${whereSql}`)
|
||||
.get(...(params as never[])) as { cnt: number }
|
||||
.prepare(`${rankedCte} SELECT COUNT(*) as cnt FROM ranked WHERE rn <= ?`)
|
||||
.get(...(params as never[]), perImageLimit) as { cnt: number }
|
||||
).cnt;
|
||||
const items = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM vulnerability_scans WHERE ${whereSql} ORDER BY scanned_at DESC LIMIT ? OFFSET ?`,
|
||||
`${rankedCte} SELECT id, node_id, image_ref, image_digest, scanned_at,
|
||||
total_vulnerabilities, critical_count, high_count, medium_count, low_count,
|
||||
unknown_count, fixable_count, secret_count, misconfig_count, scanners_used,
|
||||
highest_severity, os_info, trivy_version, scan_duration_ms, triggered_by,
|
||||
status, error, stack_context, policy_evaluation
|
||||
FROM ranked WHERE rn <= ?
|
||||
ORDER BY scanned_at DESC LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(...(params as never[]), limit, offset) as VulnerabilityScan[];
|
||||
return { items, total };
|
||||
.all(...(params as never[]), perImageLimit, limit, offset) as VulnerabilityScan[];
|
||||
|
||||
// Identify which image_refs sit at or above the cap so the UI can
|
||||
// flag them. `>=` (not `>`) is intentional: the daily prune keeps
|
||||
// each image at exactly perImageLimit rows, so by the time a user
|
||||
// opens the history sheet the underlying count rarely exceeds the
|
||||
// cap. Flagging at-cap groups still tells the truth (older scans
|
||||
// have been or will be pruned at this image's next scan).
|
||||
const cappedRows = this.db
|
||||
.prepare(
|
||||
`SELECT image_ref FROM vulnerability_scans
|
||||
WHERE ${whereSql}
|
||||
GROUP BY image_ref HAVING COUNT(*) >= ?`,
|
||||
)
|
||||
.all(...(params as never[]), perImageLimit) as Array<{ image_ref: string }>;
|
||||
const cappedImageRefs = cappedRows.map((r) => r.image_ref);
|
||||
|
||||
return { items, total, cappedImageRefs, perImageLimit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-image scan history pruner. For each (node_id, image_ref), keep the
|
||||
* newest N scans (ordered by scanned_at DESC) and delete older rows along
|
||||
* with their child findings. SQLite foreign-key cascade is not enabled
|
||||
* at the connection level here, so children are deleted explicitly. The
|
||||
* subquery is self-contained so we don't bind one parameter per ID. A
|
||||
* first-run backlog of thousands of stale scans would otherwise blow
|
||||
* past SQLITE_MAX_VARIABLE_NUMBER.
|
||||
*/
|
||||
public pruneScanHistoryPerImage(perImageLimit: number): number {
|
||||
const limit = Math.max(1, Math.floor(perImageLimit));
|
||||
const overflowSubquery = `SELECT id FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (
|
||||
PARTITION BY node_id, image_ref ORDER BY scanned_at DESC
|
||||
) AS rn
|
||||
FROM vulnerability_scans
|
||||
) WHERE rn > ?`;
|
||||
const deleteChild = (table: string) =>
|
||||
this.db
|
||||
.prepare(`DELETE FROM ${table} WHERE scan_id IN (${overflowSubquery})`)
|
||||
.run(limit);
|
||||
const deleteParent = this.db.prepare(
|
||||
`DELETE FROM vulnerability_scans WHERE id IN (${overflowSubquery})`,
|
||||
);
|
||||
|
||||
const txn = this.db.transaction(() => {
|
||||
deleteChild('vulnerability_details');
|
||||
deleteChild('secret_findings');
|
||||
deleteChild('misconfig_findings');
|
||||
return deleteParent.run(limit).changes;
|
||||
});
|
||||
return txn();
|
||||
}
|
||||
|
||||
public getLatestScanForImage(
|
||||
|
||||
@@ -551,7 +551,9 @@ export class MonitorService {
|
||||
const notifSummary = db.cleanupOldNotifications(isNaN(retentionDays) ? 30 : retentionDays);
|
||||
const auditRetentionDays = parseInt(settings['audit_retention_days'] || '90', 10);
|
||||
db.cleanupOldAuditLogs(isNaN(auditRetentionDays) ? 90 : auditRetentionDays);
|
||||
if (isDebugEnabled()) console.log(`[Monitor:diag] Cleanup: metrics ${isNaN(retentionHours) ? 24 : retentionHours}h, notifications ${isNaN(retentionDays) ? 30 : retentionDays}d (ttl=${notifSummary.ttl} perStack=${notifSummary.perStack} perNode=${notifSummary.perNode}), audit ${isNaN(auditRetentionDays) ? 90 : auditRetentionDays}d`);
|
||||
const scanPerImage = parseInt(settings['scan_history_per_image_limit'] || '50', 10);
|
||||
const scanPruned = db.pruneScanHistoryPerImage(isNaN(scanPerImage) ? 50 : scanPerImage);
|
||||
if (isDebugEnabled()) console.log(`[Monitor:diag] Cleanup: metrics ${isNaN(retentionHours) ? 24 : retentionHours}h, notifications ${isNaN(retentionDays) ? 30 : retentionDays}d (ttl=${notifSummary.ttl} perStack=${notifSummary.perStack} perNode=${notifSummary.perNode}), audit ${isNaN(auditRetentionDays) ? 90 : auditRetentionDays}d, scans pruned ${scanPruned}`);
|
||||
} catch (e) {
|
||||
console.error('MonitorService: failed to cleanup old data', e);
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user