diff --git a/docs/docs/usage/attack-chaining/attack-path-map.md b/docs/docs/usage/attack-chaining/attack-path-map.md index 10cffd75034..aec85e4bd62 100644 --- a/docs/docs/usage/attack-chaining/attack-path-map.md +++ b/docs/docs/usage/attack-chaining/attack-path-map.md @@ -103,6 +103,9 @@ detail panel to keep the working area clear. 6. Toggle between the **graph view** and the **table view** (top-right) if you prefer reviewing endpoints as a sortable list. 7. Use the fullscreen toggle for a larger working area on complex chains. +8. Use the **Export as PNG** button (next to the fullscreen toggle, in the graph view) to download the map as an + image. The export always contains the **whole** graph — every node, whatever the current zoom or pan — so it can + be dropped into a report or a debrief as-is. ### Reading the legend diff --git a/openaev-front/package.json b/openaev-front/package.json index f2ac1ca2d9e..ee0574bfcb4 100644 --- a/openaev-front/package.json +++ b/openaev-front/package.json @@ -67,6 +67,7 @@ "final-form": "4.20.10", "final-form-arrays": "4.0.1", "html-react-parser": "6.1.7", + "html-to-image": "1.11.13", "http-proxy-middleware": "4.2.0", "immutable": "5.1.9", "ipaddr.js": "^2.2.0", diff --git a/openaev-front/src/__tests__/admin/components/simulations/simulation/attack_path/SimulationAttackPath.test.tsx b/openaev-front/src/__tests__/admin/components/simulations/simulation/attack_path/SimulationAttackPath.test.tsx index fa8b122cf40..002ae15ccef 100644 --- a/openaev-front/src/__tests__/admin/components/simulations/simulation/attack_path/SimulationAttackPath.test.tsx +++ b/openaev-front/src/__tests__/admin/components/simulations/simulation/attack_path/SimulationAttackPath.test.tsx @@ -5,8 +5,10 @@ import type * as ReactRouter from 'react-router'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import SimulationAttackPath from '../../../../../../admin/components/simulations/simulation/attack_path/SimulationAttackPath'; +import { MESSAGING$ } from '../../../../../../utils/Environment'; import { type AppAbility } from '../../../../../../utils/permissions/ability'; import { AbilityContext } from '../../../../../../utils/permissions/permissionsContext'; +import type * as Utils from '../../../../../../utils/utils'; type FlowProps = { focusRequest?: { nodeId: string }; @@ -18,6 +20,8 @@ type FlowProps = { onEndpointClick?: (nodeId: string, ref?: string, label?: string) => void; onInjectorSelect?: (injectorId: string, label?: string) => void; onFindingClusterClick?: (clusterId: string, typeFindings: string | undefined, injectorId: string | undefined, endpointRef: string | undefined, kind: 'header' | 'overflow' | 'typeOverflow') => void; + exportRequest?: number; + onExportDone?: (png: Blob | null) => void; }; // Capture the props AttackPathCanvas receives (mocked so the canvas is not instantiated), and stub @@ -31,6 +35,7 @@ const mocks = vi.hoisted(() => ({ fetchEndpointRelations: vi.fn(), fetchFindingsByCategory: vi.fn(), fetchExecutionDetail: vi.fn(), + download: vi.fn(), flowProps: { current: null as FlowProps | null }, // Read by the useEnterpriseEdition mock below; a test flips it to exercise the unlicensed path. licenceValidated: true, @@ -47,6 +52,16 @@ vi.mock('../../../../../../actions/attack-path/attack-path-actions', () => ({ fetchExecutionDetail: mocks.fetchExecutionDetail, })); +// Only the download is stubbed: the test asserts what the export hands to the browser, not the +// anchor dance the helper does. +vi.mock('../../../../../../utils/utils', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + download: mocks.download, + }; +}); + vi.mock('../../../../../../components/i18n', () => ({ useFormatter: () => ({ t: (s: string) => s, @@ -412,6 +427,38 @@ describe('SimulationAttackPath findings drawer + cross-focus', () => { expect(mocks.flowProps.current?.fitRequest ?? 0).toBeGreaterThan(0); expect(screen.queryByText(/Executions/)).toBeNull(); }); + + it('exports the graph as a PNG and reports a failed capture instead of downloading nothing', async () => { + const notifyError = vi.spyOn(MESSAGING$, 'notifyError').mockImplementation(() => {}); + setup(); + await screen.findByTestId('attack-path-flow'); + + // The capture belongs to the canvas (it owns the world geometry and the culling), so the button + // only asks for one: a nonce the canvas turns into a blob. + const exportButton = screen.getByRole('button', { name: 'Export as PNG' }); + fireEvent.click(exportButton); + await waitFor(() => expect(mocks.flowProps.current?.exportRequest ?? 0).toBeGreaterThan(0)); + // A second click while the capture runs would queue a redundant one. + expect((exportButton as HTMLButtonElement).disabled).toBe(true); + + // The returned image is downloaded under a name carrying the simulation it comes from. + const png = new Blob(['png'], { type: 'image/png' }); + await act(async () => { + mocks.flowProps.current?.onExportDone?.(png); + }); + expect(mocks.download).toHaveBeenCalledWith(png, 'attack-path-sim-1.png', 'image/png'); + expect((exportButton as HTMLButtonElement).disabled).toBe(false); + + // A failed capture must say so: silently doing nothing reads as a broken button. + mocks.download.mockClear(); + fireEvent.click(exportButton); + await act(async () => { + mocks.flowProps.current?.onExportDone?.(null); + }); + expect(mocks.download).not.toHaveBeenCalled(); + expect(notifyError).toHaveBeenCalledWith('Error while exporting the attack path'); + notifyError.mockRestore(); + }); }); // The live path (issue 6647): one snapshot then a 3 s delta poll. Timers are faked so the cadence, the diff --git a/openaev-front/src/__tests__/admin/components/simulations/simulation/attack_path/canvas/AttackPathCanvas.test.tsx b/openaev-front/src/__tests__/admin/components/simulations/simulation/attack_path/canvas/AttackPathCanvas.test.tsx new file mode 100644 index 00000000000..f38a8fe3b04 --- /dev/null +++ b/openaev-front/src/__tests__/admin/components/simulations/simulation/attack_path/canvas/AttackPathCanvas.test.tsx @@ -0,0 +1,120 @@ +import { createTheme, ThemeProvider } from '@mui/material/styles'; +import { act, cleanup, render } from '@testing-library/react'; +import { type ReactNode } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { AP_FLOW_NODE_TYPE, type AttackPathFlowNode } from '../../../../../../../admin/components/simulations/simulation/attack_path/attack-path-flow-helpers'; +import AttackPathCanvas from '../../../../../../../admin/components/simulations/simulation/attack_path/canvas/AttackPathCanvas'; +import { computeCardRects, computeContentBounds } from '../../../../../../../admin/components/simulations/simulation/attack_path/canvas/canvas-geometry'; + +const mocks = vi.hoisted(() => ({ toBlob: vi.fn() })); + +// html-to-image needs a real browser (foreignObject + canvas), so the DOM capture itself is stubbed: +// what this test pins is the contract the canvas offers to it — the frame of the WHOLE graph. +vi.mock('html-to-image', () => ({ toBlob: mocks.toBlob })); + +vi.mock('../../../../../../../components/i18n', () => ({ useFormatter: () => ({ t: (s: string) => s }) })); + +const wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +// Two cards far apart, so an export that only framed the visible camera would crop one out. +const nodes: AttackPathFlowNode[] = [{ + id: 'NODE_ASSET|host-x', + type: AP_FLOW_NODE_TYPE.asset, + position: { + x: 0, + y: 0, + }, + data: { label: 'CORP-HOST' }, +}, { + id: 'NODE_FINDING|cred-1', + type: AP_FLOW_NODE_TYPE.finding, + position: { + x: 1600, + y: 900, + }, + data: { label: 'admin:••••' }, +}]; + +const settleExport = async (render: () => void) => { + await act(async () => { + render(); + await new Promise((resolve) => { + requestAnimationFrame(() => setTimeout(resolve, 0)); + }); + }); +}; + +describe('AttackPathCanvas PNG export', () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it('captures the whole graph, not the part the camera happens to show', async () => { + const png = new Blob(['png'], { type: 'image/png' }); + mocks.toBlob.mockResolvedValue(png); + const onExportDone = vi.fn(); + + const { rerender } = render( + , + { wrapper }, + ); + expect(mocks.toBlob).not.toHaveBeenCalled(); + + // A new nonce asks for a capture: the canvas mounts the culled cards, then reads the DOM one + // frame later. + await settleExport(() => rerender( + , + )); + + expect(mocks.toBlob).toHaveBeenCalledTimes(1); + const [, options] = mocks.toBlob.mock.calls[0]; + // The frame is the whole laid-out world plus the export margin — the far card included, which a + // capture of the visible camera could not contain. + const world = computeContentBounds(computeCardRects(nodes)); + expect(options.width).toBe(world.width + 96); + expect(options.height).toBe(world.height + 96); + expect(options.width).toBeGreaterThan(400); + expect(options.height).toBeGreaterThan(400); + // The live camera transform is replaced by the framing one, so pan/zoom cannot crop the image. + expect(options.style.transform).toBe('translate(48px, 48px)'); + expect(options.backgroundColor).toBeTruthy(); + + expect(onExportDone).toHaveBeenCalledWith(png); + }); + + it('reports a failed capture rather than handing back a broken image', async () => { + mocks.toBlob.mockRejectedValue(new Error('boom')); + const onExportDone = vi.fn(); + + const { rerender } = render( + , + { wrapper }, + ); + await settleExport(() => rerender( + , + )); + + expect(onExportDone).toHaveBeenCalledWith(null); + }); + + it('does not replay the last capture when the canvas is remounted', async () => { + mocks.toBlob.mockResolvedValue(new Blob(['png'], { type: 'image/png' })); + const onExportDone = vi.fn(); + + // Mounting with a nonce already spent (leaving and coming back to the graph view) must not + // download a second image behind the user's back. + await settleExport(() => { + render( + , + { wrapper }, + ); + }); + + expect(mocks.toBlob).not.toHaveBeenCalled(); + expect(onExportDone).not.toHaveBeenCalled(); + }); +}); diff --git a/openaev-front/src/admin/components/simulations/simulation/attack_path/AttackPathHeader.tsx b/openaev-front/src/admin/components/simulations/simulation/attack_path/AttackPathHeader.tsx index 0fdc1e3ac7e..9bb2ed97f0d 100644 --- a/openaev-front/src/admin/components/simulations/simulation/attack_path/AttackPathHeader.tsx +++ b/openaev-front/src/admin/components/simulations/simulation/attack_path/AttackPathHeader.tsx @@ -1,5 +1,5 @@ -import { AccountTreeOutlined, ArrowBackOutlined, FilterAltOffOutlined, FullscreenExitOutlined, FullscreenOutlined, HelpOutline, LocalFireDepartment, MoreHorizOutlined, SearchOutlined, TableRowsOutlined } from '@mui/icons-material'; -import { Autocomplete, Box, Button, ButtonBase, ListItemButton, Paper, Popover, TextField, ToggleButton, ToggleButtonGroup, Tooltip, Typography } from '@mui/material'; +import { AccountTreeOutlined, ArrowBackOutlined, FilterAltOffOutlined, FullscreenExitOutlined, FullscreenOutlined, HelpOutline, ImageOutlined, LocalFireDepartment, MoreHorizOutlined, SearchOutlined, TableRowsOutlined } from '@mui/icons-material'; +import { Autocomplete, Box, Button, ButtonBase, CircularProgress, IconButton, ListItemButton, Paper, Popover, TextField, ToggleButton, ToggleButtonGroup, Tooltip, Typography } from '@mui/material'; import { alpha, useTheme } from '@mui/material/styles'; import { type FunctionComponent, type MouseEvent, type ReactNode, useState } from 'react'; @@ -184,6 +184,9 @@ interface Props { // Fullscreen toggle. fullscreen: boolean; onToggleFullscreen: () => void; + // PNG export of the whole graph (graph view only; omitted when there is nothing to export). + onExportPng?: () => void; + exportingPng?: boolean; // Search (endpoint / injector / finding type). searchOptions: SearchOption[]; searchInput: string; @@ -218,6 +221,8 @@ const AttackPathHeader: FunctionComponent = ({ onViewChange, fullscreen, onToggleFullscreen, + onExportPng, + exportingPng, searchOptions, searchInput, onSearchInputChange, @@ -637,6 +642,28 @@ const AttackPathHeader: FunctionComponent = ({ + {/* Action, not a state: an IconButton with the segmented controls' outline so the band still + reads as one family. Only the graph can be rasterized — the table has its own CSV export. */} + {view === 'graph' && onExportPng && ( + + + + {exportingPng ? : } + + + + )} {/* Standalone ToggleButton so fullscreen reads as part of the same segmented family. */} window.removeEventListener('keydown', onKey); }, [fullscreen]); + // PNG export of the graph. The capture itself belongs to the canvas (it owns the world geometry + // and the off-screen culling), so the button only fires a nonce and waits for the blob back. + const [exportNonce, setExportNonce] = useState(0); + const [exportingPng, setExportingPng] = useState(false); + const requestPngExport = useCallback(() => { + setExportingPng(true); + setExportNonce(n => n + 1); + }, []); + const onPngExported = useCallback((png: Blob | null) => { + setExportingPng(false); + if (!png) { + MESSAGING$.notifyError(t('Error while exporting the attack path')); + return; + } + const name = metaById.get(simulationId)?.exercise_name || simulationId; + const slug = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + download(png, `attack-path-${slug || 'graph'}.png`, 'image/png'); + }, [metaById, simulationId, t]); + // Free-text search input (endpoint / injector / finding type), used by the search autocomplete. const [searchInput, setSearchInput] = useState(''); @@ -2731,6 +2751,8 @@ const SimulationAttackPath = ({ scenarioExerciseIds, scenarioId, hideLaunchCta = onViewChange={setView} fullscreen={fullscreen} onToggleFullscreen={() => setFullscreen(f => !f)} + onExportPng={graphHasContent ? requestPngExport : undefined} + exportingPng={exportingPng} searchOptions={searchOptions} searchInput={searchInput} onSearchInputChange={setSearchInput} @@ -2877,6 +2899,8 @@ const SimulationAttackPath = ({ scenarioExerciseIds, scenarioId, hideLaunchCta = pursuitActive={pursuitActive && !pathFinding} showMiniMap={!pathFinding && nodes.length > 40} legend={} + exportRequest={exportNonce} + onExportDone={onPngExported} /> )} diff --git a/openaev-front/src/admin/components/simulations/simulation/attack_path/canvas/AttackPathCanvas.tsx b/openaev-front/src/admin/components/simulations/simulation/attack_path/canvas/AttackPathCanvas.tsx index c2e626fedd3..5f901bf3b35 100644 --- a/openaev-front/src/admin/components/simulations/simulation/attack_path/canvas/AttackPathCanvas.tsx +++ b/openaev-front/src/admin/components/simulations/simulation/attack_path/canvas/AttackPathCanvas.tsx @@ -1,6 +1,7 @@ import { AddOutlined, CenterFocusStrongOutlined, RemoveOutlined } from '@mui/icons-material'; import { Box, IconButton, Tooltip } from '@mui/material'; import { useTheme } from '@mui/material/styles'; +import { toBlob } from 'html-to-image'; import { type PointerEvent as ReactPointerEvent, type ReactNode, @@ -78,6 +79,14 @@ interface AttackPathCanvasProps { showMiniMap?: boolean; /** Overlay rendered in the bottom-right stack, under the minimap (the graph legend). */ legend?: ReactNode; + /** + * Capture the WHOLE graph as a PNG; nonce re-fires on repeat. The capture is driven from here + * rather than from the parent because only the canvas knows the world geometry and can lift the + * off-screen culling that keeps the DOM small — culled cards would be missing from the image. + */ + exportRequest?: number; + /** Result of an {@link AttackPathCanvasProps#exportRequest}: null when the capture failed. */ + onExportDone?: (png: Blob | null) => void; } interface Camera { @@ -96,6 +105,19 @@ const INITIAL_MAX_ZOOM = 1.1; const ZOOM_STEP = 1.15; const DRAG_THRESHOLD = 4; const CULL_MARGIN = 240; +// Margin kept around the graph in an exported PNG, so no card touches the image edge. +const EXPORT_PADDING = 48; +// Browsers refuse canvases beyond ~16k on a side or ~2^28 pixels, and answer with a blank image +// rather than an error: a huge graph is captured at a lower density instead. +const MAX_EXPORT_SIDE = 8192; +const MAX_EXPORT_PIXELS = 32_000_000; +const exportPixelRatio = (width: number, height: number) => Math.min( + 2, + window.devicePixelRatio || 1, + MAX_EXPORT_SIDE / width, + MAX_EXPORT_SIDE / height, + Math.sqrt(MAX_EXPORT_PIXELS / (width * height)), +); // After a manual pan/zoom/fit, live pursuit stays hands-off for this long before following again, // so the user can inspect (or hold the full overview) without the camera being snatched away. const PURSUIT_MANUAL_PAUSE_MS = 6000; @@ -130,10 +152,16 @@ const AttackPathCanvas = ({ anchorRequest, showMiniMap = true, legend, + exportRequest, + onExportDone, }: AttackPathCanvasProps) => { const theme = useTheme(); const { t } = useFormatter(); const containerRef = useRef(null); + const worldRef = useRef(null); + // While true the whole world is mounted (no culling) and entrance animations are off, so a PNG + // capture sees a complete, settled graph. + const [exporting, setExporting] = useState(false); const [camera, setCamera] = useState({ zoom: 1, x: 0, @@ -691,7 +719,8 @@ const AttackPathCanvas = ({ // Cull off-screen cards: with hundreds of nodes only mount those whose screen rect intersects the // viewport (expanded by a margin so cards just outside slide in without a pop). const visibleNodes = useMemo(() => { - if (size.w === 0) { + // A PNG export captures the DOM: everything must be mounted, culled or not. + if (size.w === 0 || exporting) { return nodes; } const left = -CULL_MARGIN; @@ -709,7 +738,88 @@ const AttackPathCanvas = ({ const sh = r.height * camera.zoom; return sx + sw >= left && sx <= right && sy + sh >= top && sy <= bottom; }); - }, [nodes, effectiveRects, camera, size]); + }, [nodes, effectiveRects, camera, size, exporting]); + + // Frame of an exported PNG: the auto-layout world, widened to also contain cards the user dragged + // outside of it, plus a margin — so nothing is cropped whatever the current pan/zoom is. + const exportFrame = useCallback(() => { + let minX = 0; + let minY = 0; + let maxX = worldWidth; + let maxY = worldHeight; + effectiveRects.forEach((r) => { + minX = Math.min(minX, r.x); + minY = Math.min(minY, r.y); + maxX = Math.max(maxX, r.x + r.width); + maxY = Math.max(maxY, r.y + r.height); + }); + return { + width: maxX - minX + EXPORT_PADDING * 2, + height: maxY - minY + EXPORT_PADDING * 2, + offsetX: EXPORT_PADDING - minX, + offsetY: EXPORT_PADDING - minY, + }; + }, [effectiveRects, worldWidth, worldHeight]); + const exportFrameRef = useRef(exportFrame); + exportFrameRef.current = exportFrame; + const onExportDoneRef = useRef(onExportDone); + onExportDoneRef.current = onExportDone; + + // An export first switches the canvas into export mode (see `exporting`), because the culled + // cards have to be mounted before the DOM can be captured. The nonce is tracked so a REMOUNT (a + // trip through the table view, say) does not replay the last capture. + const handledExportRef = useRef(exportRequest ?? 0); + useEffect(() => { + if (exportRequest && exportRequest !== handledExportRef.current) { + handledExportRef.current = exportRequest; + setExporting(true); + } + }, [exportRequest]); + + // Effects run after the commit, so by now every card is in the DOM; one frame then lets the + // browser lay them out before their computed styles are read. + useEffect(() => { + if (!exporting) { + return undefined; + } + let settled = false; + const done = (png: Blob | null) => { + if (settled) { + return; + } + settled = true; + onExportDoneRef.current?.(png); + setExporting(false); + }; + const frame = requestAnimationFrame(() => { + const world = worldRef.current; + if (!world) { + done(null); + return; + } + const { width, height, offsetX, offsetY } = exportFrameRef.current(); + toBlob(world, { + width, + height, + pixelRatio: exportPixelRatio(width, height), + backgroundColor: theme.palette.background.default, + // Applied to the clone only: it replaces the live camera transform, so the capture frames + // the whole world whatever the user's current pan/zoom is. + style: { + transform: `translate(${offsetX}px, ${offsetY}px)`, + transformOrigin: '0 0', + }, + }) + .then(png => done(png)) + .catch(() => done(null)); + }); + return () => { + cancelAnimationFrame(frame); + // Torn down before the capture produced anything: answer, so the caller is not left waiting + // on a blob that will never come. A capture that already answered ignores this. + done(null); + }; + }, [exporting, theme.palette.background.default]); const controlButtonSx = { 'padding': 0.75, @@ -778,6 +888,7 @@ const AttackPathCanvas = ({ }} >