diff --git a/frontend/src/components/Chat/MessageList.styles.ts b/frontend/src/components/Chat/MessageList.styles.ts
index 174c63ee6f..db408c6df1 100644
--- a/frontend/src/components/Chat/MessageList.styles.ts
+++ b/frontend/src/components/Chat/MessageList.styles.ts
@@ -241,50 +241,6 @@ export const useMessageListStyles = makeStyles({
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
- scoreSurface: {
- display: 'flex',
- flexDirection: 'column',
- rowGap: tokens.spacingVerticalXS,
- width: 'min(360px, calc(100vw - 64px))',
- minWidth: 0,
- maxWidth: '100%',
- },
- scoreRow: {
- display: 'flex',
- alignItems: 'flex-start',
- columnGap: tokens.spacingHorizontalS,
- width: '100%',
- minWidth: 0,
- },
- scoreLabel: {
- flex: '0 0 72px',
- color: tokens.colorNeutralForeground2,
- },
- scoreValue: {
- display: 'block',
- flex: '0 1 auto',
- minWidth: 0,
- maxWidth: '100%',
- height: 'auto',
- whiteSpace: 'normal',
- overflowWrap: 'anywhere',
- wordBreak: 'break-word',
- },
- scoreRationale: {
- display: 'flex',
- flexDirection: 'column',
- rowGap: tokens.spacingVerticalXXS,
- marginTop: tokens.spacingVerticalXS,
- paddingTop: tokens.spacingVerticalXS,
- borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
- },
- scoreRationaleText: {
- color: tokens.colorNeutralForeground2,
- whiteSpace: 'pre-wrap',
- wordBreak: 'break-word',
- maxHeight: '30vh',
- overflowY: 'auto',
- },
loadingEllipsis: {
fontSize: tokens.fontSizeBase500,
animationName: {
diff --git a/frontend/src/components/Chat/MessageList.tsx b/frontend/src/components/Chat/MessageList.tsx
index 28f96dacdd..0671e61927 100644
--- a/frontend/src/components/Chat/MessageList.tsx
+++ b/frontend/src/components/Chat/MessageList.tsx
@@ -31,9 +31,11 @@ import MarkdownContent from '@/components/Markdown/MarkdownContent'
import MediaPreview from '@/components/Media/MediaPreview'
import type { DisplayScore, Message, MessageAttachment } from '@/types'
+import { scoreSummaryFromBackendScore, scoreSummaryValue } from '@/utils/scoreSummary'
import { useMessageListStyles } from './MessageList.styles'
import { formatStructuredMessage, getRenderMessagePieces } from './messageViewModel'
+import ScoreDetails from './ScoreDetails'
interface MessageListProps {
onEditConversation?: () => void
@@ -62,51 +64,13 @@ interface MessageListProps {
}
function scoreDisplayValue(score: DisplayScore): string {
- // An undetermined score has no value, so name its status instead.
- return score.score_value ?? score.status ?? ''
+ return scoreSummaryValue(scoreSummaryFromBackendScore(score))
}
function scoreDisplayLabel(score: DisplayScore): string {
return `${score.is_objective_score ? 'Final score' : 'Score'}: ${scoreDisplayValue(score)}`
}
-function ScoreDetails({ score, testId }: { score: DisplayScore; testId: string }) {
- const styles = useMessageListStyles()
- const categories = score.score_category?.filter(Boolean) ?? []
-
- return (
-
-
Score details
-
- Score
- {scoreDisplayValue(score)}
-
-
- Scorer
- {score.scorer_type}
-
-
- Result role
-
- {score.is_objective_score ? 'Final score' : 'Supporting score'}
-
-
- {categories.length > 0 && (
-
- Category
- {categories.join(', ')}
-
- )}
- {score.score_rationale && (
-
- Rationale
- {score.score_rationale}
-
- )}
-
- )
-}
-
function MessageScore({ score, groupId, scoreIndex }: { score: DisplayScore; groupId: string | number; scoreIndex: number }) {
const styles = useMessageListStyles()
const displayValue = scoreDisplayValue(score)
@@ -130,7 +94,7 @@ function MessageScore({ score, groupId, scoreIndex }: { score: DisplayScore; gro
-
+
)
@@ -429,7 +393,7 @@ function MessageScores({ scores, groupId }: { scores: DisplayScore[]; groupId: s
id={`message-score-panel-${groupId}`}
aria-labelledby={`message-score-tab-${groupId}-${selectedIndex}`}
>
-
+
diff --git a/frontend/src/components/Chat/ScoreDetails.styles.ts b/frontend/src/components/Chat/ScoreDetails.styles.ts
new file mode 100644
index 0000000000..c47dccc28d
--- /dev/null
+++ b/frontend/src/components/Chat/ScoreDetails.styles.ts
@@ -0,0 +1,48 @@
+import { makeStyles, tokens } from '@fluentui/react-components'
+
+export const useScoreDetailsStyles = makeStyles({
+ surface: {
+ display: 'flex',
+ flexDirection: 'column',
+ rowGap: tokens.spacingVerticalXS,
+ width: 'min(360px, calc(100vw - 64px))',
+ minWidth: 0,
+ maxWidth: '100%',
+ },
+ row: {
+ display: 'flex',
+ alignItems: 'flex-start',
+ columnGap: tokens.spacingHorizontalS,
+ width: '100%',
+ minWidth: 0,
+ },
+ label: {
+ flex: '0 0 72px',
+ color: tokens.colorNeutralForeground2,
+ },
+ value: {
+ display: 'block',
+ flex: '0 1 auto',
+ minWidth: 0,
+ maxWidth: '100%',
+ height: 'auto',
+ whiteSpace: 'normal',
+ overflowWrap: 'anywhere',
+ wordBreak: 'break-word',
+ },
+ rationale: {
+ display: 'flex',
+ flexDirection: 'column',
+ rowGap: tokens.spacingVerticalXXS,
+ marginTop: tokens.spacingVerticalXS,
+ paddingTop: tokens.spacingVerticalXS,
+ borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
+ },
+ rationaleText: {
+ color: tokens.colorNeutralForeground2,
+ whiteSpace: 'pre-wrap',
+ wordBreak: 'break-word',
+ maxHeight: '30vh',
+ overflowY: 'auto',
+ },
+})
diff --git a/frontend/src/components/Chat/ScoreDetails.test.tsx b/frontend/src/components/Chat/ScoreDetails.test.tsx
new file mode 100644
index 0000000000..e162cc3bd0
--- /dev/null
+++ b/frontend/src/components/Chat/ScoreDetails.test.tsx
@@ -0,0 +1,36 @@
+import type { ReactNode } from 'react'
+
+import { FluentProvider, webLightTheme } from '@fluentui/react-components'
+import { render, screen } from '@testing-library/react'
+
+import ScoreDetails from './ScoreDetails'
+
+function TestWrapper({ children }: { readonly children: ReactNode }) {
+ return {children}
+}
+
+describe('ScoreDetails', () => {
+ it('should show the value, scorer, role, categories, and rationale', () => {
+ render()
+
+ const details = screen.getByTestId('details')
+ expect(details).toHaveTextContent('Score details')
+ expect(screen.getByText('true')).toBeInTheDocument()
+ expect(screen.getByText('SelfAskRefusalScorer')).toBeInTheDocument()
+ expect(screen.getByText('Final score')).toBeInTheDocument()
+ expect(screen.getByText('jailbreak, harm')).toBeInTheDocument()
+ expect(screen.getByText('The target complied.')).toBeInTheDocument()
+ })
+
+ it('should name an undetermined score by its status and omit empty sections', () => {
+ render()
+
+ expect(screen.getByText('undetermined')).toBeInTheDocument()
+ expect(screen.getByText('Supporting score')).toBeInTheDocument()
+ expect(screen.queryByText('Category')).not.toBeInTheDocument()
+ expect(screen.queryByText('Rationale')).not.toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/components/Chat/ScoreDetails.tsx b/frontend/src/components/Chat/ScoreDetails.tsx
new file mode 100644
index 0000000000..49a721e5d1
--- /dev/null
+++ b/frontend/src/components/Chat/ScoreDetails.tsx
@@ -0,0 +1,49 @@
+import { Badge, Text } from '@fluentui/react-components'
+
+import type { ScoreSummary } from '@/types'
+import { scoreSummaryValue } from '@/utils/scoreSummary'
+
+import { useScoreDetailsStyles } from './ScoreDetails.styles'
+
+interface ScoreDetailsProps {
+ readonly score: ScoreSummary
+ readonly testId?: string
+}
+
+/** One score's value, scorer, role, categories, and rationale, as shown in a score popover. */
+export default function ScoreDetails({ score, testId }: ScoreDetailsProps) {
+ const styles = useScoreDetailsStyles()
+ const categories = score.categories?.filter(Boolean) ?? []
+
+ return (
+
+
Score details
+
+ Score
+ {scoreSummaryValue(score)}
+
+
+ Scorer
+ {score.scorer}
+
+
+ Result role
+
+ {score.isObjective ? 'Final score' : 'Supporting score'}
+
+
+ {categories.length > 0 && (
+
+ Category
+ {categories.join(', ')}
+
+ )}
+ {score.rationale && (
+
+ Rationale
+ {score.rationale}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/Home/Home.tsx b/frontend/src/components/Home/Home.tsx
index 3b7b9daf0c..a645952097 100644
--- a/frontend/src/components/Home/Home.tsx
+++ b/frontend/src/components/Home/Home.tsx
@@ -2,7 +2,6 @@ import { useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import { useNavigate } from 'react-router'
import {
- Badge,
Button,
MessageBar,
MessageBarBody,
@@ -11,7 +10,6 @@ import {
TabList,
Text,
} from '@fluentui/react-components'
-import type { BadgeProps } from '@fluentui/react-components'
import {
AddRegular,
ArrowRightRegular,
@@ -24,26 +22,19 @@ import {
} from '@fluentui/react-icons'
import OutcomeBadge from '@/components/OutcomeBadge'
+import ScanStatusBadge from '@/components/ScanStatusBadge'
import type { ViewName } from '@/components/Sidebar/Navigation'
import { useRuntime } from '@/hooks/useRuntime'
import { useAutoRefresh } from '@/hooks/useAutoRefresh'
import { attacksApi, operationsApi, scenariosApi } from '@/services/api'
import { toApiError } from '@/services/errors'
-import type { AttackSummary, Operation, OperationListItem, ScenarioRunListItem, ScenarioRunState } from '@/types'
+import type { AttackSummary, Operation, OperationListItem, ScenarioRunListItem } from '@/types'
import { scenarioRunRoutePath } from '@/utils/routeParams'
import { useHomeStyles } from './Home.styles'
const RECENT_WORK_LIMIT = 5
const VISIBLE_OPERATIONS_LIMIT = 3
-const SCAN_STATUS_LABELS: Record = {
- CREATED: 'Created', QUEUED: 'Queued', IN_PROGRESS: 'Executing',
- COMPLETED: 'Completed', FAILED: 'Failed', CANCELLED: 'Cancelled',
-}
-const SCAN_STATUS_COLORS: Record = {
- CREATED: 'informative', QUEUED: 'informative', IN_PROGRESS: 'brand',
- COMPLETED: 'success', FAILED: 'danger', CANCELLED: 'warning',
-}
interface HomeProps {
readonly selectedOperation: Operation | null
@@ -165,7 +156,7 @@ export default function Home({
title: scan.scenario_registry_name || scan.scenario_name,
operationName: scan.operation_name,
updatedAt: scan.updated_at,
- badge: {SCAN_STATUS_LABELS[scan.status]},
+ badge: ,
open: () => { void navigate(scenarioRunRoutePath(scan.scenario_result_id)) },
})),
].filter((item: RecentItem) => filter === 'all' || (filter === 'attacks' ? item.kind === 'Attack' : item.kind === 'Scan'))
diff --git a/frontend/src/components/OutcomeBadge.test.tsx b/frontend/src/components/OutcomeBadge.test.tsx
index 7d35ef8ca1..e8f456a6f2 100644
--- a/frontend/src/components/OutcomeBadge.test.tsx
+++ b/frontend/src/components/OutcomeBadge.test.tsx
@@ -36,6 +36,17 @@ describe('OutcomeBadge', () => {
expect(screen.getByTestId('verdict')).toHaveTextContent('No')
expect(screen.getByTestId('verdict')).toHaveClass('custom')
})
+
+ it.each<[AttackOutcome, 'outcome' | 'success', string]>([
+ ['success', 'success', 'True'],
+ ['failure', 'success', 'False'],
+ ['error', 'success', 'Error'],
+ ['success', 'outcome', 'Success'],
+ ['undetermined', 'outcome', 'Undetermined'],
+ ])('labels %s in %s style as %s', (outcome: AttackOutcome, labelStyle: 'outcome' | 'success', label: string) => {
+ render()
+ expect(screen.getByTestId('verdict')).toHaveTextContent(label)
+ })
})
describe('OutcomeIcon', () => {
diff --git a/frontend/src/components/OutcomeBadge.tsx b/frontend/src/components/OutcomeBadge.tsx
index 33199b74e2..c27d2e8b86 100644
--- a/frontend/src/components/OutcomeBadge.tsx
+++ b/frontend/src/components/OutcomeBadge.tsx
@@ -9,6 +9,7 @@ import {
QuestionCircleRegular,
} from '@fluentui/react-icons'
+import { formatAttackOutcome, formatAttackSuccess } from '@/components/AttackResults/attackAttemptFormatting'
import type { AttackOutcome } from '@/types'
import { useOutcomeBadgeStyles } from './OutcomeBadge.styles'
@@ -20,6 +21,18 @@ const OUTCOME_ICONS: Record = {
undetermined: ,
}
+/**
+ * How a saved outcome is worded: `outcome` capitalizes it ("Success"), `success`
+ * answers "Attack success?" ("True"/"False"). Omitted keeps the raw outcome word.
+ */
+type OutcomeLabelStyle = 'outcome' | 'success'
+
+function outcomeLabel(outcome: AttackOutcome, labelStyle: OutcomeLabelStyle | undefined): string {
+ if (labelStyle === 'success') return formatAttackSuccess(outcome)
+ if (labelStyle === 'outcome') return formatAttackOutcome(outcome)
+ return outcome
+}
+
interface OutcomeBadgeProps {
readonly outcome?: AttackOutcome | null
readonly testId?: string
@@ -27,6 +40,7 @@ interface OutcomeBadgeProps {
readonly appearance?: BadgeProps['appearance']
readonly size?: BadgeProps['size']
readonly label?: string
+ readonly labelStyle?: OutcomeLabelStyle
}
interface OutcomeIconProps {
@@ -50,6 +64,7 @@ export default function OutcomeBadge({
appearance = 'filled',
size,
label,
+ labelStyle,
}: OutcomeBadgeProps) {
const styles = useOutcomeBadgeStyles()
const normalizedOutcome = outcome ?? 'undetermined'
@@ -62,7 +77,7 @@ export default function OutcomeBadge({
className={mergeClasses(styles[normalizedOutcome], styles[appearance], className)}
data-testid={testId}
>
- {label ?? normalizedOutcome}
+ {label ?? outcomeLabel(normalizedOutcome, labelStyle)}
)
}
diff --git a/frontend/src/components/Registry/IdentityDisclosure.styles.ts b/frontend/src/components/Registry/IdentityDisclosure.styles.ts
new file mode 100644
index 0000000000..ab5fafea92
--- /dev/null
+++ b/frontend/src/components/Registry/IdentityDisclosure.styles.ts
@@ -0,0 +1,29 @@
+import { makeStyles, tokens } from '@fluentui/react-components'
+
+import { mobileTouchTargetHeight } from '@/styles/touchTargets'
+
+export const useIdentityDisclosureStyles = makeStyles({
+ toggle: {
+ ...mobileTouchTargetHeight,
+ justifyContent: 'flex-start',
+ textAlign: 'left',
+ overflowWrap: 'anywhere',
+ fontFamily: tokens.fontFamilyBase,
+ fontSize: tokens.fontSizeBase300,
+ fontWeight: tokens.fontWeightRegular,
+ lineHeight: tokens.lineHeightBase300,
+ },
+ details: {
+ paddingBlock: tokens.spacingVerticalM,
+ },
+ json: {
+ margin: 0,
+ padding: `${tokens.spacingVerticalM} ${tokens.spacingHorizontalM}`,
+ backgroundColor: tokens.colorNeutralBackground2,
+ fontFamily: tokens.fontFamilyMonospace,
+ fontSize: tokens.fontSizeBase200,
+ lineHeight: tokens.lineHeightBase200,
+ whiteSpace: 'pre-wrap',
+ overflowWrap: 'anywhere',
+ },
+})
diff --git a/frontend/src/components/Registry/IdentityDisclosure.test.tsx b/frontend/src/components/Registry/IdentityDisclosure.test.tsx
new file mode 100644
index 0000000000..a5d81db628
--- /dev/null
+++ b/frontend/src/components/Registry/IdentityDisclosure.test.tsx
@@ -0,0 +1,64 @@
+import { useId, useState, type ReactNode } from 'react'
+
+import { FluentProvider, webLightTheme } from '@fluentui/react-components'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+
+import { IdentityDisclosureButton, IdentityJson } from './IdentityDisclosure'
+
+const IDENTITY = { class_name: 'Crescendo', parameters: { max_turns: 5 } }
+
+function TestWrapper({ children }: { readonly children: ReactNode }) {
+ return {children}
+}
+
+function Disclosure() {
+ const id = useId()
+ const [expanded, setExpanded] = useState(false)
+ return <>
+ { setExpanded((current: boolean) => !current) }} />
+ {expanded && }
+ >
+}
+
+describe('IdentityDisclosure', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('should reveal and hide the identity JSON from the name', async () => {
+ const user = userEvent.setup()
+ render()
+
+ const toggle = screen.getByRole('button', { name: 'Identity for Crescendo' })
+ expect(toggle).toHaveTextContent('Crescendo')
+ expect(toggle).toHaveAttribute('aria-expanded', 'false')
+ expect(screen.queryByRole('region')).not.toBeInTheDocument()
+
+ await user.click(toggle)
+ const details = screen.getByRole('region', { name: 'Identity for Crescendo' })
+ expect(toggle).toHaveAttribute('aria-expanded', 'true')
+ expect(toggle).toHaveAttribute('aria-controls', details.id)
+ expect(JSON.parse(details.textContent ?? '')).toEqual(IDENTITY)
+ expect(details.scrollIntoView).toHaveBeenCalled()
+
+ await user.click(toggle)
+ expect(screen.queryByRole('region')).not.toBeInTheDocument()
+ expect(toggle).toHaveFocus()
+ })
+
+ it('should not trigger an enclosing row when toggled', async () => {
+ const user = userEvent.setup()
+ const onRowClick = jest.fn()
+ const onToggle = jest.fn()
+ render(
+
+
+
+ )
+ await user.click(screen.getByRole('button', { name: 'Identity for Crescendo' }))
+ expect(onToggle).toHaveBeenCalledTimes(1)
+ expect(onRowClick).not.toHaveBeenCalled()
+ })
+})
diff --git a/frontend/src/components/Registry/IdentityDisclosure.tsx b/frontend/src/components/Registry/IdentityDisclosure.tsx
new file mode 100644
index 0000000000..5f5b19cc72
--- /dev/null
+++ b/frontend/src/components/Registry/IdentityDisclosure.tsx
@@ -0,0 +1,58 @@
+import { useCallback, type MouseEvent } from 'react'
+
+import { Button, mergeClasses } from '@fluentui/react-components'
+import { ChevronDownRegular, ChevronRightRegular } from '@fluentui/react-icons'
+
+import { useIdentityDisclosureStyles } from './IdentityDisclosure.styles'
+
+interface IdentityDisclosureButtonProps {
+ readonly name: string
+ readonly expanded: boolean
+ readonly controlsId: string
+ readonly onToggle: () => void
+ readonly className?: string
+}
+
+/** The component name that reveals its saved identity JSON. */
+export function IdentityDisclosureButton({
+ name, expanded, controlsId, onToggle, className,
+}: IdentityDisclosureButtonProps) {
+ const styles = useIdentityDisclosureStyles()
+ return (
+ : }
+ aria-expanded={expanded}
+ aria-controls={controlsId}
+ aria-label={`Identity for ${name}`}
+ onClick={(event: MouseEvent) => {
+ // Disclosures sit inside clickable rows; toggling must not also open the row.
+ event.stopPropagation()
+ onToggle()
+ }}
+ >
+ {name}
+
+ )
+}
+
+interface IdentityJsonProps {
+ readonly id: string
+ readonly name: string
+ readonly identity: object
+ readonly className?: string
+}
+
+/** The saved identity JSON, scrolled into view when it is revealed. */
+export function IdentityJson({ id, name, identity, className }: IdentityJsonProps) {
+ const styles = useIdentityDisclosureStyles()
+ const reveal = useCallback((element: HTMLDivElement | null): void => {
+ element?.scrollIntoView({ block: 'nearest', inline: 'start' })
+ }, [])
+ return (
+
+
{JSON.stringify(identity, null, 2)}
+
+ )
+}
diff --git a/frontend/src/components/Registry/RegistryIdentityRow.styles.ts b/frontend/src/components/Registry/RegistryIdentityRow.styles.ts
index 60b13ec1e3..73c7e3415b 100644
--- a/frontend/src/components/Registry/RegistryIdentityRow.styles.ts
+++ b/frontend/src/components/Registry/RegistryIdentityRow.styles.ts
@@ -1,33 +1,10 @@
import { makeStyles, tokens } from '@fluentui/react-components'
-import { mobileTouchTargetHeight } from '@/styles/touchTargets'
-
export const useRegistryIdentityRowStyles = makeStyles({
row: {
backgroundColor: tokens.colorNeutralBackground1,
},
name: {
- ...mobileTouchTargetHeight,
paddingBlock: tokens.spacingVerticalS,
- justifyContent: 'flex-start',
- textAlign: 'left',
- overflowWrap: 'anywhere',
- fontFamily: tokens.fontFamilyBase,
- fontSize: tokens.fontSizeBase300,
- fontWeight: tokens.fontWeightRegular,
- lineHeight: tokens.lineHeightBase300,
- },
- details: {
- paddingBlock: tokens.spacingVerticalM,
- },
- json: {
- margin: 0,
- padding: `${tokens.spacingVerticalM} ${tokens.spacingHorizontalM}`,
- backgroundColor: tokens.colorNeutralBackground2,
- fontFamily: tokens.fontFamilyMonospace,
- fontSize: tokens.fontSizeBase200,
- lineHeight: tokens.lineHeightBase200,
- whiteSpace: 'pre-wrap',
- overflowWrap: 'anywhere',
},
})
diff --git a/frontend/src/components/Registry/RegistryIdentityRow.tsx b/frontend/src/components/Registry/RegistryIdentityRow.tsx
index 06bcf23bf8..5811f452cb 100644
--- a/frontend/src/components/Registry/RegistryIdentityRow.tsx
+++ b/frontend/src/components/Registry/RegistryIdentityRow.tsx
@@ -1,8 +1,8 @@
-import { useCallback, useId, type MouseEvent, type ReactNode } from 'react'
+import { useId, type ReactNode } from 'react'
-import { Button, TableCell, TableRow, Text, mergeClasses, type TableRowProps } from '@fluentui/react-components'
-import { ChevronDownRegular, ChevronRightRegular } from '@fluentui/react-icons'
+import { TableCell, TableRow, Text, mergeClasses, type TableRowProps } from '@fluentui/react-components'
+import { IdentityDisclosureButton, IdentityJson } from './IdentityDisclosure'
import { useRegistryIdentityRowStyles } from './RegistryIdentityRow.styles'
interface RegistryIdentityRowProps {
@@ -27,33 +27,21 @@ export default function RegistryIdentityRow({
}: RegistryIdentityRowProps) {
const styles = useRegistryIdentityRowStyles()
const detailsId = useId()
- const revealDetails = useCallback((element: HTMLDivElement | null): void => {
- element?.scrollIntoView({ block: 'nearest', inline: 'start' })
- }, [])
return (
<>
{leadingCell}
- {identity ? : }
- aria-expanded={expanded} aria-controls={detailsId}
- aria-label={`Identity for ${name}`} onClick={(event: MouseEvent) => {
- event.stopPropagation()
- onToggle()
- }}>
- {name}
- : {name}}
+ {identity ? : {name}}
{nameContent}
{children}
{expanded && identity &&
-
-
{JSON.stringify(identity, null, 2)}
-
+
}
{additionalRows}
diff --git a/frontend/src/components/ScanStatusBadge.test.tsx b/frontend/src/components/ScanStatusBadge.test.tsx
new file mode 100644
index 0000000000..8fdc13542d
--- /dev/null
+++ b/frontend/src/components/ScanStatusBadge.test.tsx
@@ -0,0 +1,42 @@
+import type { ReactNode } from 'react'
+
+import { FluentProvider, webLightTheme } from '@fluentui/react-components'
+import { render, screen } from '@testing-library/react'
+
+import ScanStatusBadge from './ScanStatusBadge'
+
+function TestWrapper({ children }: { readonly children: ReactNode }) {
+ return {children}
+}
+
+describe('ScanStatusBadge', () => {
+ it.each([
+ ['CREATED', 'Created'],
+ ['QUEUED', 'Queued'],
+ ['IN_PROGRESS', 'Executing'],
+ ['COMPLETED', 'Completed'],
+ ['FAILED', 'Failed'],
+ ['CANCELLED', 'Cancelled'],
+ ['QUEUING', 'Queuing'],
+ ['PENDING', 'Pending'],
+ ['SUBMITTING', 'Submitting'],
+ ['SUBMISSION_FAILED', 'Submission failed'],
+ ] as const)('should label %s as %s', (status, label) => {
+ render()
+ expect(screen.getByTestId('status')).toHaveTextContent(label)
+ })
+
+ it('should let a caller reword the label', () => {
+ render()
+ expect(screen.getByTestId('status')).toHaveTextContent('In progress')
+ })
+
+ it('should mark terminal states with an icon only when asked', () => {
+ const view = render()
+ expect(screen.getByTestId('status').querySelector('svg')).toBeNull()
+ view.rerender()
+ expect(screen.getByTestId('status').querySelector('svg')).not.toBeNull()
+ view.rerender()
+ expect(screen.getByTestId('status').querySelector('svg')).toBeNull()
+ })
+})
diff --git a/frontend/src/components/ScanStatusBadge.tsx b/frontend/src/components/ScanStatusBadge.tsx
new file mode 100644
index 0000000000..ce703b80b7
--- /dev/null
+++ b/frontend/src/components/ScanStatusBadge.tsx
@@ -0,0 +1,73 @@
+import type { ReactElement } from 'react'
+
+import { Badge } from '@fluentui/react-components'
+import type { BadgeProps } from '@fluentui/react-components'
+import { CheckmarkCircleRegular, DismissCircleRegular, ErrorCircleRegular } from '@fluentui/react-icons'
+
+import type { ScenarioRunState, ScenarioSuiteExecutionEntryState, ScenarioSuiteExecutionStatus } from '@/types'
+
+type ScanStatus = ScenarioRunState | ScenarioSuiteExecutionStatus | ScenarioSuiteExecutionEntryState
+type ScanStatusColor = 'informative' | 'brand' | 'success' | 'danger' | 'warning'
+
+const SCAN_STATUS_COLORS: Record = {
+ PENDING: 'informative',
+ SUBMITTING: 'informative',
+ QUEUING: 'informative',
+ CREATED: 'informative',
+ QUEUED: 'informative',
+ IN_PROGRESS: 'brand',
+ COMPLETED: 'success',
+ SUBMISSION_FAILED: 'danger',
+ FAILED: 'danger',
+ CANCELLED: 'warning',
+}
+
+const SCAN_STATUS_LABELS: Record = {
+ PENDING: 'Pending',
+ SUBMITTING: 'Submitting',
+ QUEUING: 'Queuing',
+ CREATED: 'Created',
+ QUEUED: 'Queued',
+ IN_PROGRESS: 'Executing',
+ COMPLETED: 'Completed',
+ SUBMISSION_FAILED: 'Submission failed',
+ FAILED: 'Failed',
+ CANCELLED: 'Cancelled',
+}
+
+function scanStatusIcon(status: ScanStatus): ReactElement | undefined {
+ if (status === 'COMPLETED') return
+ if (status === 'FAILED' || status === 'SUBMISSION_FAILED') return
+ if (status === 'CANCELLED') return
+ return undefined
+}
+
+interface ScanStatusBadgeProps {
+ readonly status: ScanStatus
+ readonly appearance?: Extract
+ /** Mark terminal states with an icon as well as color. */
+ readonly withIcon?: boolean
+ /** Replaces the standard status label, such as a suite's wording for a running entry. */
+ readonly label?: string
+ readonly testId?: string
+}
+
+/** The lifecycle state of a scan, suite, or suite entry, with one color per state everywhere. */
+export default function ScanStatusBadge({
+ status,
+ appearance = 'tint',
+ withIcon = false,
+ label,
+ testId,
+}: ScanStatusBadgeProps) {
+ return (
+
+ {label ?? SCAN_STATUS_LABELS[status]}
+
+ )
+}
diff --git a/frontend/src/components/ScenarioSuites/ScenarioSuiteExecutionPage.tsx b/frontend/src/components/ScenarioSuites/ScenarioSuiteExecutionPage.tsx
index 8f2121939b..54bf70b5d1 100644
--- a/frontend/src/components/ScenarioSuites/ScenarioSuiteExecutionPage.tsx
+++ b/frontend/src/components/ScenarioSuites/ScenarioSuiteExecutionPage.tsx
@@ -1,7 +1,6 @@
-import { type ReactElement, useState } from 'react'
+import { useState } from 'react'
import {
- Badge,
Button,
Dialog,
DialogActions,
@@ -24,14 +23,13 @@ import {
import {
ArrowLeftRegular,
ArrowSyncRegular,
- CheckmarkCircleRegular,
- DismissCircleRegular,
ErrorCircleRegular,
StopRegular,
} from '@fluentui/react-icons'
import { Link, useParams } from 'react-router'
import { formatTimestamp } from '@/components/AttackResults/attackAttemptFormatting'
+import ScanStatusBadge from '@/components/ScanStatusBadge'
import {
isTerminalSuiteExecutionStatus,
useScenarioSuiteExecution,
@@ -39,40 +37,12 @@ import {
import { scenarioSuitesApi } from '@/services/api'
import { toApiError } from '@/services/errors'
import type {
- ScenarioSuiteExecutionEntryState,
ScenarioSuiteExecutionEntrySummary,
- ScenarioSuiteExecutionStatus,
} from '@/types'
import { routerPathParamValue, scenarioRunRoutePath } from '@/utils/routeParams'
import { useScenarioSuiteExecutionPageStyles } from './ScenarioSuiteExecutionPage.styles'
-const EXECUTION_BADGE_COLORS: Record<
- ScenarioSuiteExecutionStatus,
- 'informative' | 'brand' | 'success' | 'danger' | 'warning'
-> = {
- QUEUING: 'informative',
- IN_PROGRESS: 'brand',
- COMPLETED: 'success',
- FAILED: 'danger',
- CANCELLED: 'warning',
-}
-
-const ENTRY_BADGE_COLORS: Record<
- ScenarioSuiteExecutionEntryState,
- 'informative' | 'brand' | 'success' | 'danger' | 'warning'
-> = {
- PENDING: 'informative',
- SUBMITTING: 'informative',
- SUBMISSION_FAILED: 'danger',
- CREATED: 'informative',
- QUEUED: 'informative',
- IN_PROGRESS: 'brand',
- COMPLETED: 'success',
- FAILED: 'danger',
- CANCELLED: 'warning',
-}
-
const ENTRY_SUBMISSION_FAILURE_MESSAGE = (
'The scenario run could not be created. Check server logs before retrying.'
)
@@ -212,14 +182,13 @@ function ScenarioSuiteExecutionPageContent({
{execution.suite_name}
-
- {formatState(execution.status)}
-
+ withIcon
+ label={formatState(execution.status)}
+ testId="suite-execution-state-badge"
+ />
Suite execution ID: {execution.suite_execution_id}
@@ -312,13 +281,7 @@ function ScenarioSuiteExecutionPageContent({
-
- {formatState(entry.state)}
-
+
{entry.run ? (
@@ -420,21 +383,6 @@ function entryErrorMessage(entry: ScenarioSuiteExecutionEntrySummary): string {
: ENTRY_STATUS_FAILURE_MESSAGE
}
-function statusIcon(
- status: ScenarioSuiteExecutionStatus | ScenarioSuiteExecutionEntryState,
-): ReactElement | undefined {
- if (status === 'COMPLETED') {
- return
- }
- if (status === 'FAILED' || status === 'SUBMISSION_FAILED') {
- return
- }
- if (status === 'CANCELLED') {
- return
- }
- return undefined
-}
-
function formatState(status: string): string {
return status.toLowerCase().replace(/_/g, ' ').replace(
/^\w/,
diff --git a/frontend/src/components/Scenarios/AtomicAttackGroupsSection.tsx b/frontend/src/components/Scenarios/AtomicAttackGroupsSection.tsx
new file mode 100644
index 0000000000..1bc2f186c8
--- /dev/null
+++ b/frontend/src/components/Scenarios/AtomicAttackGroupsSection.tsx
@@ -0,0 +1,177 @@
+import { useMemo, useState, type Ref } from 'react'
+
+import { Button, Text } from '@fluentui/react-components'
+import { ChevronDownRegular, ChevronRightRegular } from '@fluentui/react-icons'
+
+import type { UseScenarioResultHierarchyResult } from '@/hooks/useScenarioResultHierarchy'
+import type { ScenarioHierarchyNode, ScenarioProgressResult, ScenarioProgressSummary, ScenarioRunState } from '@/types'
+
+import AttackExecutionTable from './AttackExecutionTable'
+import { useScenarioRunPageStyles } from './ScenarioRunPage.styles'
+import ScenarioResultHierarchy from './ScenarioResultHierarchy'
+import { hierarchyDisplayGroups } from './scenarioHierarchy'
+
+const MAX_VISIBLE_ATTEMPTS_PER_GROUP = 100
+const AUTO_EXPAND_GROUP_LIMIT = 20
+
+interface AtomicAttackGroupsSectionProps {
+ readonly scenarioResultId: string
+ readonly runStatus: ScenarioRunState
+ readonly summary: ScenarioProgressSummary
+ readonly results: readonly ScenarioProgressResult[]
+ readonly hierarchy: UseScenarioResultHierarchyResult
+ readonly seedObjectives: Map
+ readonly onOpenDetails: (attempt: ScenarioProgressResult | ScenarioHierarchyNode, trigger: HTMLElement) => void
+ /** The section toggle, which receives focus when an opened attack is no longer on the page. */
+ readonly toggleRef: Ref
+}
+
+/** Every display group's progress, expandable to its attack executions. */
+export default function AtomicAttackGroupsSection({
+ scenarioResultId, runStatus, summary, results, hierarchy, seedObjectives, onOpenDetails, toggleRef,
+}: AtomicAttackGroupsSectionProps) {
+ const styles = useScenarioRunPageStyles()
+ const [sectionExpandedChoice, setSectionExpandedChoice] = useState(null)
+ const [expandedGroupChoices, setExpandedGroupChoices] = useState
{formatDuration(result.execution_time_ms)}
diff --git a/frontend/src/components/Scenarios/ScenarioDetail.test.tsx b/frontend/src/components/Scenarios/ScenarioDetail.test.tsx
index 840865200e..89f0f9e409 100644
--- a/frontend/src/components/Scenarios/ScenarioDetail.test.tsx
+++ b/frontend/src/components/Scenarios/ScenarioDetail.test.tsx
@@ -1926,6 +1926,23 @@ describe('ScenarioDetail', () => {
expect(mockStartRun).not.toHaveBeenCalled()
})
+ it('reveals a recorded technique identity with the shared identity JSON', async () => {
+ const user = userEvent.setup()
+ const identifier = { class_name: 'CrescendoAttack', class_module: 'pyrit.executor.attack', hash: 'crescendo-hash' }
+ mockGetScenario.mockResolvedValue(makeScenario({ technique_summaries: [
+ { name: 'default_technique', description: null, tags: ['default'] },
+ { name: 'crescendo', description: null, tags: ['multi_turn'], identifier },
+ ] }))
+ renderDetail('/scanner/launch/foundry.red_team_agent')
+ await screen.findByTestId('scenario-target-select')
+
+ await user.click(screen.getByRole('button', { name: 'Identity for crescendo' }))
+
+ const region = screen.getByRole('region', { name: 'Identity for crescendo' })
+ expect(JSON.parse(region.textContent ?? '')).toEqual(identifier)
+ expect(region.scrollIntoView).toHaveBeenCalled()
+ })
+
it('includes the baseline when a shared tag selects or clears its members', async () => {
const user = userEvent.setup()
renderDetail('/scanner/launch/foundry.red_team_agent')
diff --git a/frontend/src/components/Scenarios/ScenarioRunConfigurationSection.tsx b/frontend/src/components/Scenarios/ScenarioRunConfigurationSection.tsx
new file mode 100644
index 0000000000..462fb460d6
--- /dev/null
+++ b/frontend/src/components/Scenarios/ScenarioRunConfigurationSection.tsx
@@ -0,0 +1,27 @@
+import { useState } from 'react'
+
+import { Table, TableBody, Text } from '@fluentui/react-components'
+
+import RegistryIdentityRow from '@/components/Registry/RegistryIdentityRow'
+import type { ScenarioProgressHeader } from '@/types'
+
+import { useScenarioRunPageStyles } from './ScenarioRunPage.styles'
+
+interface ScenarioRunConfigurationSectionProps {
+ readonly run: ScenarioProgressHeader
+}
+
+/** The saved scenario identity the scan ran with. */
+export default function ScenarioRunConfigurationSection({ run }: ScenarioRunConfigurationSectionProps) {
+ const styles = useScenarioRunPageStyles()
+ const [identityExpanded, setIdentityExpanded] = useState(false)
+ return (
+
+ Scenario configuration
+ {run.scenario_identity ?
+ { setIdentityExpanded((value: boolean) => !value) }} />
+
: No saved scenario identity.}
+
+ )
+}
diff --git a/frontend/src/components/Scenarios/ScenarioRunConversations.tsx b/frontend/src/components/Scenarios/ScenarioRunConversations.tsx
new file mode 100644
index 0000000000..9f282d0d12
--- /dev/null
+++ b/frontend/src/components/Scenarios/ScenarioRunConversations.tsx
@@ -0,0 +1,64 @@
+import { useMemo } from 'react'
+
+import { Button, Text } from '@fluentui/react-components'
+import { Link } from 'react-router'
+
+import type { ScenarioActiveConversation, ScenarioProgressResult } from '@/types'
+import { scenarioConversationRoutePath } from '@/utils/routeParams'
+
+import { useScenarioRunPageStyles } from './ScenarioRunPage.styles'
+
+interface ScenarioRunConversationsProps {
+ readonly scenarioResultId: string
+ readonly results: readonly ScenarioProgressResult[]
+ readonly activeConversations: readonly ScenarioActiveConversation[]
+ readonly recordedConversations: readonly ScenarioActiveConversation[]
+ readonly onOpenAttempt: (conversation: ScenarioActiveConversation, trigger: HTMLElement) => void
+}
+
+/** Conversations still executing, and interrupted ones saved without a completed result. */
+export default function ScenarioRunConversations({
+ scenarioResultId, results, activeConversations, recordedConversations, onOpenAttempt,
+}: ScenarioRunConversationsProps) {
+ const styles = useScenarioRunPageStyles()
+ const savedConversations = useMemo(() => {
+ const completedConversationIds = new Set(results.flatMap((attempt) => [
+ attempt.conversation_id,
+ ...(attempt.related_conversation_ids ?? []),
+ ]))
+ const recorded = new Map()
+ for (const conversation of recordedConversations) {
+ if (!completedConversationIds.has(conversation.conversation_id)) {
+ recorded.set(`${conversation.attack_result_id}:${conversation.conversation_id}`, conversation)
+ }
+ }
+ return [...recorded.values()]
+ }, [results, recordedConversations])
+
+ return (
+ <>
+ {activeConversations.length > 0 &&
+ Executing
+ {activeConversations.map((conversation) => (
+
+
+ {conversation.objective}
+ Watch live conversation
+
+ ))}
+ }
+ {savedConversations.length > 0 &&
+ Saved conversations
+ {savedConversations.map((conversation) => (
+
+ {conversation.atomic_attack_name}
+ {conversation.objective}
+ Open saved conversation
+
+ ))}
+ }
+ >
+ )
+}
diff --git a/frontend/src/components/Scenarios/ScenarioRunDialogs.tsx b/frontend/src/components/Scenarios/ScenarioRunDialogs.tsx
index 7ddf7848e9..2332850cdd 100644
--- a/frontend/src/components/Scenarios/ScenarioRunDialogs.tsx
+++ b/frontend/src/components/Scenarios/ScenarioRunDialogs.tsx
@@ -7,6 +7,8 @@ import {
DialogContent,
DialogSurface,
DialogTitle,
+ MessageBar,
+ MessageBarBody,
Text,
mergeClasses,
} from '@fluentui/react-components'
@@ -102,3 +104,25 @@ export function ObjectiveDetailsDialog({ objective, onClose, attempts, onOpenAtt
)
}
+
+interface CancelScanDialogProps {
+ readonly open: boolean
+ readonly cancelling: boolean
+ readonly error: string | null
+ readonly onOpenChange: (open: boolean) => void
+ readonly onConfirm: () => void
+}
+
+export function CancelScanDialog({ open, cancelling, error, onOpenChange, onConfirm }: CancelScanDialogProps) {
+ const styles = useScenarioRunPageStyles()
+ return (
+
+ )
+}
diff --git a/frontend/src/components/Scenarios/ScenarioRunHeader.tsx b/frontend/src/components/Scenarios/ScenarioRunHeader.tsx
new file mode 100644
index 0000000000..d8d66ac34c
--- /dev/null
+++ b/frontend/src/components/Scenarios/ScenarioRunHeader.tsx
@@ -0,0 +1,56 @@
+import type { Ref } from 'react'
+
+import { Button, MessageBar, MessageBarBody, Text } from '@fluentui/react-components'
+import { ArrowClockwiseRegular, PlayRegular, StopRegular } from '@fluentui/react-icons'
+import { Link } from 'react-router'
+
+import ScanStatusBadge from '@/components/ScanStatusBadge'
+import type { ScenarioRunControls } from '@/hooks/useScenarioRunControls'
+import type { ScenarioProgressHeader } from '@/types'
+
+import { useScenarioRunPageStyles } from './ScenarioRunPage.styles'
+
+interface ScenarioRunHeaderProps {
+ readonly run: ScenarioProgressHeader
+ readonly active: boolean
+ readonly controls: ScenarioRunControls
+ /** Focus target for returning to the run overview. */
+ readonly headerRef: Ref
+}
+
+/** The scan's title, state, and run controls, with any control failure below them. */
+export default function ScenarioRunHeader({ run, active, controls, headerRef }: ScenarioRunHeaderProps) {
+ const styles = useScenarioRunPageStyles()
+ const resumable = run.status === 'FAILED' || run.status === 'CANCELLED'
+ return (
+ <>
+
+
+
+ {run.scenario_registry_name ?? run.scenario_name}
+
+
+ {run.description &&
{run.description}}
+
+
+ {active && View queue}
+ } disabled={controls.rerunPending} onClick={controls.rerun}>
+ {controls.rerunPending ? 'Loading...' : 'Rerun'}
+
+ {active ? (
+ } className={styles.touchTarget} onClick={() => { controls.setCancelOpen(true) }}>
+ Cancel
+
+ ) : resumable && (
+ } disabled={controls.resumePending} onClick={controls.resume}>
+ Resume
+
+ )}
+
+
+ {controls.controlError &&
+ {controls.controlError}
+ }
+ >
+ )
+}
diff --git a/frontend/src/components/Scenarios/ScenarioRunNotices.tsx b/frontend/src/components/Scenarios/ScenarioRunNotices.tsx
new file mode 100644
index 0000000000..11c24fe331
--- /dev/null
+++ b/frontend/src/components/Scenarios/ScenarioRunNotices.tsx
@@ -0,0 +1,69 @@
+import { Accordion, AccordionHeader, AccordionItem, AccordionPanel, Button, MessageBar, MessageBarBody, Text } from '@fluentui/react-components'
+
+import type { ScenarioOverloadSummary, ScenarioProgressHeader, ScenarioProgressResult, ScenarioProgressSummary } from '@/types'
+import type { ScenarioRunProgressState } from '@/utils/scenarioRunProgress'
+
+import { useScenarioRunPageStyles } from './ScenarioRunPage.styles'
+
+interface ScenarioRunNoticesProps {
+ readonly run: ScenarioProgressHeader
+ readonly summary: ScenarioProgressSummary
+ readonly state: ScenarioRunProgressState
+ readonly onRetry: () => void
+ readonly onOpenAttempt: (attempt: ScenarioProgressResult, trigger: HTMLElement) => void
+}
+
+/** Run failures, paused live updates, and conditions that limit how far the totals can be trusted. */
+export default function ScenarioRunNotices({ run, summary, state, onRetry, onOpenAttempt }: ScenarioRunNoticesProps) {
+ const styles = useScenarioRunPageStyles()
+ const overall = summary.overall
+ const firstError = state.results.find((attempt) => attempt.outcome === 'error')
+ const unmatched = summary.unattributed_attempts ?? 0
+ const overloaded = state.overloadSummaries.length > 0
+ const notices: string[] = []
+ if (overloaded) notices.push('Service overload detected.')
+ if (unmatched > 0) notices.push(`${unmatched} execution${unmatched === 1 ? '' : 's'} could not be matched to the saved plan.`)
+ if (!state.planComplete) notices.push('The saved execution plan is incomplete; some totals cannot be verified.')
+ const runIntent = run.status === 'FAILED' ? 'error' : run.status === 'CANCELLED' && overall.errors === 0 ? 'info' : 'warning'
+
+ return (
+ <>
+ {(run.status === 'FAILED' || run.error || overall.errors > 0) &&
+ {run.status === 'FAILED' ? 'Scan failed. Resume to continue remaining attacks.'
+ : run.status === 'CANCELLED' ? 'Scan cancelled.'
+ : `${overall.errors} attack${overall.errors === 1 ? '' : 's'} ended with an error.`}
+ {firstError && }
+ {run.error &&
+ Error details
+ {run.error_type ? `${run.error_type}: ` : ''}{run.error}
+ }
+
+ }
+ {state.stale &&
+ Live updates paused. {state.error}
+ }
+ {notices.length > 0 && 0 ? 'warning' : 'info'}>
+
+ {notices.join(' ')}
+ {overloaded && }
+
+ }
+ >
+ )
+}
+
+function OverloadDetails({ overloads }: { readonly overloads: readonly ScenarioOverloadSummary[] }) {
+ return (
+
+ Overload details
+ {overloads.map((overload) => (
+
+ {overload.component_role.replace(/_/g, ' ')}: {overload.count}
+ {overload.status_codes.length > 0 ? ` (HTTP ${overload.status_codes.join(', ')})` : ''}
+
+ ))}
+
+ )
+}
diff --git a/frontend/src/components/Scenarios/ScenarioRunObjectivesSection.tsx b/frontend/src/components/Scenarios/ScenarioRunObjectivesSection.tsx
new file mode 100644
index 0000000000..9a6ec7dbff
--- /dev/null
+++ b/frontend/src/components/Scenarios/ScenarioRunObjectivesSection.tsx
@@ -0,0 +1,76 @@
+import { useMemo } from 'react'
+
+import { Button, Table, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow, Text } from '@fluentui/react-components'
+
+import { formatProgressSuccess, objectivePreview } from '@/components/AttackResults/attackAttemptFormatting'
+import type {
+ ScenarioActiveConversation,
+ ScenarioComponentIdentity,
+ ScenarioProgressResult,
+ ScenarioProgressSummary,
+ ScenarioRunPlan,
+ ScenarioRunPlanSeedGroup,
+} from '@/types'
+
+import { useScenarioRunPageStyles } from './ScenarioRunPage.styles'
+
+interface ScenarioRunObjectivesSectionProps {
+ readonly summary: ScenarioProgressSummary
+ readonly plan: ScenarioRunPlan | null
+ readonly results: readonly ScenarioProgressResult[]
+ readonly activeConversations: readonly ScenarioActiveConversation[]
+ readonly onOpenObjective: (objective: ScenarioRunPlanSeedGroup, trigger: HTMLElement) => void
+}
+
+function seedDatasetNames(identities: ScenarioComponentIdentity[]): string {
+ return [...new Set(identities.flatMap((identity: ScenarioComponentIdentity) =>
+ typeof identity.parameters.dataset_name === 'string' ? [identity.parameters.dataset_name] : []))].join(', ')
+}
+
+/** Each objective's dataset, attack success, and completion, opening its seed prompts and attacks. */
+export default function ScenarioRunObjectivesSection({
+ summary, plan, results, activeConversations, onOpenObjective,
+}: ScenarioRunObjectivesSectionProps) {
+ const styles = useScenarioRunPageStyles()
+ const planSeeds = useMemo(
+ () => new Map(plan?.seed_groups.map((seed) => [seed.id, seed]) ?? []),
+ [plan],
+ )
+ const datasetsBySeed = useMemo(() => {
+ const identities = new Map()
+ for (const attempt of [...results, ...activeConversations]) {
+ if (!attempt.seed_group_id) continue
+ const seeds = identities.get(attempt.seed_group_id) ?? []
+ seeds.push(...(attempt.seed_identifiers ?? []))
+ identities.set(attempt.seed_group_id, seeds)
+ }
+ return new Map([...identities].map(([id, seeds]) => [id, seedDatasetNames(seeds)]))
+ }, [results, activeConversations])
+
+ return (
+
+ Datasets & objectives
+
+
+ DatasetObjective
+ Attack successCompleted
+
+ {summary.seed_groups.map((seed) => (
+
+ {datasetsBySeed.get(seed.id) || 'Not recorded'}
+
+ {formatProgressSuccess(seed)}
+ {seed.completed}{seed.planned === null ? '' : ` / ${seed.planned}`}
+
+ ))}
+
+
+ )
+}
diff --git a/frontend/src/components/Scenarios/ScenarioRunOverview.tsx b/frontend/src/components/Scenarios/ScenarioRunOverview.tsx
new file mode 100644
index 0000000000..945d4eed6d
--- /dev/null
+++ b/frontend/src/components/Scenarios/ScenarioRunOverview.tsx
@@ -0,0 +1,48 @@
+import { Badge, Text } from '@fluentui/react-components'
+import { Link } from 'react-router'
+
+import { formatProgressSuccess } from '@/components/AttackResults/attackAttemptFormatting'
+import type { ScenarioProgressCounts, ScenarioProgressHeader } from '@/types'
+
+import { useScenarioRunPageStyles } from './ScenarioRunPage.styles'
+import ScenarioRunTiming from './ScenarioRunTiming'
+
+interface ScenarioRunOverviewProps {
+ readonly run: ScenarioProgressHeader
+ readonly overall: ScenarioProgressCounts
+}
+
+/** Operation, target, timing, success, errors, and labels for the whole scan. */
+export default function ScenarioRunOverview({ run, overall }: ScenarioRunOverviewProps) {
+ const styles = useScenarioRunPageStyles()
+ const outcomes = overall.outcomes
+ return (
+
+ Operation
+ {run.operation_id
+ ? {run.operation_name ?? 'Operation'}
+ : Unassigned}
+
+ Target
+ {run.target?.model_name ?? run.target?.target_type ?? 'Not recorded'}
+
+ Timing
+
+
+ Attack success
+ {formatProgressSuccess(overall)}
+
+ {outcomes ? `${outcomes.successes} / ${outcomes.total_decided} decided` : `${overall.succeeded} / ${overall.completed} complete`}
+
+
+ Errors / retries
+ {overall.errors} / {overall.retries}
+
+ Labels
+
{Object.entries(run.labels ?? {}).map(([key, value]) => (
+ {key}: {value}
+ ))}
+
+
+ )
+}
diff --git a/frontend/src/components/Scenarios/ScenarioRunPage.tsx b/frontend/src/components/Scenarios/ScenarioRunPage.tsx
index 7ace137a32..c928860c89 100644
--- a/frontend/src/components/Scenarios/ScenarioRunPage.tsx
+++ b/frontend/src/components/Scenarios/ScenarioRunPage.tsx
@@ -1,55 +1,33 @@
-import { useEffect, useMemo, useRef, useState } from 'react'
+import { useMemo, useRef, useState } from 'react'
import {
- Accordion, AccordionHeader, AccordionItem, AccordionPanel, Badge, Button,
- Dialog, DialogActions, DialogBody, DialogContent, DialogSurface, DialogTitle,
- MessageBar, MessageBarBody, ProgressBar, Spinner, Table, TableBody, TableCell,
- TableHeader, TableHeaderCell, TableRow, Text,
+ Accordion, AccordionHeader, AccordionItem, AccordionPanel, Button, MessageBar, MessageBarBody, Spinner, Text,
} from '@fluentui/react-components'
-import { ArrowLeftRegular, ArrowClockwiseRegular, ChevronDownRegular, ChevronRightRegular, DismissRegular, PlayRegular, StopRegular } from '@fluentui/react-icons'
-import { Link, useLocation, useNavigate, useParams } from 'react-router'
+import { ArrowLeftRegular } from '@fluentui/react-icons'
+import { Link, useLocation, useParams } from 'react-router'
-import AttackAttemptDetails from '@/components/AttackResults/AttackAttemptDetails'
-import { formatProgressSuccess, objectivePreview } from '@/components/AttackResults/attackAttemptFormatting'
-import RegistryIdentityRow from '@/components/Registry/RegistryIdentityRow'
-import { suiteExecutionRoutePath } from '@/components/ScenarioSuites/suiteRoutes'
-import { useScenarioRunProgress } from '@/hooks/useScenarioRunProgress'
-import { useScenarioResultHierarchy } from '@/hooks/useScenarioResultHierarchy'
-import { useScenarioRunResume } from '@/hooks/useScenarioRunResume'
import { useScenarioQueue } from '@/hooks/useScenarioQueue'
-import { scenariosApi } from '@/services/api'
-import { toApiError } from '@/services/errors'
-import type {
- ScenarioActiveConversation,
- ScenarioComponentIdentity,
- ScenarioHierarchyNode,
- ScenarioProgressResult,
- ScenarioRunPlanSeedGroup,
- ScenarioRunState,
-} from '@/types'
-import {
- attackConversationRoutePath,
- routerPathParamValue,
- scenarioConversationRoutePath,
- scenarioRerunRoutePath,
- scenarioRunAttackRoutePath,
- scenarioRunRoutePath,
-} from '@/utils/routeParams'
+import { useScenarioResultHierarchy } from '@/hooks/useScenarioResultHierarchy'
+import { useScenarioRunAttackDetails } from '@/hooks/useScenarioRunAttackDetails'
+import { useScenarioRunControls } from '@/hooks/useScenarioRunControls'
+import { useScenarioRunProgress } from '@/hooks/useScenarioRunProgress'
+import type { ScenarioProgressResult, ScenarioRunPlanSeedGroup } from '@/types'
+import { routerPathParamValue } from '@/utils/routeParams'
import { isTerminalRunState } from '@/utils/scenarioRunProgress'
-import AttackExecutionTable from './AttackExecutionTable'
+import AtomicAttackGroupsSection from './AtomicAttackGroupsSection'
+import AttackDetailsDialog from './AttackDetailsDialog'
import PairedOutcomes from './PairedOutcomes'
-import { ObjectiveDetailsDialog } from './ScenarioRunDialogs'
+import ScenarioRunConfigurationSection from './ScenarioRunConfigurationSection'
+import ScenarioRunConversations from './ScenarioRunConversations'
+import { CancelScanDialog, ObjectiveDetailsDialog } from './ScenarioRunDialogs'
+import ScenarioRunHeader from './ScenarioRunHeader'
+import ScenarioRunNotices from './ScenarioRunNotices'
+import ScenarioRunObjectivesSection from './ScenarioRunObjectivesSection'
+import ScenarioRunOverview from './ScenarioRunOverview'
import { useScenarioRunPageStyles } from './ScenarioRunPage.styles'
-import ScenarioRunTiming from './ScenarioRunTiming'
-import ScenarioResultHierarchy from './ScenarioResultHierarchy'
-import { hierarchyDisplayGroups } from './scenarioHierarchy'
-
-const MAX_VISIBLE_ATTEMPTS_PER_GROUP = 100
-const AUTO_EXPAND_GROUP_LIMIT = 20
-const STATE_COLORS: Record = {
- CREATED: 'informative', QUEUED: 'informative', IN_PROGRESS: 'brand', COMPLETED: 'success', FAILED: 'danger', CANCELLED: 'warning',
-}
+import ScenarioRunProgress from './ScenarioRunProgress'
+import { scenarioRunBackLink, type ScenarioRunNavigationState } from './scenarioRunNavigation'
export default function ScenarioRunPage() {
const { scenarioResultId, attackResultId } = useParams<{ scenarioResultId: string; attackResultId?: string }>()
@@ -62,74 +40,32 @@ interface ScenarioRunContentProps {
readonly attackResultId: string | null
}
-function seedDatasetNames(identities: ScenarioComponentIdentity[]): string {
- return [...new Set(identities.flatMap((identity: ScenarioComponentIdentity) =>
- typeof identity.parameters.dataset_name === 'string' ? [identity.parameters.dataset_name] : []))].join(', ')
-}
-
function ScenarioRunContent({ scenarioResultId, attackResultId }: ScenarioRunContentProps) {
const styles = useScenarioRunPageStyles()
- const navigate = useNavigate()
const location = useLocation()
- const navigationState = location.state as {
- fromScenarioHistory?: boolean
- scenarioHistorySearch?: string
- scenarioName?: string
- suiteExecutionId?: string
- suiteName?: string
- } | null
- const backPath = navigationState?.suiteExecutionId
- ? suiteExecutionRoutePath(navigationState.suiteExecutionId)
- : navigationState?.fromScenarioHistory || navigationState?.scenarioHistorySearch
- ? `/scanner/history${navigationState.scenarioHistorySearch ?? ''}`
- : navigationState?.scenarioName
- ? `/scanner/launch/${encodeURIComponent(navigationState.scenarioName)}`
- : '/scanner/launch'
- const backLabel = navigationState?.suiteExecutionId
- ? `Back to ${navigationState.suiteName ?? 'suite execution'}`
- : navigationState?.fromScenarioHistory || navigationState?.scenarioHistorySearch
- ? 'Back to scans'
- : navigationState?.scenarioName ? 'Back to scenario' : 'Back to scanners'
+ const backLink = scenarioRunBackLink(location.state as ScenarioRunNavigationState | null)
const { state, retry, applyRunSummary } = useScenarioRunProgress(scenarioResultId)
const hierarchy = useScenarioResultHierarchy(scenarioResultId, state.run !== null && state.summary !== null)
const queue = useScenarioQueue()
- const [identityExpanded, setIdentityExpanded] = useState(false)
- const [pairedExpanded, setPairedExpanded] = useState(false)
- const [objective, setObjective] = useState(null)
- const [cancelOpen, setCancelOpen] = useState(false)
- const [cancelling, setCancelling] = useState(false)
- const [cancelError, setCancelError] = useState(null)
- const [rerunPending, setRerunPending] = useState(false)
- const [rerunError, setRerunError] = useState(null)
- const rerunRequest = useRef(null)
- const detailsTrigger = useRef(null)
+ const controls = useScenarioRunControls({
+ scenarioResultId, applyRunSummary, retry, refreshQueue: queue.retry, runError: state.run?.error,
+ })
+ const overviewRef = useRef(null)
+ const groupsToggleRef = useRef(null)
const objectiveTrigger = useRef(null)
- const overviewTarget = useRef(null)
- const active = Boolean(state.run && !isTerminalRunState(state.run.status))
- const resume = useScenarioRunResume({
- onResumed: applyRunSummary,
- onRefresh: (succeeded: boolean): void => {
- queue.retry()
- if (!succeeded) retry()
- },
+ const [objective, setObjective] = useState(null)
+ const [pairedExpanded, setPairedExpanded] = useState(false)
+ const attackDetails = useScenarioRunAttackDetails({
+ scenarioResultId,
+ attackResultId,
+ results: state.results,
+ activeConversations: state.activeConversations,
+ fallbackFocus: () => groupsToggleRef.current ?? overviewRef.current,
})
- const [atomicGroupsExpandedChoice, setAtomicGroupsExpandedChoice] = useState(null)
- const [expandedGroupChoices, setExpandedGroupChoices] = useState