diff --git a/docs/features/vulnerability-scanning.mdx b/docs/features/vulnerability-scanning.mdx index 2d246750..e4cccaf1 100644 --- a/docs/features/vulnerability-scanning.mdx +++ b/docs/features/vulnerability-scanning.mdx @@ -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. + + + Scan history sheet overlaid on the Resources Hub, with search box, pagination, and scan rows grouped by image + ## Comparing scans Skipper diff --git a/docs/images/vulnerability-scanning/scan-history-sheet.png b/docs/images/vulnerability-scanning/scan-history-sheet.png new file mode 100644 index 00000000..63be03f2 Binary files /dev/null and b/docs/images/vulnerability-scanning/scan-history-sheet.png differ diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 5ef087c6..98fa7657 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -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(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).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() { setFilterNodeId(null)} /> - ) : activeView === 'security-history' ? ( - ) : ( { loadFile(stackFile); }} @@ -2916,6 +2919,12 @@ export default function EditorLayout() { scanId={stackMisconfigScanId} onClose={() => setStackMisconfigScanId(null)} /> + + {/* Scan history overlay */} + setSecurityHistoryOpen(false)} + /> ); diff --git a/frontend/src/components/SecurityHistoryView.tsx b/frontend/src/components/SecurityHistoryView.tsx index 70fe81e6..ac4c8629 100644 --- a/frontend/src/components/SecurityHistoryView.tsx +++ b/frontend/src/components/SecurityHistoryView.tsx @@ -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(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 ( -
- - -
-
- - Scan History -
+ { if (!next) onClose(); }}> + + +
+ Security ยท Node {activeNode?.name ?? '-'} +
+
+ Scan history
-

+ Completed vulnerability scans on this node, grouped by image. Select two to compare. -

- - + +
+
@@ -233,7 +253,7 @@ export function SecurityHistoryView() {
) : ( - +
{groups.map((group) => (
@@ -311,22 +331,22 @@ export function SecurityHistoryView() {
)} - - +
- setCompareIds(null)} - /> + setCompareIds(null)} + /> - setInspectScanId(null)} - canGenerateSbom={isPaid} - canCompare={false} - canManageSuppressions={isPaid && isAdmin} - /> -
+ setInspectScanId(null)} + canGenerateSbom={isPaid} + canCompare={false} + canManageSuppressions={isPaid && isAdmin} + /> +
+
); } diff --git a/frontend/src/components/__tests__/SecurityHistoryView.test.tsx b/frontend/src/components/__tests__/SecurityHistoryView.test.tsx index f5d288ef..6bf2bdc5 100644 --- a/frontend/src/components/__tests__/SecurityHistoryView.test.tsx +++ b/frontend/src/components/__tests__/SecurityHistoryView.test.tsx @@ -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(); + render(); 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(); + render(); 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(); + const { rerender } = render(); await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(1)); nodesState.activeNode = { id: 2 }; - rerender(); + rerender(); await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(2)); }); @@ -152,7 +152,7 @@ describe('SecurityHistoryView', () => { ]), ); const user = userEvent.setup(); - render(); + render(); const checkboxes = await screen.findAllByRole('checkbox'); expect(checkboxes).toHaveLength(3); @@ -175,7 +175,7 @@ describe('SecurityHistoryView', () => { ]), ); const user = userEvent.setup(); - render(); + render(); 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(); + + // 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(); + + 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(); + render(); const checkboxes = await screen.findAllByRole('checkbox'); await user.click(checkboxes[0]);