mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 06:23:18 +00:00
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:
@@ -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.
|
- **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.
|
- **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>
|
## Comparing scans <Badge>Skipper</Badge>
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 146 KiB |
@@ -318,7 +318,8 @@ export default function EditorLayout() {
|
|||||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
);
|
);
|
||||||
const isDarkMode = theme === 'dark' || (theme === 'auto' && systemDark);
|
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 [filterNodeId, setFilterNodeId] = useState<number | null>(null);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [editingCompose, setEditingCompose] = useState(false);
|
const [editingCompose, setEditingCompose] = useState(false);
|
||||||
@@ -428,10 +429,14 @@ export default function EditorLayout() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: Event) => {
|
const handler = (e: Event) => {
|
||||||
const detail = (e as CustomEvent<SenchoNavigateDetail>).detail;
|
const detail = (e as CustomEvent<SenchoNavigateDetail>).detail;
|
||||||
if (detail?.view) {
|
if (!detail?.view) return;
|
||||||
setActiveView(detail.view);
|
if (detail.view === 'security-history') {
|
||||||
|
setSecurityHistoryOpen(true);
|
||||||
setFilterNodeId(detail.nodeId ?? null);
|
setFilterNodeId(detail.nodeId ?? null);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
setActiveView(detail.view);
|
||||||
|
setFilterNodeId(detail.nodeId ?? null);
|
||||||
};
|
};
|
||||||
window.addEventListener(SENCHO_NAVIGATE_EVENT, handler);
|
window.addEventListener(SENCHO_NAVIGATE_EVENT, handler);
|
||||||
return () => window.removeEventListener(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">
|
<CapabilityGate capability="scheduled-ops" featureName="Scheduled Operations">
|
||||||
<ScheduledOperationsView filterNodeId={filterNodeId} onClearFilter={() => setFilterNodeId(null)} />
|
<ScheduledOperationsView filterNodeId={filterNodeId} onClearFilter={() => setFilterNodeId(null)} />
|
||||||
</CapabilityGate>
|
</CapabilityGate>
|
||||||
) : activeView === 'security-history' ? (
|
|
||||||
<SecurityHistoryView />
|
|
||||||
) : (
|
) : (
|
||||||
<HomeDashboard
|
<HomeDashboard
|
||||||
onNavigateToStack={(stackFile) => { loadFile(stackFile); }}
|
onNavigateToStack={(stackFile) => { loadFile(stackFile); }}
|
||||||
@@ -2916,6 +2919,12 @@ export default function EditorLayout() {
|
|||||||
scanId={stackMisconfigScanId}
|
scanId={stackMisconfigScanId}
|
||||||
onClose={() => setStackMisconfigScanId(null)}
|
onClose={() => setStackMisconfigScanId(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Scan history overlay */}
|
||||||
|
<SecurityHistoryView
|
||||||
|
open={securityHistoryOpen}
|
||||||
|
onClose={() => setSecurityHistoryOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</GlobalCommandPaletteProvider>
|
</GlobalCommandPaletteProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from '@/components/ui/sheet';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -16,7 +22,6 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
GitCompare,
|
GitCompare,
|
||||||
History,
|
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
@@ -55,7 +60,12 @@ function groupByImage(scans: VulnerabilityScan[]): GroupedScans[] {
|
|||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SecurityHistoryView() {
|
interface SecurityHistoryViewProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SecurityHistoryView({ open, onClose }: SecurityHistoryViewProps) {
|
||||||
const { isPaid } = useLicense();
|
const { isPaid } = useLicense();
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
const { activeNode } = useNodes();
|
const { activeNode } = useNodes();
|
||||||
@@ -91,15 +101,25 @@ export function SecurityHistoryView() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const lastNodeIdRef = useRef<number | null>(activeNode?.id ?? null);
|
||||||
load(page, search);
|
const [reloadToken, setReloadToken] = useState(0);
|
||||||
}, [load, page, search, activeNode?.id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const id = activeNode?.id ?? null;
|
||||||
|
if (lastNodeIdRef.current === id) return;
|
||||||
|
lastNodeIdRef.current = id;
|
||||||
setSelected([]);
|
setSelected([]);
|
||||||
setPage(0);
|
setPage(0);
|
||||||
|
setReloadToken((t) => t + 1);
|
||||||
}, [activeNode?.id]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
setSearch(searchDraft);
|
setSearch(searchDraft);
|
||||||
@@ -135,14 +155,14 @@ export function SecurityHistoryView() {
|
|||||||
const compareDisabled = selected.length !== 2;
|
const compareDisabled = selected.length !== 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col gap-4 overflow-auto">
|
<Sheet open={open} onOpenChange={(next) => { if (!next) onClose(); }}>
|
||||||
<Card className="rounded-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel">
|
<SheetContent className="sm:max-w-4xl flex flex-col p-0 gap-0">
|
||||||
<CardHeader className="pb-3">
|
<SheetHeader className="px-6 pt-6 pb-4 pr-14 border-b border-border space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||||
<div className="flex items-center gap-2">
|
Security · Node {activeNode?.name ?? '-'}
|
||||||
<History className="w-5 h-5" strokeWidth={1.5} />
|
</div>
|
||||||
<CardTitle>Scan History</CardTitle>
|
<div className="flex items-center justify-between gap-3">
|
||||||
</div>
|
<SheetTitle className="font-display italic text-2xl">Scan history</SheetTitle>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -176,11 +196,11 @@ export function SecurityHistoryView() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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.
|
Completed vulnerability scans on this node, grouped by image. Select two to compare.
|
||||||
</p>
|
</SheetDescription>
|
||||||
</CardHeader>
|
</SheetHeader>
|
||||||
<CardContent>
|
<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="flex items-center gap-3 mb-4 flex-wrap">
|
||||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
<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} />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ScrollArea className="max-h-[70vh]">
|
<ScrollArea className="flex-1 min-h-0">
|
||||||
<div className="space-y-5 pr-2">
|
<div className="space-y-5 pr-2">
|
||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
<div key={group.image_ref}>
|
<div key={group.image_ref}>
|
||||||
@@ -311,22 +331,22 @@ export function SecurityHistoryView() {
|
|||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<ScanComparisonSheet
|
<ScanComparisonSheet
|
||||||
baselineScanId={compareIds?.[0] ?? null}
|
baselineScanId={compareIds?.[0] ?? null}
|
||||||
currentScanId={compareIds?.[1] ?? null}
|
currentScanId={compareIds?.[1] ?? null}
|
||||||
onClose={() => setCompareIds(null)}
|
onClose={() => setCompareIds(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<VulnerabilityScanSheet
|
<VulnerabilityScanSheet
|
||||||
scanId={inspectScanId}
|
scanId={inspectScanId}
|
||||||
onClose={() => setInspectScanId(null)}
|
onClose={() => setInspectScanId(null)}
|
||||||
canGenerateSbom={isPaid}
|
canGenerateSbom={isPaid}
|
||||||
canCompare={false}
|
canCompare={false}
|
||||||
canManageSuppressions={isPaid && isAdmin}
|
canManageSuppressions={isPaid && isAdmin}
|
||||||
/>
|
/>
|
||||||
</div>
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ afterEach(() => vi.clearAllMocks());
|
|||||||
describe('SecurityHistoryView', () => {
|
describe('SecurityHistoryView', () => {
|
||||||
it('fetches completed scans on mount with server-driven pagination params', async () => {
|
it('fetches completed scans on mount with server-driven pagination params', async () => {
|
||||||
mockedFetch.mockResolvedValue(listResponse([scan()]));
|
mockedFetch.mockResolvedValue(listResponse([scan()]));
|
||||||
render(<SecurityHistoryView />);
|
render(<SecurityHistoryView open onClose={vi.fn()} />);
|
||||||
await waitFor(() => expect(mockedFetch).toHaveBeenCalled());
|
await waitFor(() => expect(mockedFetch).toHaveBeenCalled());
|
||||||
const url = mockedFetch.mock.calls[0][0] as string;
|
const url = mockedFetch.mock.calls[0][0] as string;
|
||||||
expect(url).toMatch(/^\/security\/scans\?/);
|
expect(url).toMatch(/^\/security\/scans\?/);
|
||||||
@@ -118,7 +118,7 @@ describe('SecurityHistoryView', () => {
|
|||||||
it('advances offset when the user pages forward', async () => {
|
it('advances offset when the user pages forward', async () => {
|
||||||
mockedFetch.mockResolvedValue(listResponse([scan()], 250));
|
mockedFetch.mockResolvedValue(listResponse([scan()], 250));
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<SecurityHistoryView />);
|
render(<SecurityHistoryView open onClose={vi.fn()} />);
|
||||||
|
|
||||||
await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
@@ -135,11 +135,11 @@ describe('SecurityHistoryView', () => {
|
|||||||
|
|
||||||
it('re-fetches when activeNode.id changes', async () => {
|
it('re-fetches when activeNode.id changes', async () => {
|
||||||
mockedFetch.mockResolvedValue(listResponse([scan()]));
|
mockedFetch.mockResolvedValue(listResponse([scan()]));
|
||||||
const { rerender } = render(<SecurityHistoryView />);
|
const { rerender } = render(<SecurityHistoryView open onClose={vi.fn()} />);
|
||||||
await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
nodesState.activeNode = { id: 2 };
|
nodesState.activeNode = { id: 2 };
|
||||||
rerender(<SecurityHistoryView key="remount-signal" />);
|
rerender(<SecurityHistoryView key="remount-signal" open onClose={vi.fn()} />);
|
||||||
await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(2));
|
await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(2));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ describe('SecurityHistoryView', () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<SecurityHistoryView />);
|
render(<SecurityHistoryView open onClose={vi.fn()} />);
|
||||||
|
|
||||||
const checkboxes = await screen.findAllByRole('checkbox');
|
const checkboxes = await screen.findAllByRole('checkbox');
|
||||||
expect(checkboxes).toHaveLength(3);
|
expect(checkboxes).toHaveLength(3);
|
||||||
@@ -175,7 +175,7 @@ describe('SecurityHistoryView', () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<SecurityHistoryView />);
|
render(<SecurityHistoryView open onClose={vi.fn()} />);
|
||||||
|
|
||||||
const checkboxes = await screen.findAllByRole('checkbox');
|
const checkboxes = await screen.findAllByRole('checkbox');
|
||||||
await user.click(checkboxes[0]);
|
await user.click(checkboxes[0]);
|
||||||
@@ -188,6 +188,29 @@ describe('SecurityHistoryView', () => {
|
|||||||
expect(last?.currentScanId).toBe(10);
|
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 () => {
|
it('disables Compare button for community tier', async () => {
|
||||||
licenseState.isPaid = false;
|
licenseState.isPaid = false;
|
||||||
mockedFetch.mockResolvedValue(
|
mockedFetch.mockResolvedValue(
|
||||||
@@ -197,7 +220,7 @@ describe('SecurityHistoryView', () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<SecurityHistoryView />);
|
render(<SecurityHistoryView open onClose={vi.fn()} />);
|
||||||
|
|
||||||
const checkboxes = await screen.findAllByRole('checkbox');
|
const checkboxes = await screen.findAllByRole('checkbox');
|
||||||
await user.click(checkboxes[0]);
|
await user.click(checkboxes[0]);
|
||||||
|
|||||||
Reference in New Issue
Block a user