mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 08:27:42 +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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user