fix(security): convert scan history from full page to sheet overlay (#720)

Scan history is now a right-side sheet that layers over the current view
(typically Resources Hub) instead of a full-page activeView branch. The
sheet opens via the existing navigation event, fetches only when open,
dismisses on Escape or overlay click, and preserves the nested scan-details
and scan-compare sheets intact via Radix portal stacking.

The fetch effect now resets selection and page state on active-node change
exactly once, avoiding a double-fetch on node switches.
This commit is contained in:
Anso
2026-04-21 08:01:31 -04:00
committed by GitHub
parent 661b9c638b
commit e4fdb1cd6c
5 changed files with 104 additions and 48 deletions
+5 -1
View File
@@ -285,7 +285,11 @@ Every scan Sencho runs is stored with its full vulnerability detail. Scan record
- **Digest caching**: skip re-scanning an image that has already been scanned within 24 hours.
- **Trend badges**: surface whether the latest scan added or resolved vulnerabilities compared to the previous scan for the same image.
Open the **Scan history** page from the top of the Resources Hub to browse completed scans grouped by image, search by image reference, and pick two scans to compare.
Click **Scan history** from the top of the Resources Hub to open a right-side sheet layered over the current page. The sheet lists completed scans grouped by image, lets you search by image reference, and lets you pick two scans to compare. Close the sheet by pressing Escape, clicking the overlay, or clicking the close button in the header.
<Frame>
<img src="/images/vulnerability-scanning/scan-history-sheet.png" alt="Scan history sheet overlaid on the Resources Hub, with search box, pagination, and scan rows grouped by image" />
</Frame>
## Comparing scans <Badge>Skipper</Badge>
Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

+14 -5
View File
@@ -318,7 +318,8 @@ export default function EditorLayout() {
window.matchMedia('(prefers-color-scheme: dark)').matches
);
const isDarkMode = theme === 'dark' || (theme === 'auto' && systemDark);
const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability' | 'fleet' | 'audit-log' | 'scheduled-ops' | 'auto-updates' | 'security-history'>('dashboard');
const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability' | 'fleet' | 'audit-log' | 'scheduled-ops' | 'auto-updates'>('dashboard');
const [securityHistoryOpen, setSecurityHistoryOpen] = useState(false);
const [filterNodeId, setFilterNodeId] = useState<number | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [editingCompose, setEditingCompose] = useState(false);
@@ -428,10 +429,14 @@ export default function EditorLayout() {
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent<SenchoNavigateDetail>).detail;
if (detail?.view) {
setActiveView(detail.view);
if (!detail?.view) return;
if (detail.view === 'security-history') {
setSecurityHistoryOpen(true);
setFilterNodeId(detail.nodeId ?? null);
return;
}
setActiveView(detail.view);
setFilterNodeId(detail.nodeId ?? null);
};
window.addEventListener(SENCHO_NAVIGATE_EVENT, handler);
return () => window.removeEventListener(SENCHO_NAVIGATE_EVENT, handler);
@@ -2732,8 +2737,6 @@ export default function EditorLayout() {
<CapabilityGate capability="scheduled-ops" featureName="Scheduled Operations">
<ScheduledOperationsView filterNodeId={filterNodeId} onClearFilter={() => setFilterNodeId(null)} />
</CapabilityGate>
) : activeView === 'security-history' ? (
<SecurityHistoryView />
) : (
<HomeDashboard
onNavigateToStack={(stackFile) => { loadFile(stackFile); }}
@@ -2916,6 +2919,12 @@ export default function EditorLayout() {
scanId={stackMisconfigScanId}
onClose={() => setStackMisconfigScanId(null)}
/>
{/* Scan history overlay */}
<SecurityHistoryView
open={securityHistoryOpen}
onClose={() => setSecurityHistoryOpen(false)}
/>
</div>
</GlobalCommandPaletteProvider>
);
+55 -35
View File
@@ -1,9 +1,15 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import {
Table,
TableBody,
@@ -16,7 +22,6 @@ import {
ChevronLeft,
ChevronRight,
GitCompare,
History,
RefreshCw,
Search,
ShieldCheck,
@@ -55,7 +60,12 @@ function groupByImage(scans: VulnerabilityScan[]): GroupedScans[] {
return groups;
}
export function SecurityHistoryView() {
interface SecurityHistoryViewProps {
open: boolean;
onClose: () => void;
}
export function SecurityHistoryView({ open, onClose }: SecurityHistoryViewProps) {
const { isPaid } = useLicense();
const { isAdmin } = useAuth();
const { activeNode } = useNodes();
@@ -91,15 +101,25 @@ export function SecurityHistoryView() {
}
}, []);
useEffect(() => {
load(page, search);
}, [load, page, search, activeNode?.id]);
const lastNodeIdRef = useRef<number | null>(activeNode?.id ?? null);
const [reloadToken, setReloadToken] = useState(0);
useEffect(() => {
const id = activeNode?.id ?? null;
if (lastNodeIdRef.current === id) return;
lastNodeIdRef.current = id;
setSelected([]);
setPage(0);
setReloadToken((t) => t + 1);
}, [activeNode?.id]);
useEffect(() => {
if (!open) return;
load(page, search);
// reloadToken bumps when the active node changes even if page/search
// happen to match the previous values, so the fetch re-runs exactly once.
}, [open, load, page, search, reloadToken]);
useEffect(() => {
const t = setTimeout(() => {
setSearch(searchDraft);
@@ -135,14 +155,14 @@ export function SecurityHistoryView() {
const compareDisabled = selected.length !== 2;
return (
<div className="flex-1 flex flex-col gap-4 overflow-auto">
<Card className="rounded-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<History className="w-5 h-5" strokeWidth={1.5} />
<CardTitle>Scan History</CardTitle>
</div>
<Sheet open={open} onOpenChange={(next) => { if (!next) onClose(); }}>
<SheetContent className="sm:max-w-4xl flex flex-col p-0 gap-0">
<SheetHeader className="px-6 pt-6 pb-4 pr-14 border-b border-border space-y-2">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
Security · Node {activeNode?.name ?? '-'}
</div>
<div className="flex items-center justify-between gap-3">
<SheetTitle className="font-display italic text-2xl">Scan history</SheetTitle>
<div className="flex items-center gap-2">
<Button
variant="outline"
@@ -176,11 +196,11 @@ export function SecurityHistoryView() {
</Button>
</div>
</div>
<p className="text-sm text-muted-foreground mt-1">
<SheetDescription className="text-sm text-muted-foreground">
Completed vulnerability scans on this node, grouped by image. Select two to compare.
</p>
</CardHeader>
<CardContent>
</SheetDescription>
</SheetHeader>
<div className="flex-1 flex flex-col px-6 py-4 overflow-hidden">
<div className="flex items-center gap-3 mb-4 flex-wrap">
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
@@ -233,7 +253,7 @@ export function SecurityHistoryView() {
</div>
</div>
) : (
<ScrollArea className="max-h-[70vh]">
<ScrollArea className="flex-1 min-h-0">
<div className="space-y-5 pr-2">
{groups.map((group) => (
<div key={group.image_ref}>
@@ -311,22 +331,22 @@ export function SecurityHistoryView() {
</div>
</ScrollArea>
)}
</CardContent>
</Card>
</div>
<ScanComparisonSheet
baselineScanId={compareIds?.[0] ?? null}
currentScanId={compareIds?.[1] ?? null}
onClose={() => setCompareIds(null)}
/>
<ScanComparisonSheet
baselineScanId={compareIds?.[0] ?? null}
currentScanId={compareIds?.[1] ?? null}
onClose={() => setCompareIds(null)}
/>
<VulnerabilityScanSheet
scanId={inspectScanId}
onClose={() => setInspectScanId(null)}
canGenerateSbom={isPaid}
canCompare={false}
canManageSuppressions={isPaid && isAdmin}
/>
</div>
<VulnerabilityScanSheet
scanId={inspectScanId}
onClose={() => setInspectScanId(null)}
canGenerateSbom={isPaid}
canCompare={false}
canManageSuppressions={isPaid && isAdmin}
/>
</SheetContent>
</Sheet>
);
}
@@ -106,7 +106,7 @@ afterEach(() => vi.clearAllMocks());
describe('SecurityHistoryView', () => {
it('fetches completed scans on mount with server-driven pagination params', async () => {
mockedFetch.mockResolvedValue(listResponse([scan()]));
render(<SecurityHistoryView />);
render(<SecurityHistoryView open onClose={vi.fn()} />);
await waitFor(() => expect(mockedFetch).toHaveBeenCalled());
const url = mockedFetch.mock.calls[0][0] as string;
expect(url).toMatch(/^\/security\/scans\?/);
@@ -118,7 +118,7 @@ describe('SecurityHistoryView', () => {
it('advances offset when the user pages forward', async () => {
mockedFetch.mockResolvedValue(listResponse([scan()], 250));
const user = userEvent.setup();
render(<SecurityHistoryView />);
render(<SecurityHistoryView open onClose={vi.fn()} />);
await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(1));
@@ -135,11 +135,11 @@ describe('SecurityHistoryView', () => {
it('re-fetches when activeNode.id changes', async () => {
mockedFetch.mockResolvedValue(listResponse([scan()]));
const { rerender } = render(<SecurityHistoryView />);
const { rerender } = render(<SecurityHistoryView open onClose={vi.fn()} />);
await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(1));
nodesState.activeNode = { id: 2 };
rerender(<SecurityHistoryView key="remount-signal" />);
rerender(<SecurityHistoryView key="remount-signal" open onClose={vi.fn()} />);
await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(2));
});
@@ -152,7 +152,7 @@ describe('SecurityHistoryView', () => {
]),
);
const user = userEvent.setup();
render(<SecurityHistoryView />);
render(<SecurityHistoryView open onClose={vi.fn()} />);
const checkboxes = await screen.findAllByRole('checkbox');
expect(checkboxes).toHaveLength(3);
@@ -175,7 +175,7 @@ describe('SecurityHistoryView', () => {
]),
);
const user = userEvent.setup();
render(<SecurityHistoryView />);
render(<SecurityHistoryView open onClose={vi.fn()} />);
const checkboxes = await screen.findAllByRole('checkbox');
await user.click(checkboxes[0]);
@@ -188,6 +188,29 @@ describe('SecurityHistoryView', () => {
expect(last?.currentScanId).toBe(10);
});
it('does not fetch when closed', async () => {
mockedFetch.mockResolvedValue(listResponse([scan()]));
render(<SecurityHistoryView open={false} onClose={vi.fn()} />);
// Flush any microtasks; the fetch guard returns synchronously so no
// timer delay is required.
await Promise.resolve();
expect(mockedFetch).not.toHaveBeenCalled();
});
it('fires onClose when Escape is pressed and does not fetch again', async () => {
mockedFetch.mockResolvedValue(listResponse([scan()]));
const onClose = vi.fn();
const user = userEvent.setup();
render(<SecurityHistoryView open onClose={onClose} />);
await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(1));
await user.keyboard('{Escape}');
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(mockedFetch).toHaveBeenCalledTimes(1);
});
it('disables Compare button for community tier', async () => {
licenseState.isPaid = false;
mockedFetch.mockResolvedValue(
@@ -197,7 +220,7 @@ describe('SecurityHistoryView', () => {
]),
);
const user = userEvent.setup();
render(<SecurityHistoryView />);
render(<SecurityHistoryView open onClose={vi.fn()} />);
const checkboxes = await screen.findAllByRole('checkbox');
await user.click(checkboxes[0]);