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
+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]);