mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 01:43:55 +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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user