From a4452f3c7ffc68524b9702f212c006729dc2a600 Mon Sep 17 00:00:00 2001 From: WatchTree-19 <119982314+WatchTree-19@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:07:09 +0100 Subject: [PATCH] FEAT: outcome breakdown visualization on the scenario run page --- .../AttackResults/OutcomeSummaryBar.styles.ts | 66 +++++++++++++++ .../AttackResults/OutcomeSummaryBar.test.tsx | 48 +++++++++++ .../AttackResults/OutcomeSummaryBar.tsx | 80 +++++++++++++++++++ .../Scenarios/ScenarioRunPage.test.tsx | 1 + .../components/Scenarios/ScenarioRunPage.tsx | 13 +++ .../src/utils/attackOutcomeSummary.test.ts | 49 ++++++++++++ frontend/src/utils/attackOutcomeSummary.ts | 65 +++++++++++++++ 7 files changed, 322 insertions(+) create mode 100644 frontend/src/components/AttackResults/OutcomeSummaryBar.styles.ts create mode 100644 frontend/src/components/AttackResults/OutcomeSummaryBar.test.tsx create mode 100644 frontend/src/components/AttackResults/OutcomeSummaryBar.tsx create mode 100644 frontend/src/utils/attackOutcomeSummary.test.ts create mode 100644 frontend/src/utils/attackOutcomeSummary.ts diff --git a/frontend/src/components/AttackResults/OutcomeSummaryBar.styles.ts b/frontend/src/components/AttackResults/OutcomeSummaryBar.styles.ts new file mode 100644 index 0000000000..d1717570c2 --- /dev/null +++ b/frontend/src/components/AttackResults/OutcomeSummaryBar.styles.ts @@ -0,0 +1,66 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +export const useOutcomeSummaryBarStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalM, + }, + bar: { + display: 'flex', + width: '100%', + height: '1rem', + borderRadius: tokens.borderRadiusMedium, + overflow: 'hidden', + border: `1px solid ${tokens.colorNeutralStroke2}`, + backgroundColor: tokens.colorNeutralBackground3, + }, + segment: { + height: '100%', + // Keep a non-zero slice visible even when its share rounds down to a sliver. + minWidth: '2px', + }, + legend: { + display: 'flex', + flexWrap: 'wrap', + gap: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalL}`, + margin: 0, + padding: 0, + listStyle: 'none', + }, + legendItem: { + display: 'flex', + alignItems: 'center', + gap: tokens.spacingHorizontalXS, + }, + swatch: { + width: '0.75rem', + height: '0.75rem', + borderRadius: tokens.borderRadiusSmall, + flexShrink: 0, + }, + legendCount: { + fontVariantNumeric: 'tabular-nums', + }, + legendPercent: { + color: tokens.colorNeutralForeground3, + fontVariantNumeric: 'tabular-nums', + }, + hint: { + color: tokens.colorNeutralForeground3, + }, + // Per-outcome fills, aligned with OutcomeBadge semantics. Applied to both the + // bar segment and the legend swatch for the same outcome. + colorSuccess: { + backgroundColor: tokens.colorPaletteGreenForeground1, + }, + colorFailure: { + backgroundColor: tokens.colorPaletteRedForeground1, + }, + colorUndetermined: { + backgroundColor: tokens.colorNeutralForeground3, + }, + colorError: { + backgroundColor: tokens.colorPaletteDarkOrangeForeground1, + }, +}) diff --git a/frontend/src/components/AttackResults/OutcomeSummaryBar.test.tsx b/frontend/src/components/AttackResults/OutcomeSummaryBar.test.tsx new file mode 100644 index 0000000000..050a5a0b6f --- /dev/null +++ b/frontend/src/components/AttackResults/OutcomeSummaryBar.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from '@testing-library/react' + +import OutcomeSummaryBar from './OutcomeSummaryBar' + +describe('OutcomeSummaryBar', () => { + it('renders a segment per present outcome and an accessible summary label', () => { + render( + , + ) + + expect(screen.getByTestId('outcome-summary-segment-success')).toBeInTheDocument() + expect(screen.getByTestId('outcome-summary-segment-failure')).toBeInTheDocument() + expect(screen.getByTestId('outcome-summary-segment-error')).toBeInTheDocument() + // Undetermined has no attempts, so it gets no segment. + expect(screen.queryByTestId('outcome-summary-segment-undetermined')).not.toBeInTheDocument() + + const bar = screen.getByRole('img') + expect(bar).toHaveAccessibleName(/4 executions/i) + expect(bar).toHaveAccessibleName(/2 success \(50\.0%\)/i) + }) + + it('shows counts and percentages in the legend', () => { + render( + , + ) + + expect(screen.getByText('Success')).toBeInTheDocument() + expect(screen.getByText('Failure')).toBeInTheDocument() + expect(screen.getAllByText('50.0%')).toHaveLength(2) + }) + + it('renders a hint when there are no results', () => { + render() + + expect(screen.getByTestId('outcome-summary')).toHaveTextContent(/no completed executions/i) + expect(screen.queryByRole('img')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/AttackResults/OutcomeSummaryBar.tsx b/frontend/src/components/AttackResults/OutcomeSummaryBar.tsx new file mode 100644 index 0000000000..35bb439d0b --- /dev/null +++ b/frontend/src/components/AttackResults/OutcomeSummaryBar.tsx @@ -0,0 +1,80 @@ +import { mergeClasses, Text } from '@fluentui/react-components' + +import type { AttackOutcome, ScenarioProgressResult } from '@/types' +import { + OUTCOME_LABELS, + OUTCOME_ORDER, + summarizeAttackOutcomes, +} from '@/utils/attackOutcomeSummary' + +import { useOutcomeSummaryBarStyles } from './OutcomeSummaryBar.styles' + +interface OutcomeSummaryBarProps { + readonly results: readonly Pick[] + readonly testId?: string +} + +function formatPercent(value: number): string { + return `${value.toFixed(1)}%` +} + +export default function OutcomeSummaryBar({ results, testId }: OutcomeSummaryBarProps) { + const styles = useOutcomeSummaryBarStyles() + const { counts, total, percentages } = summarizeAttackOutcomes(results) + + // Fill class per outcome, keyed here so the bar and legend stay in sync. + const colorClasses: Record = { + success: styles.colorSuccess, + failure: styles.colorFailure, + undetermined: styles.colorUndetermined, + error: styles.colorError, + } + + if (total === 0) { + return ( + + No completed executions to summarize yet. + + ) + } + + const presentOutcomes = OUTCOME_ORDER.filter((outcome) => counts[outcome] > 0) + + const ariaLabel = `${total} execution${total === 1 ? '' : 's'}: ${presentOutcomes + .map( + (outcome) => + `${counts[outcome]} ${OUTCOME_LABELS[outcome].toLowerCase()} (${formatPercent(percentages[outcome])})`, + ) + .join(', ')}` + + return ( + + + {presentOutcomes.map((outcome) => ( + + ))} + + + {presentOutcomes.map((outcome) => ( + + + + {OUTCOME_LABELS[outcome]} + + + {counts[outcome]} + + + {formatPercent(percentages[outcome])} + + + ))} + + + ) +} diff --git a/frontend/src/components/Scenarios/ScenarioRunPage.test.tsx b/frontend/src/components/Scenarios/ScenarioRunPage.test.tsx index 3e7fd7f862..87a9bb00ea 100644 --- a/frontend/src/components/Scenarios/ScenarioRunPage.test.tsx +++ b/frontend/src/components/Scenarios/ScenarioRunPage.test.tsx @@ -324,6 +324,7 @@ describe('ScenarioRunPage', () => { expect(headings).toEqual([ 'Run configuration', 'Overall progress', + 'Outcome breakdown', 'Atomic attack groups', 'Objective Scorer', 'Techniques', diff --git a/frontend/src/components/Scenarios/ScenarioRunPage.tsx b/frontend/src/components/Scenarios/ScenarioRunPage.tsx index 504c647f0a..43fa68af08 100644 --- a/frontend/src/components/Scenarios/ScenarioRunPage.tsx +++ b/frontend/src/components/Scenarios/ScenarioRunPage.tsx @@ -38,6 +38,7 @@ import { Link, useLocation, useNavigate, useParams } from 'react-router' import AttackAttemptDetails from '@/components/AttackResults/AttackAttemptDetails' import ObjectiveScorerDetails from '@/components/AttackResults/ObjectiveScorerDetails' +import OutcomeSummaryBar from '@/components/AttackResults/OutcomeSummaryBar' import { formatDuration, formatTimestamp, @@ -457,6 +458,18 @@ function ScenarioRunPageContent({ scenarioResultId, attackResultId }: ScenarioRu + + + + Outcome breakdown + + + Distribution of attack outcomes across executions recorded so far. + + + + + diff --git a/frontend/src/utils/attackOutcomeSummary.test.ts b/frontend/src/utils/attackOutcomeSummary.test.ts new file mode 100644 index 0000000000..a4320fdc46 --- /dev/null +++ b/frontend/src/utils/attackOutcomeSummary.test.ts @@ -0,0 +1,49 @@ +import { summarizeAttackOutcomes } from './attackOutcomeSummary' + +describe('summarizeAttackOutcomes', () => { + it('counts each outcome and totals them', () => { + const summary = summarizeAttackOutcomes([ + { outcome: 'success' }, + { outcome: 'success' }, + { outcome: 'failure' }, + { outcome: 'undetermined' }, + { outcome: 'error' }, + ]) + + expect(summary.counts).toEqual({ success: 2, failure: 1, undetermined: 1, error: 1 }) + expect(summary.total).toBe(5) + }) + + it('computes percentages of the total', () => { + const summary = summarizeAttackOutcomes([ + { outcome: 'success' }, + { outcome: 'success' }, + { outcome: 'failure' }, + { outcome: 'error' }, + ]) + + expect(summary.percentages.success).toBeCloseTo(50) + expect(summary.percentages.failure).toBeCloseTo(25) + expect(summary.percentages.error).toBeCloseTo(25) + expect(summary.percentages.undetermined).toBe(0) + }) + + it('returns zeroed counts and percentages for an empty list', () => { + const summary = summarizeAttackOutcomes([]) + + expect(summary.total).toBe(0) + expect(summary.counts).toEqual({ success: 0, failure: 0, undetermined: 0, error: 0 }) + expect(summary.percentages).toEqual({ success: 0, failure: 0, undetermined: 0, error: 0 }) + }) + + it('ignores unrecognised or missing outcomes rather than throwing', () => { + const summary = summarizeAttackOutcomes([ + { outcome: 'success' }, + { outcome: 'not-a-real-outcome' as never }, + { outcome: undefined as never }, + ]) + + expect(summary.counts.success).toBe(1) + expect(summary.total).toBe(1) + }) +}) diff --git a/frontend/src/utils/attackOutcomeSummary.ts b/frontend/src/utils/attackOutcomeSummary.ts new file mode 100644 index 0000000000..03dee39de4 --- /dev/null +++ b/frontend/src/utils/attackOutcomeSummary.ts @@ -0,0 +1,65 @@ +import type { AttackOutcome, ScenarioProgressResult } from '@/types' + +/** + * Fixed presentation order for attack outcomes: most-to-least actionable, with + * the two "no verdict" states (undetermined, error) last. Every consumer of a + * summary iterates in this order so bars and legends stay stable across runs. + */ +export const OUTCOME_ORDER: readonly AttackOutcome[] = ['success', 'failure', 'undetermined', 'error'] + +/** Human-readable labels for each outcome, used in legends and screen-reader text. */ +export const OUTCOME_LABELS: Record = { + success: 'Success', + failure: 'Failure', + undetermined: 'Undetermined', + error: 'Error', +} + +export interface AttackOutcomeSummary { + /** Count of executions for each outcome. */ + readonly counts: Record + /** Total number of executions counted (sum of all outcome counts). */ + readonly total: number + /** Share of the total for each outcome, in the range 0-100. Zero when total is 0. */ + readonly percentages: Record +} + +function emptyCounts(): Record { + return { success: 0, failure: 0, undetermined: 0, error: 0 } +} + +function isAttackOutcome(value: unknown): value is AttackOutcome { + return typeof value === 'string' && (OUTCOME_ORDER as readonly string[]).includes(value) +} + +/** + * Aggregate a list of attempt results into per-outcome counts and percentages. + * + * Pure and defensive: results whose `outcome` is missing or unrecognised are + * ignored rather than throwing, so a malformed backend payload degrades to a + * smaller total instead of breaking the run page. + */ +export function summarizeAttackOutcomes( + results: readonly Pick[], +): AttackOutcomeSummary { + const counts = emptyCounts() + for (const result of results) { + if (isAttackOutcome(result?.outcome)) { + counts[result.outcome] += 1 + } + } + + let total = 0 + for (const outcome of OUTCOME_ORDER) { + total += counts[outcome] + } + + const percentages = emptyCounts() + if (total > 0) { + for (const outcome of OUTCOME_ORDER) { + percentages[outcome] = (counts[outcome] / total) * 100 + } + } + + return { counts, total, percentages } +}