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 ( + + ) +} + +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 ? : {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>(new Map()) + // Attempts are grouped by atomic group id, then truncated per group, so expanding an + // older group still shows its own executions rather than an empty table. + const attemptsByGroupId = useMemo(() => { + const groupedAttempts = new Map() + for (let index = results.length - 1; index >= 0; index -= 1) { + const attempt = results[index] + if (!attempt) continue + const existing = groupedAttempts.get(attempt.atomic_group_id) + if (existing) { + existing.push(attempt) + } else { + groupedAttempts.set(attempt.atomic_group_id, [attempt]) + } + } + return groupedAttempts + }, [results]) + + const displayGroups = hierarchyDisplayGroups(summary.display_groups ?? summary.techniques, hierarchy.roots.groups) + const producerCounts = summary.overall.producer_counts + const executionCount = producerCounts + ? producerCounts.target_facing.attempts + producerCounts.orchestration.attempts + producerCounts.unknown.attempts + : results.length + const defaultExpanded = displayGroups.length <= AUTO_EXPAND_GROUP_LIMIT + const sectionExpanded = sectionExpandedChoice ?? defaultExpanded + + const toggleDisplayGroup = (groupId: string): void => { + setExpandedGroupChoices((current) => new Map(current).set(groupId, !(current.get(groupId) ?? defaultExpanded))) + } + + return ( +
+
+
+ + Atomic attack groups + + +
+ + {`${displayGroups.length.toLocaleString()} group${displayGroups.length === 1 ? '' : 's'}, ${executionCount.toLocaleString()} ${producerCounts ? '' : 'loaded '}execution${executionCount === 1 ? '' : 's'}`} + +
+ +
+ ) +} + +function DisplayGroupMetric({ label, value }: { readonly label: string; readonly value: string }) { + return ( +
+ {label} + {value} +
+ ) +} + +function formatCompletion(completed: number, planned: number | null): string { + return planned === null ? `${completed}/total unavailable` : `${completed}/${planned}` +} + +function formatSuccess(succeeded: number, completed: number, percentage: number | null): string { + if (percentage === null) return completed === 0 ? 'Unavailable' : `${succeeded}/${completed}` + return `${percentage}%` +} diff --git a/frontend/src/components/Scenarios/AttackDetailsDialog.tsx b/frontend/src/components/Scenarios/AttackDetailsDialog.tsx new file mode 100644 index 0000000000..4eee2a6b5a --- /dev/null +++ b/frontend/src/components/Scenarios/AttackDetailsDialog.tsx @@ -0,0 +1,88 @@ +import { useMemo } from 'react' + +import { Button, Dialog, DialogActions, DialogBody, DialogContent, DialogSurface, DialogTitle } from '@fluentui/react-components' +import { DismissRegular } from '@fluentui/react-icons' + +import AttackAttemptDetails from '@/components/AttackResults/AttackAttemptDetails' +import type { ScenarioRunAttempt } from '@/hooks/useScenarioRunAttackDetails' +import type { + ScenarioActiveConversation, + ScenarioHierarchyNode, + ScenarioProgressSummary, + ScenarioRunPlan, +} from '@/types' +import { attackConversationRoutePath, scenarioConversationRoutePath } from '@/utils/routeParams' + +import { useScenarioRunPageStyles } from './ScenarioRunPage.styles' + +interface AttackDetailsDialogProps { + readonly scenarioResultId: string + readonly attempt: ScenarioRunAttempt | null + /** The hierarchy node the dialog was opened from, which may carry the objective. */ + readonly hierarchySelection: ScenarioHierarchyNode | null + readonly plan: ScenarioRunPlan | null + readonly summary: ScenarioProgressSummary + readonly activeConversations: readonly ScenarioActiveConversation[] + readonly seedObjectives: Map + /** Router state carried to the conversation, so its back link returns here. */ + readonly conversationState: unknown + readonly onClose: () => void +} + +/** One attack's objective, outcome, technique, scorer, and conversation, opened from anywhere on the run page. */ +export default function AttackDetailsDialog({ + scenarioResultId, attempt, hierarchySelection, plan, summary, activeConversations, seedObjectives, conversationState, onClose, +}: AttackDetailsDialogProps) { + const styles = useScenarioRunPageStyles() + const plannedAtomicGroups = useMemo( + () => new Map(plan?.atomic_groups.map((group) => [group.id, group]) ?? []), + [plan], + ) + const progressAtomicGroups = useMemo( + () => new Map(summary.atomic_groups.map((group) => [group.id, group])), + [summary], + ) + const atomicGroupId = attempt && 'atomic_group_id' in attempt ? attempt.atomic_group_id : null + const active = attempt ? activeConversations.find((conversation) => conversation.attack_result_id === attempt.attack_result_id) : undefined + const objective = attempt + ? (hierarchySelection?.attack_result_id === attempt.attack_result_id ? hierarchySelection.objective : null) ?? + ('objective' in attempt ? attempt.objective : null) ?? + (attempt.seed_group_id ? seedObjectives.get(attempt.seed_group_id) : null) ?? + null + : null + const conversationPath = !attempt ? null + : active ? scenarioConversationRoutePath(scenarioResultId, active.conversation_id) + : attempt.conversation_id ? attackConversationRoutePath(attempt.attack_result_id, attempt.conversation_id, scenarioResultId) + : null + + return ( + { if (!data.open) onClose() }}> + + + } aria-label="Close attack details" onClick={onClose} /> + )}> + {attempt?.atomic_attack_name || 'Attack attempt'} + + {attempt && ( + + + + )} + + + + + + + ) +} diff --git a/frontend/src/components/Scenarios/AttackExecutionTable.tsx b/frontend/src/components/Scenarios/AttackExecutionTable.tsx index 8152b4b8be..bcede3870b 100644 --- a/frontend/src/components/Scenarios/AttackExecutionTable.tsx +++ b/frontend/src/components/Scenarios/AttackExecutionTable.tsx @@ -18,8 +18,6 @@ import { ChevronDownRegular, ChevronRightRegular } from '@fluentui/react-icons' import { Link, useLocation, useNavigate } from 'react-router' import { - formatAttackOutcome, - formatAttackSuccess, formatDuration, formatTimestamp, objectivePreview, @@ -221,7 +219,7 @@ function ResultRows({
{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 ( + { onOpenChange(data.open) }}> + Cancel scan? + Stop remaining attacks. Saved conversations and results stay available. + {error && {error}} + + + + + ) +} 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} + + {active ? ( + + ) : resumable && ( + + )} +
+
+ {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>(new Map()) - const detailsTriggerRef = useRef(null) - const atomicGroupsToggleRef = useRef(null) - const [hierarchySelection, setHierarchySelection] = useState(null) - useEffect(() => () => { - rerunRequest.current?.abort() - rerunRequest.current = null - }, [location.pathname]) const seedObjectives = useMemo( () => new Map(state.plan?.seed_groups.map((seed) => [seed.id, seed.objective]) ?? []), [state.plan], ) - const planSeeds = useMemo( - () => new Map(state.plan?.seed_groups.map((seed) => [seed.id, seed]) ?? []), - [state.plan], - ) const attemptsBySeed = useMemo(() => { const groups = new Map() for (const attempt of state.results) { @@ -139,78 +75,6 @@ function ScenarioRunContent({ scenarioResultId, attackResultId }: ScenarioRunCon } return groups }, [state.results]) - const plannedAtomicGroups = useMemo( - () => new Map(state.plan?.atomic_groups.map((group) => [group.id, group]) ?? []), - [state.plan], - ) - const progressAtomicGroups = useMemo( - () => new Map(state.summary?.atomic_groups.map((group) => [group.id, group]) ?? []), - [state.summary], - ) - // A hierarchy node can be opened before the paged progress feed has delivered it. - const selectedAttempt = useMemo( - () => - state.results.find((attempt) => attempt.attack_result_id === attackResultId) ?? - state.activeConversations.find((conversation) => conversation.attack_result_id === attackResultId) ?? - (hierarchySelection?.attack_result_id === attackResultId ? hierarchySelection : null), - [attackResultId, state.results, state.activeConversations, hierarchySelection], - ) - const selectedAtomicGroupId = selectedAttempt && 'atomic_group_id' in selectedAttempt - ? selectedAttempt.atomic_group_id - : null - const selectedAttemptGroup = selectedAtomicGroupId - ? plannedAtomicGroups.get(selectedAtomicGroupId) - : undefined - // Attempts are grouped by atomic group id, then truncated per group, so expanding an - // older group still shows its own executions rather than an empty table. - const attemptsByGroupId = useMemo(() => { - const groupedAttempts = new Map() - for (let index = state.results.length - 1; index >= 0; index -= 1) { - const attempt = state.results[index] - if (!attempt) continue - const existing = groupedAttempts.get(attempt.atomic_group_id) - if (existing) { - existing.push(attempt) - } else { - groupedAttempts.set(attempt.atomic_group_id, [attempt]) - } - } - return groupedAttempts - }, [state.results]) - const datasetsBySeed = useMemo(() => { - const identities = new Map() - for (const attempt of [...state.results, ...state.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)])) - }, [state.results, state.activeConversations]) - const savedConversations = useMemo(() => { - const completedConversationIds = new Set(state.results.flatMap((attempt) => [ - attempt.conversation_id, - ...(attempt.related_conversation_ids ?? []), - ])) - const recorded = new Map() - for (const conversation of state.recordedConversations) { - if (!completedConversationIds.has(conversation.conversation_id)) { - recorded.set(`${conversation.attack_result_id}:${conversation.conversation_id}`, conversation) - } - } - return [...recorded.values()] - }, [state.results, state.recordedConversations]) - const selectedActive = state.activeConversations.find((conversation) => conversation.attack_result_id === attackResultId) - const runPath = scenarioRunRoutePath(scenarioResultId) - - const closeAttemptDetails = (): void => { - navigate(runPath, { replace: true, state: location.state }) - requestAnimationFrame(() => { - const trigger = detailsTriggerRef.current ?? detailsTrigger.current - const focusTarget = trigger?.isConnected ? trigger : atomicGroupsToggleRef.current ?? overviewTarget.current - focusTarget?.focus() - }) - } const closeObjective = (): void => { setObjective(null) @@ -220,69 +84,11 @@ function ScenarioRunContent({ scenarioResultId, attackResultId }: ScenarioRunCon }) } - const openAttemptDetails = ( - attempt: ScenarioProgressResult | ScenarioHierarchyNode, - trigger: HTMLElement, - ): void => { - detailsTriggerRef.current = trigger - detailsTrigger.current = trigger - setHierarchySelection('result_role' in attempt ? attempt : null) - navigate(scenarioRunAttackRoutePath(scenarioResultId, attempt.attack_result_id), { state: location.state }) - } - - const toggleDisplayGroup = (groupId: string, defaultExpanded: boolean): void => { - setExpandedGroupChoices((current) => new Map(current).set( - groupId, - !(current.get(groupId) ?? defaultExpanded), - )) - } - - function openDetails(attempt: ScenarioProgressResult, trigger: HTMLElement): void { - const focusTarget = objective ? objectiveTrigger.current : trigger - detailsTriggerRef.current = focusTarget - detailsTrigger.current = focusTarget - setObjective(null) - setHierarchySelection(null) - navigate(scenarioRunAttackRoutePath(scenarioResultId, attempt.attack_result_id), { state: location.state }) - } - - async function rerun(): Promise { - rerunRequest.current?.abort() - const controller = new AbortController() - rerunRequest.current = controller - setRerunPending(true) - setRerunError(null) - try { - const request = await scenariosApi.getLaunchConfiguration(scenarioResultId, controller.signal) - if (rerunRequest.current !== controller || controller.signal.aborted) return - navigate(scenarioRerunRoutePath(request.scenario_name, scenarioResultId)) - } catch (error: unknown) { - if (rerunRequest.current !== controller || controller.signal.aborted) return - setRerunError(toApiError(error).detail) - } finally { - if (rerunRequest.current === controller) { - rerunRequest.current = null - setRerunPending(false) - } - } - } - - async function cancel(): Promise { - setCancelling(true) - setCancelError(null) - try { - applyRunSummary(await scenariosApi.cancelRun(scenarioResultId)) - setCancelOpen(false) - } catch (error: unknown) { - setCancelError(toApiError(error).detail) - } finally { - setCancelling(false) - } - } + const backLinkElement = {backLink.label} if (!state.run || !state.summary) return (
- {backLabel} + {backLinkElement} {state.loadStatus === 'loading' ? : ( {state.error ?? 'Scan not found.'} )} @@ -291,246 +97,50 @@ function ScenarioRunContent({ scenarioResultId, attackResultId }: ScenarioRunCon const run = state.run const summary = state.summary - const overall = summary.overall - const outcomes = overall.outcomes - const groups = summary.display_groups ?? summary.techniques - const firstError = state.results.find((attempt) => attempt.outcome === 'error') - const controlError = rerunError ?? resume.error - ?? (resume.executionError !== run.error ? resume.executionError : null) - const notices: string[] = [] - if (state.overloadSummaries.length > 0) notices.push('Service overload detected.') - const unattributedAttempts = summary.unattributed_attempts ?? 0 - const unmatched = unattributedAttempts - 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 objectiveScorer = summary.objective_scorer - const displayGroups = hierarchyDisplayGroups(groups, hierarchy.roots.groups) - const producerCounts = overall.producer_counts - const executionCount = producerCounts - ? producerCounts.target_facing.attempts + producerCounts.orchestration.attempts + producerCounts.unknown.attempts - : state.results.length - const atomicGroupsExpanded = atomicGroupsExpandedChoice ?? displayGroups.length <= AUTO_EXPAND_GROUP_LIMIT + const active = !isTerminalRunState(run.status) + const openObjectiveAttempt = (attempt: ScenarioProgressResult): void => { + attackDetails.open(attempt, objectiveTrigger.current) + setObjective(null) + } + return (
- {backLabel} + {backLinkElement} {!active ? `${run.scenario_registry_name ?? run.scenario_name}: ${run.status.toLowerCase()}.` : ''} -
-
-
- {run.scenario_registry_name ?? run.scenario_name} - - {run.status === 'IN_PROGRESS' ? 'Executing' : run.status.charAt(0) + run.status.slice(1).toLowerCase()} - -
- {run.description && {run.description}} -
-
- {active && View queue} - - {active ? - : (run.status === 'FAILED' || run.status === 'CANCELLED') && } -
-
- {controlError && - {controlError} - } -
-
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})}
-
-
- {(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 || unmatched > 0 ? 'warning' : 'info'}> - - {notices.join(' ')} - {state.overloadSummaries.length > 0 && - Overload details - {state.overloadSummaries.map((overload) => ( - - {overload.component_role.replace(/_/g, ' ')}: {overload.count} - {overload.status_codes.length > 0 ? ` (HTTP ${overload.status_codes.join(', ')})` : ''} - - ))} - } - - } - {active &&
-
- {run.status === 'QUEUED' ? `Queued${run.queue_position ? ` · Position ${run.queue_position}` : ''}` : `${overall.completed}${overall.planned === null ? '' : ` of ${overall.planned}`} attacks complete`} - -
- -
} - {state.activeConversations.length > 0 &&
- Executing - {state.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 -
)} -
} -
-
-
- - Atomic attack groups - - -
- - {`${displayGroups.length.toLocaleString()} group${displayGroups.length === 1 ? '' : 's'}, ${executionCount.toLocaleString()} ${producerCounts ? '' : 'loaded '}execution${executionCount === 1 ? '' : 's'}`} - -
- -
-
- Datasets & objectives -
- DatasetObjectiveAttack successCompleted - {summary.seed_groups.map((seed) => { - const planSeed = planSeeds.get(seed.id) - return - {datasetsBySeed.get(seed.id) || 'Not recorded'} - - {formatProgressSuccess(seed)} - {seed.completed}{seed.planned === null ? '' : ` / ${seed.planned}`} - - })} -
-
+ + + + {active && } + + + { + objectiveTrigger.current = trigger + setObjective(seed) + }} + /> {run.status === 'COMPLETED' && ( { setPairedExpanded(data.openItems.includes('paired')) }}> @@ -547,100 +157,24 @@ function ScenarioRunContent({ scenarioResultId, attackResultId }: ScenarioRunCon )} -
- Scenario configuration - {run.scenario_identity ? - { setIdentityExpanded((value: boolean) => !value) }} /> -
: No saved scenario identity.} -
+
- { if (!cancelling) setCancelOpen(data.open) }}> - Cancel scan? - Stop remaining attacks. Saved conversations and results stay available. - {cancelError && {cancelError}} - - - - - { - if (!data.open) { - closeAttemptDetails() - } - }} - > - - - } - aria-label="Close attack details" - onClick={closeAttemptDetails} - /> - )} - > - {selectedAttempt?.atomic_attack_name || 'Attack attempt'} - - {selectedAttempt && ( - - - - )} - - - - - - + + + onOpenAttempt={openObjectiveAttempt} scenarioResultId={scenarioResultId} />
) } - -function EmptyState({ text }: { readonly text: string }) { - return {text} -} - -function DisplayGroupMetric({ label, value }: { readonly label: string; readonly value: string }) { - return ( -
- {label} - {value} -
- ) -} - -function formatCompletion(completed: number, planned: number | null): string { - return planned === null ? `${completed}/total unavailable` : `${completed}/${planned}` -} - -function formatSuccess(succeeded: number, completed: number, percentage: number | null): string { - if (percentage === null) return completed === 0 ? 'Unavailable' : `${succeeded}/${completed}` - return `${percentage}%` -} diff --git a/frontend/src/components/Scenarios/ScenarioRunProgress.tsx b/frontend/src/components/Scenarios/ScenarioRunProgress.tsx new file mode 100644 index 0000000000..9d7712180d --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioRunProgress.tsx @@ -0,0 +1,28 @@ +import { ProgressBar, Text } from '@fluentui/react-components' + +import type { ScenarioProgressCounts, ScenarioProgressHeader } from '@/types' + +import { useScenarioRunPageStyles } from './ScenarioRunPage.styles' +import ScenarioRunTiming from './ScenarioRunTiming' + +interface ScenarioRunProgressProps { + readonly run: ScenarioProgressHeader + readonly overall: ScenarioProgressCounts +} + +/** Queue position or completed attacks, with elapsed time and ETA, while a scan is active. */ +export default function ScenarioRunProgress({ run, overall }: ScenarioRunProgressProps) { + const styles = useScenarioRunPageStyles() + const status = run.status === 'QUEUED' + ? `Queued${run.queue_position ? ` · Position ${run.queue_position}` : ''}` + : `${overall.completed}${overall.planned === null ? '' : ` of ${overall.planned}`} attacks complete` + return ( +
+
+ {status} + +
+ +
+ ) +} diff --git a/frontend/src/components/Scenarios/ScenarioTechniqueSection.tsx b/frontend/src/components/Scenarios/ScenarioTechniqueSection.tsx index b1f5e30a61..fc23378bb0 100644 --- a/frontend/src/components/Scenarios/ScenarioTechniqueSection.tsx +++ b/frontend/src/components/Scenarios/ScenarioTechniqueSection.tsx @@ -5,7 +5,8 @@ import { ChevronDownRegular, ChevronRightRegular } from '@fluentui/react-icons' import ContextualHelpLabel from '@/components/ContextualHelpLabel' import { HELP_DEFINITIONS } from '@/components/helpDefinitions' -import { useRegistryIdentityRowStyles } from '@/components/Registry/RegistryIdentityRow.styles' +import { IdentityJson } from '@/components/Registry/IdentityDisclosure' +import { useIdentityDisclosureStyles } from '@/components/Registry/IdentityDisclosure.styles' import { useScenarioDetailStyles } from './ScenarioDetail.styles' import { exceedsTechniqueLimit, type SelectableTechnique } from './scenarioLaunchForm' @@ -46,7 +47,7 @@ export default function ScenarioTechniqueSection({ onRemoveToken, }: ScenarioTechniqueSectionProps) { const styles = useScenarioDetailStyles() - const identityStyles = useRegistryIdentityRowStyles() + const identityStyles = useIdentityDisclosureStyles() const identityId = useId() const techniqueLimitDescriptionId = `${identityId}-technique-limit` const baselineUnavailableId = `${identityId}-baseline-unavailable` @@ -187,11 +188,13 @@ export default function ScenarioTechniqueSection({ icon={expanded ? : } aria-label={`Identity for ${technique.name}`} aria-expanded={expanded} aria-controls={regionId} onClick={() => toggleIdentity(technique.name)} /> - {expanded &&
- {technique.identifier ?
{JSON.stringify(technique.identifier, null, 2)}
- : {technique.isBaseline ? 'Baseline identity is available after launch.' : 'Identity was not recorded for this technique.'}} -
} + {expanded && (technique.identifier + ? + :
+ {technique.isBaseline ? 'Baseline identity is available after launch.' : 'Identity was not recorded for this technique.'} +
)}
) })} diff --git a/frontend/src/components/Scenarios/scenarioRunNavigation.test.ts b/frontend/src/components/Scenarios/scenarioRunNavigation.test.ts new file mode 100644 index 0000000000..a0a754959c --- /dev/null +++ b/frontend/src/components/Scenarios/scenarioRunNavigation.test.ts @@ -0,0 +1,22 @@ +import { scenarioRunBackLink } from './scenarioRunNavigation' + +describe('scenarioRunBackLink', () => { + it('returns to the suite execution that opened the run', () => { + expect(scenarioRunBackLink({ suiteExecutionId: 'suite-1', suiteName: 'Nightly', fromScenarioHistory: true })) + .toEqual({ path: '/scanner/history/suites/executions/suite-1', label: 'Back to Nightly' }) + expect(scenarioRunBackLink({ suiteExecutionId: 'suite-1' }).label).toBe('Back to suite execution') + }) + + it('returns to scan history with its filters', () => { + expect(scenarioRunBackLink({ scenarioHistorySearch: '?status=FAILED' })) + .toEqual({ path: '/scanner/history?status=FAILED', label: 'Back to scans' }) + expect(scenarioRunBackLink({ fromScenarioHistory: true })) + .toEqual({ path: '/scanner/history', label: 'Back to scans' }) + }) + + it('returns to the launched scenario, or the scanner catalog', () => { + expect(scenarioRunBackLink({ scenarioName: 'garak/encoding' })) + .toEqual({ path: '/scanner/launch/garak%2Fencoding', label: 'Back to scenario' }) + expect(scenarioRunBackLink(null)).toEqual({ path: '/scanner/launch', label: 'Back to scanners' }) + }) +}) diff --git a/frontend/src/components/Scenarios/scenarioRunNavigation.ts b/frontend/src/components/Scenarios/scenarioRunNavigation.ts new file mode 100644 index 0000000000..ece7af2438 --- /dev/null +++ b/frontend/src/components/Scenarios/scenarioRunNavigation.ts @@ -0,0 +1,32 @@ +import { suiteExecutionRoutePath } from '@/components/ScenarioSuites/suiteRoutes' + +/** Where the run page was opened from, carried in router location state. */ +export interface ScenarioRunNavigationState { + fromScenarioHistory?: boolean + scenarioHistorySearch?: string + scenarioName?: string + suiteExecutionId?: string + suiteName?: string +} + +interface ScenarioRunBackLink { + readonly path: string + readonly label: string +} + +/** Return to the suite, history filters, or scanner that opened the run. */ +export function scenarioRunBackLink(state: ScenarioRunNavigationState | null): ScenarioRunBackLink { + if (state?.suiteExecutionId) { + return { + path: suiteExecutionRoutePath(state.suiteExecutionId), + label: `Back to ${state.suiteName ?? 'suite execution'}`, + } + } + if (state?.fromScenarioHistory || state?.scenarioHistorySearch) { + return { path: `/scanner/history${state.scenarioHistorySearch ?? ''}`, label: 'Back to scans' } + } + if (state?.scenarioName) { + return { path: `/scanner/launch/${encodeURIComponent(state.scenarioName)}`, label: 'Back to scenario' } + } + return { path: '/scanner/launch', label: 'Back to scanners' } +} diff --git a/frontend/src/hooks/useScenarioRunAttackDetails.ts b/frontend/src/hooks/useScenarioRunAttackDetails.ts new file mode 100644 index 0000000000..481f4f50a9 --- /dev/null +++ b/frontend/src/hooks/useScenarioRunAttackDetails.ts @@ -0,0 +1,66 @@ +import { useMemo, useRef, useState } from 'react' + +import { useLocation, useNavigate } from 'react-router' + +import type { ScenarioActiveConversation, ScenarioHierarchyNode, ScenarioProgressResult } from '@/types' +import { scenarioRunAttackRoutePath, scenarioRunRoutePath } from '@/utils/routeParams' + +export type ScenarioRunAttempt = ScenarioProgressResult | ScenarioHierarchyNode | ScenarioActiveConversation + +interface UseScenarioRunAttackDetailsOptions { + readonly scenarioResultId: string + /** The attack named by the route, if any. */ + readonly attackResultId: string | null + readonly results: readonly ScenarioProgressResult[] + readonly activeConversations: readonly ScenarioActiveConversation[] + /** Receives focus on close when the control that opened the details is gone. */ + readonly fallbackFocus: () => HTMLElement | null +} + +export interface ScenarioRunAttackDetails { + readonly selectedAttempt: ScenarioRunAttempt | null + /** The hierarchy node the details were opened from, which may carry the objective. */ + readonly hierarchySelection: ScenarioHierarchyNode | null + readonly open: (attempt: ScenarioRunAttempt, trigger: HTMLElement | null) => void + readonly close: () => void +} + +/** The attack shown in the run's details dialog, addressed by route so it survives reloads and back navigation. */ +export function useScenarioRunAttackDetails({ + scenarioResultId, + attackResultId, + results, + activeConversations, + fallbackFocus, +}: UseScenarioRunAttackDetailsOptions): ScenarioRunAttackDetails { + const navigate = useNavigate() + const location = useLocation() + const trigger = useRef(null) + const [hierarchySelection, setHierarchySelection] = useState(null) + + // A hierarchy node can be opened before the paged progress feed has delivered it. + const selectedAttempt = useMemo( + () => + results.find((attempt) => attempt.attack_result_id === attackResultId) ?? + activeConversations.find((conversation) => conversation.attack_result_id === attackResultId) ?? + (hierarchySelection?.attack_result_id === attackResultId ? hierarchySelection : null), + [attackResultId, results, activeConversations, hierarchySelection], + ) + + return { + selectedAttempt, + hierarchySelection, + open: (attempt: ScenarioRunAttempt, opener: HTMLElement | null): void => { + trigger.current = opener + setHierarchySelection('result_role' in attempt ? attempt : null) + navigate(scenarioRunAttackRoutePath(scenarioResultId, attempt.attack_result_id), { state: location.state }) + }, + close: (): void => { + navigate(scenarioRunRoutePath(scenarioResultId), { replace: true, state: location.state }) + requestAnimationFrame(() => { + const focusTarget = trigger.current?.isConnected ? trigger.current : fallbackFocus() + focusTarget?.focus() + }) + }, + } +} diff --git a/frontend/src/hooks/useScenarioRunControls.ts b/frontend/src/hooks/useScenarioRunControls.ts new file mode 100644 index 0000000000..4dc257c21c --- /dev/null +++ b/frontend/src/hooks/useScenarioRunControls.ts @@ -0,0 +1,118 @@ +import { useEffect, useRef, useState } from 'react' + +import { useLocation, useNavigate } from 'react-router' + +import { useScenarioRunResume } from '@/hooks/useScenarioRunResume' +import { scenariosApi } from '@/services/api' +import { toApiError } from '@/services/errors' +import type { ScenarioRunSummary } from '@/types' +import { scenarioRerunRoutePath } from '@/utils/routeParams' + +interface UseScenarioRunControlsOptions { + readonly scenarioResultId: string + readonly applyRunSummary: (run: ScenarioRunSummary) => void + /** Reload run progress after a resume that did not start. */ + readonly retry: () => void + readonly refreshQueue: () => void + /** The run's own error, which the page already shows with the run. */ + readonly runError?: string | null +} + +export interface ScenarioRunControls { + readonly rerunPending: boolean + readonly rerun: () => void + readonly cancelOpen: boolean + readonly setCancelOpen: (open: boolean) => void + readonly cancelling: boolean + readonly cancelError: string | null + readonly cancel: () => void + readonly resumePending: boolean + readonly resume: () => void + /** The rerun or resume failure to show beside the controls. */ + readonly controlError: string | null +} + +/** Rerun, cancel, and resume one scan, keeping each request's pending state and errors. */ +export function useScenarioRunControls({ + scenarioResultId, + applyRunSummary, + retry, + refreshQueue, + runError, +}: UseScenarioRunControlsOptions): ScenarioRunControls { + const navigate = useNavigate() + const location = useLocation() + 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 resumeRequest = useScenarioRunResume({ + onResumed: applyRunSummary, + onRefresh: (succeeded: boolean): void => { + refreshQueue() + if (!succeeded) retry() + }, + }) + + // Leaving the run, or opening an attempt on it, abandons a rerun still loading its configuration. + useEffect(() => () => { + rerunRequest.current?.abort() + rerunRequest.current = null + }, [location.pathname]) + + async function requestRerun(): Promise { + rerunRequest.current?.abort() + const controller = new AbortController() + rerunRequest.current = controller + setRerunPending(true) + setRerunError(null) + try { + const request = await scenariosApi.getLaunchConfiguration(scenarioResultId, controller.signal) + if (rerunRequest.current !== controller || controller.signal.aborted) return + navigate(scenarioRerunRoutePath(request.scenario_name, scenarioResultId)) + } catch (error: unknown) { + if (rerunRequest.current !== controller || controller.signal.aborted) return + setRerunError(toApiError(error).detail) + } finally { + if (rerunRequest.current === controller) { + rerunRequest.current = null + setRerunPending(false) + } + } + } + + async function requestCancel(): Promise { + setCancelling(true) + setCancelError(null) + try { + applyRunSummary(await scenariosApi.cancelRun(scenarioResultId)) + setCancelOpen(false) + } catch (error: unknown) { + setCancelError(toApiError(error).detail) + } finally { + setCancelling(false) + } + } + + return { + rerunPending, + rerun: (): void => { void requestRerun() }, + cancelOpen, + setCancelOpen: (open: boolean): void => { + if (open) setCancelError(null) + if (!cancelling) setCancelOpen(open) + }, + cancelling, + cancelError, + cancel: (): void => { void requestCancel() }, + resumePending: resumeRequest.pendingRunId !== null, + resume: (): void => { + setRerunError(null) + resumeRequest.requestResume(scenarioResultId) + }, + controlError: rerunError ?? resumeRequest.error + ?? (resumeRequest.executionError !== runError ? resumeRequest.executionError : null), + } +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 3f3331a30f..ef7ad5cdb5 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1246,6 +1246,20 @@ export interface DisplayScore extends BackendScore { sourceLabel: string } +/** + * The fields a score popover shows, independent of where the score was recorded, + * so scan results do not need to fabricate a message-piece score. + */ +export interface ScoreSummary { + scorer: string + scoreType: string + value?: string | null + status?: string + rationale?: string | null + categories?: string[] | null + isObjective?: boolean +} + export interface BackendMessagePiece { id: string sequence?: number diff --git a/frontend/src/utils/scoreSummary.test.ts b/frontend/src/utils/scoreSummary.test.ts new file mode 100644 index 0000000000..cd80eee99a --- /dev/null +++ b/frontend/src/utils/scoreSummary.test.ts @@ -0,0 +1,23 @@ +import type { BackendScore } from '@/types' + +import { scoreSummaryFromBackendScore, scoreSummaryValue } from './scoreSummary' + +describe('scoreSummary', () => { + it('maps a message-piece score without losing its role', () => { + const score: BackendScore = { + id: 'score-1', message_piece_id: 'piece-1', scorer_type: 'Judge', score_type: 'true_false', + score_value: 'false', status: 'complete', is_objective_score: false, score_category: ['harm'], + score_rationale: 'Refused.', timestamp: '2026-01-01T00:00:00Z', + } + expect(scoreSummaryFromBackendScore(score)).toEqual({ + scorer: 'Judge', scoreType: 'true_false', value: 'false', status: 'complete', + rationale: 'Refused.', categories: ['harm'], isObjective: false, + }) + }) + + it('names the status of a score without a value', () => { + expect(scoreSummaryValue({ scorer: 'Judge', scoreType: 'true_false', value: 'true', status: 'complete' })).toBe('true') + expect(scoreSummaryValue({ scorer: 'Judge', scoreType: 'true_false', value: null, status: 'undetermined' })).toBe('undetermined') + expect(scoreSummaryValue({ scorer: 'Judge', scoreType: 'true_false' })).toBe('') + }) +}) diff --git a/frontend/src/utils/scoreSummary.ts b/frontend/src/utils/scoreSummary.ts new file mode 100644 index 0000000000..0ed551cd1d --- /dev/null +++ b/frontend/src/utils/scoreSummary.ts @@ -0,0 +1,18 @@ +import type { BackendScore, ScoreSummary } from '@/types' + +export function scoreSummaryFromBackendScore(score: BackendScore): ScoreSummary { + return { + scorer: score.scorer_type, + scoreType: score.score_type, + value: score.score_value, + status: score.status, + rationale: score.score_rationale, + categories: score.score_category, + isObjective: score.is_objective_score, + } +} + +/** An undetermined score has no value, so name its status instead. */ +export function scoreSummaryValue(score: ScoreSummary): string { + return score.value ?? score.status ?? '' +}