Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions frontend/src/components/AttackResults/OutcomeSummaryBar.styles.ts
Original file line number Diff line number Diff line change
@@ -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,
},
})
48 changes: 48 additions & 0 deletions frontend/src/components/AttackResults/OutcomeSummaryBar.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<OutcomeSummaryBar
testId="outcome-summary"
results={[
{ outcome: 'success' },
{ outcome: 'success' },
{ outcome: 'failure' },
{ outcome: 'error' },
]}
/>,
)

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(
<OutcomeSummaryBar
results={[{ outcome: 'success' }, { outcome: 'failure' }]}
/>,
)

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(<OutcomeSummaryBar testId="outcome-summary" results={[]} />)

expect(screen.getByTestId('outcome-summary')).toHaveTextContent(/no completed executions/i)
expect(screen.queryByRole('img')).not.toBeInTheDocument()
})
})
80 changes: 80 additions & 0 deletions frontend/src/components/AttackResults/OutcomeSummaryBar.tsx
Original file line number Diff line number Diff line change
@@ -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<ScenarioProgressResult, 'outcome'>[]
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<AttackOutcome, string> = {
success: styles.colorSuccess,
failure: styles.colorFailure,
undetermined: styles.colorUndetermined,
error: styles.colorError,
}

if (total === 0) {
return (
<Text className={styles.hint} data-testid={testId}>
No completed executions to summarize yet.
</Text>
)
}

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 (
<div className={styles.root} data-testid={testId}>
<div className={styles.bar} role="img" aria-label={ariaLabel}>
{presentOutcomes.map((outcome) => (
<div
key={outcome}
className={mergeClasses(styles.segment, colorClasses[outcome])}
data-testid={testId ? `${testId}-segment-${outcome}` : undefined}
style={{ width: `${percentages[outcome]}%` }}
/>
))}
</div>
<ul className={styles.legend} aria-hidden="true">
{presentOutcomes.map((outcome) => (
<li key={outcome} className={styles.legendItem}>
<span className={mergeClasses(styles.swatch, colorClasses[outcome])} />
<Text size={200} weight="semibold">
{OUTCOME_LABELS[outcome]}
</Text>
<Text size={200} className={styles.legendCount}>
{counts[outcome]}
</Text>
<Text size={200} className={styles.legendPercent}>
{formatPercent(percentages[outcome])}
</Text>
</li>
))}
</ul>
</div>
)
}
1 change: 1 addition & 0 deletions frontend/src/components/Scenarios/ScenarioRunPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@ describe('ScenarioRunPage', () => {
expect(headings).toEqual([
'Run configuration',
'Overall progress',
'Outcome breakdown',
'Atomic attack groups',
'Objective Scorer',
'Techniques',
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/components/Scenarios/ScenarioRunPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -457,6 +458,18 @@ function ScenarioRunPageContent({ scenarioResultId, attackResultId }: ScenarioRu
</span>
</section>

<section className={styles.section} aria-labelledby="outcome-breakdown-heading">
<div className={styles.sectionHeading}>
<Text as="h2" id="outcome-breakdown-heading" size={500} weight="semibold">
Outcome breakdown
</Text>
<Text className={styles.sectionHint}>
Distribution of attack outcomes across executions recorded so far.
</Text>
</div>
<OutcomeSummaryBar testId="run-outcome-summary" results={state.results} />
</section>

<section className={styles.section} aria-labelledby="atomic-groups-heading">
<div className={styles.sectionHeading}>
<Text as="h2" id="atomic-groups-heading" size={500} weight="semibold">
Expand Down
49 changes: 49 additions & 0 deletions frontend/src/utils/attackOutcomeSummary.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
65 changes: 65 additions & 0 deletions frontend/src/utils/attackOutcomeSummary.ts
Original file line number Diff line number Diff line change
@@ -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<AttackOutcome, string> = {
success: 'Success',
failure: 'Failure',
undetermined: 'Undetermined',
error: 'Error',
}

export interface AttackOutcomeSummary {
/** Count of executions for each outcome. */
readonly counts: Record<AttackOutcome, number>
/** 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<AttackOutcome, number>
}

function emptyCounts(): Record<AttackOutcome, number> {
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<ScenarioProgressResult, 'outcome'>[],
): 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 }
}