Skip to content

Commit e4250ff

Browse files
Copilotrichlundeen
authored andcommitted
FEAT: Add scenario run history
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5d02c2d5-b499-4f78-a04d-03bffa750817
1 parent 2fd41d4 commit e4250ff

37 files changed

Lines changed: 3760 additions & 82 deletions

‎frontend/e2e/scenario-history.spec.ts‎

Lines changed: 588 additions & 0 deletions
Large diffs are not rendered by default.

‎frontend/src/App.test.tsx‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,9 @@ jest.mock("./components/Layout/MainLayout", () => {
112112
<button onClick={() => onNavigate("scenarios")} data-testid="nav-scenarios">
113113
Scenarios
114114
</button>
115+
<button onClick={() => onNavigate("scenarioHistory")} data-testid="nav-scenario-history">
116+
Scenario History
117+
</button>
115118
{children}
116119
</div>
117120
);
@@ -372,6 +375,15 @@ jest.mock("./components/Scenarios/ScenarioRunPage", () => {
372375
};
373376
});
374377

378+
jest.mock("./components/History/ScenarioHistory", () => {
379+
const MockScenarioHistory = () => <div data-testid="scenario-history" />;
380+
MockScenarioHistory.displayName = "MockScenarioHistory";
381+
return {
382+
__esModule: true,
383+
default: MockScenarioHistory,
384+
};
385+
});
386+
375387
describe("App", () => {
376388
// App reads the active view from the URL, so every render needs a router.
377389
// initialPath lets a test deep-link straight to a view (e.g. "/config").
@@ -457,11 +469,21 @@ describe("App", () => {
457469

458470
expect(screen.getByTestId("main-layout")).toHaveAttribute(
459471
"data-current-view",
460-
"scenarios"
472+
"scenarioHistory"
461473
);
462474
expect(screen.getByTestId("scenario-run-page")).toBeInTheDocument();
463475
});
464476

477+
it("renders scenario history as a distinct URL-backed view", () => {
478+
renderApp("/scenario-history?operator=alice");
479+
480+
expect(screen.getByTestId("main-layout")).toHaveAttribute(
481+
"data-current-view",
482+
"scenarioHistory"
483+
);
484+
expect(screen.getByTestId("scenario-history")).toBeInTheDocument();
485+
});
486+
465487
it("switches to the scenarios view via the sidebar", () => {
466488
renderApp();
467489

@@ -474,6 +496,18 @@ describe("App", () => {
474496
expect(screen.getByTestId("scenario-catalog")).toBeInTheDocument();
475497
});
476498

499+
it("switches to scenario history via its distinct sidebar destination", () => {
500+
renderApp();
501+
502+
fireEvent.click(screen.getByTestId("nav-scenario-history"));
503+
504+
expect(screen.getByTestId("main-layout")).toHaveAttribute(
505+
"data-current-view",
506+
"scenarioHistory"
507+
);
508+
expect(screen.getByTestId("scenario-history")).toBeInTheDocument();
509+
});
510+
477511
it("passes the active target and labels to the scenario detail view", () => {
478512
renderApp("/scanner/foundry.red_team_agent");
479513

‎frontend/src/App.tsx‎

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import Home from './components/Home/Home'
1010
import TargetConfig from './components/Config/TargetConfig'
1111
import Initializers from './components/Initializers/Initializers'
1212
import AttackHistory from './components/History/AttackHistory'
13+
import ScenarioHistory from './components/History/ScenarioHistory'
1314
import ScenarioCatalog from './components/Scenarios/ScenarioCatalog'
1415
import ScenarioDetail from './components/Scenarios/ScenarioDetail'
1516
import ScenarioRunPage from './components/Scenarios/ScenarioRunPage'
@@ -22,6 +23,11 @@ import { ConnectionHealthProvider, useConnectionHealth } from './hooks/useConnec
2223
import { DEFAULT_GLOBAL_LABELS } from './components/Labels/labelDefaults'
2324
import { readStoredGlobalLabels, persistGlobalLabels } from './components/Labels/labelStorage'
2425
import { filtersFromSearchParams, filtersToSearchParams } from './components/History/historyFilters'
26+
import {
27+
scenarioHistoryFiltersFromSearchParams,
28+
scenarioHistoryFiltersToSearchParams,
29+
} from './components/History/scenarioHistoryFilters'
30+
import type { ScenarioHistoryFilters } from './components/History/scenarioHistoryFilters'
2531
import type { ViewName } from './components/Sidebar/Navigation'
2632
import type { TargetInfo } from './types'
2733
import {
@@ -49,6 +55,7 @@ const VIEW_PATHS: Record<ViewName, string> = {
4955
config: '/config',
5056
initializers: '/initializers',
5157
scenarios: '/scanner',
58+
scenarioHistory: '/scenario-history',
5259
}
5360

5461
/**
@@ -58,9 +65,12 @@ const VIEW_PATHS: Record<ViewName, string> = {
5865
* single canonical `VIEW_PATHS` entry.
5966
*/
6067
function viewFromPath(pathname: string): ViewName {
61-
if (pathname === VIEW_PATHS.scenarios || pathname.startsWith(`${VIEW_PATHS.scenarios}/`) || pathname.startsWith('/scenario-history/')) {
68+
if (pathname === VIEW_PATHS.scenarios || pathname.startsWith(`${VIEW_PATHS.scenarios}/`)) {
6269
return 'scenarios'
6370
}
71+
if (pathname === VIEW_PATHS.scenarioHistory || pathname.startsWith(`${VIEW_PATHS.scenarioHistory}/`)) {
72+
return 'scenarioHistory'
73+
}
6474
const match = (Object.entries(VIEW_PATHS) as [ViewName, string][]).find(
6575
([, path]) => path === pathname,
6676
)
@@ -147,22 +157,34 @@ function App() {
147157
// the History nav button can restore filters after visiting another view.
148158
const [searchParams, setSearchParams] = useSearchParams()
149159
const historyFilters = useMemo(() => filtersFromSearchParams(searchParams), [searchParams])
160+
const scenarioHistoryFilters = useMemo(
161+
() => scenarioHistoryFiltersFromSearchParams(searchParams),
162+
[searchParams],
163+
)
150164
const scenarioResultId = useMemo(
151165
() => scenarioRunProvenance(searchParams),
152166
[searchParams],
153167
)
154168
const lastHistorySearch = useRef('')
169+
const lastScenarioHistorySearch = useRef('')
155170
useEffect(() => {
156171
if (location.pathname === VIEW_PATHS.history) {
157172
lastHistorySearch.current = location.search
158173
}
174+
if (location.pathname === VIEW_PATHS.scenarioHistory) {
175+
lastScenarioHistorySearch.current = location.search
176+
}
159177
}, [location.pathname, location.search])
160178

161179
const handleFiltersChange = useCallback((filters: HistoryFilters) => {
162180
setSearchParams(filtersToSearchParams(filters), { replace: true })
163181
}, [setSearchParams])
164182

165-
/** App version display, attached to feedback context */
183+
const handleScenarioHistoryFiltersChange = useCallback((filters: ScenarioHistoryFilters) => {
184+
setSearchParams(scenarioHistoryFiltersToSearchParams(filters), { replace: true })
185+
}, [setSearchParams])
186+
187+
/** App version display, attached to feedback context */
166188
const [appVersion, setAppVersion] = useState<string>('')
167189
/** Whether the feedback dialog is currently open */
168190
const [feedbackOpen, setFeedbackOpen] = useState(false)
@@ -341,6 +363,10 @@ function App() {
341363
navigate(VIEW_PATHS.history + lastHistorySearch.current)
342364
return
343365
}
366+
if (view === 'scenarioHistory') {
367+
navigate(VIEW_PATHS.scenarioHistory + lastScenarioHistorySearch.current)
368+
return
369+
}
344370
navigate(VIEW_PATHS[view])
345371
}, [navigate])
346372

@@ -394,6 +420,15 @@ function App() {
394420
navigate(attackRoutePath(openAttackResultId))
395421
}, [navigate])
396422

423+
const handleOpenScenarioRun = useCallback((scenarioResultId: string) => {
424+
navigate(`${VIEW_PATHS.scenarioHistory}/${encodeURIComponent(scenarioResultId)}`, {
425+
state: {
426+
fromScenarioHistory: true,
427+
scenarioHistorySearch: location.search,
428+
},
429+
})
430+
}, [location.search, navigate])
431+
397432
const chatElement = isAttackNotFound || isAttackError ? (
398433
<AttackNotFound
399434
attackId={routeAttackId ?? ''}
@@ -491,6 +526,17 @@ function App() {
491526
/>
492527
}
493528
/>
529+
<Route
530+
path="/scenario-history"
531+
element={
532+
<ScenarioHistory
533+
filters={scenarioHistoryFilters}
534+
onFiltersChange={handleScenarioHistoryFiltersChange}
535+
onOpenRun={handleOpenScenarioRun}
536+
onNavigate={handleNavigate}
537+
/>
538+
}
539+
/>
494540
<Route path="/scenario-history/:scenarioResultId" element={<ScenarioRunPage />} />
495541
<Route
496542
path="/history"
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { makeStyles, tokens } from '@fluentui/react-components'
2+
3+
import {
4+
MINIMUM_TOUCH_TARGET_SIZE,
5+
TOUCH_INPUT_QUERY,
6+
mobileTouchTarget,
7+
mobileTouchTargetHeight,
8+
} from '@/styles/touchTargets'
9+
10+
export const useScenarioHistoryStyles = makeStyles({
11+
root: {
12+
display: 'flex',
13+
flexDirection: 'column',
14+
height: '100%',
15+
overflow: 'hidden',
16+
backgroundColor: tokens.colorNeutralBackground2,
17+
},
18+
header: {
19+
padding: `${tokens.spacingVerticalM} ${tokens.spacingHorizontalXXL}`,
20+
borderBottom: `1px solid ${tokens.colorNeutralStroke1}`,
21+
backgroundColor: tokens.colorNeutralBackground3,
22+
},
23+
headerRow: {
24+
display: 'flex',
25+
alignItems: 'center',
26+
justifyContent: 'space-between',
27+
gap: tokens.spacingHorizontalM,
28+
},
29+
filters: {
30+
display: 'flex',
31+
flexWrap: 'wrap',
32+
alignItems: 'center',
33+
gap: tokens.spacingHorizontalS,
34+
marginTop: tokens.spacingVerticalS,
35+
},
36+
filterDropdown: {
37+
minWidth: '160px',
38+
...mobileTouchTargetHeight,
39+
'& > input': {
40+
[TOUCH_INPUT_QUERY]: {
41+
minHeight: MINIMUM_TOUCH_TARGET_SIZE,
42+
},
43+
},
44+
},
45+
content: {
46+
flex: 1,
47+
overflow: 'auto',
48+
},
49+
table: {
50+
minWidth: '1120px',
51+
},
52+
clickableRow: {
53+
cursor: 'pointer',
54+
minHeight: MINIMUM_TOUCH_TARGET_SIZE,
55+
':hover': {
56+
backgroundColor: tokens.colorNeutralBackground1Hover,
57+
},
58+
},
59+
rowLink: {
60+
color: 'inherit',
61+
display: 'inline-flex',
62+
alignItems: 'center',
63+
minHeight: MINIMUM_TOUCH_TARGET_SIZE,
64+
textDecorationLine: 'none',
65+
':focus-visible': {
66+
outline: `2px solid ${tokens.colorStrokeFocus2}`,
67+
outlineOffset: '2px',
68+
},
69+
},
70+
identity: {
71+
display: 'flex',
72+
flexDirection: 'column',
73+
minWidth: '180px',
74+
},
75+
secondary: {
76+
color: tokens.colorNeutralForeground3,
77+
},
78+
nowrap: {
79+
whiteSpace: 'nowrap',
80+
},
81+
badges: {
82+
display: 'flex',
83+
flexWrap: 'wrap',
84+
gap: tokens.spacingHorizontalXXS,
85+
maxWidth: '240px',
86+
},
87+
target: {
88+
display: 'flex',
89+
flexDirection: 'column',
90+
maxWidth: '220px',
91+
},
92+
truncate: {
93+
overflow: 'hidden',
94+
textOverflow: 'ellipsis',
95+
whiteSpace: 'nowrap',
96+
},
97+
emptyState: {
98+
display: 'flex',
99+
flexDirection: 'column',
100+
alignItems: 'center',
101+
justifyContent: 'center',
102+
gap: tokens.spacingVerticalM,
103+
padding: tokens.spacingVerticalXXXL,
104+
},
105+
pagination: {
106+
display: 'flex',
107+
justifyContent: 'center',
108+
alignItems: 'center',
109+
gap: tokens.spacingHorizontalM,
110+
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalXXL}`,
111+
borderTop: `1px solid ${tokens.colorNeutralStroke1}`,
112+
backgroundColor: tokens.colorNeutralBackground3,
113+
},
114+
touchTarget: {
115+
...mobileTouchTarget,
116+
},
117+
touchTargetHeight: {
118+
...mobileTouchTargetHeight,
119+
},
120+
})

0 commit comments

Comments
 (0)