Accuracy Metrics
diff --git a/frontend/src/components/History/AttackHistory.test.tsx b/frontend/src/components/History/AttackHistory.test.tsx
index 3125017bfd..d9c958c2fe 100644
--- a/frontend/src/components/History/AttackHistory.test.tsx
+++ b/frontend/src/components/History/AttackHistory.test.tsx
@@ -89,12 +89,12 @@ describe('AttackHistory', () => {
expect(screen.getByRole('heading', { level: 1, name: 'Attack History' })).toBeInTheDocument()
expect(screen.getByTestId('refresh-btn')).toBeInTheDocument()
- expect(screen.getByTestId('attack-type-filter')).toBeInTheDocument()
expect(screen.getByTestId('outcome-filter')).toBeInTheDocument()
- expect(screen.getByTestId('converter-filter')).toBeInTheDocument()
expect(screen.getByTestId('operator-filter')).toBeInTheDocument()
expect(screen.getByTestId('operation-filter')).toBeInTheDocument()
expect(screen.getByTestId('label-filter')).toBeInTheDocument()
+ expect(screen.queryByTestId('attack-type-filter')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('converter-filter')).not.toBeInTheDocument()
await waitFor(() => {
expect(mockedAttacksApi.listAttacks).toHaveBeenCalledTimes(1)
@@ -621,6 +621,59 @@ describe('AttackHistory', () => {
})
})
+ it('should discard the current cursor when filters change on a later page', async () => {
+ mockedAttacksApi.listAttacks
+ .mockResolvedValueOnce({
+ items: sampleAttacks,
+ pagination: { limit: 25, has_more: true, next_cursor: 'cursor-page2' },
+ })
+ .mockResolvedValueOnce({
+ items: [sampleAttacks[1]],
+ pagination: { limit: 25, has_more: false },
+ })
+ .mockResolvedValueOnce({
+ items: [sampleAttacks[0]],
+ pagination: { limit: 25, has_more: false },
+ })
+ .mockResolvedValueOnce({
+ items: sampleAttacks,
+ pagination: { limit: 25, has_more: false },
+ })
+
+ const history = render(
+
+
+
+ )
+ await waitFor(() => expect(screen.getByTestId('next-page-btn')).toBeEnabled())
+ fireEvent.click(screen.getByTestId('next-page-btn'))
+ await waitFor(() => expect(mockedAttacksApi.listAttacks).toHaveBeenCalledTimes(2))
+
+ history.rerender(
+
+
+
+ )
+
+ await waitFor(() => expect(mockedAttacksApi.listAttacks).toHaveBeenCalledTimes(3))
+ const filteredRequest = mockedAttacksApi.listAttacks.mock.calls[2][0]
+ expect(filteredRequest).toEqual(expect.objectContaining({ outcome: 'success' }))
+ expect(filteredRequest).not.toHaveProperty('cursor')
+ expect(screen.getByText('Page 1')).toBeInTheDocument()
+
+ history.rerender(
+
+
+
+ )
+
+ await waitFor(() => expect(mockedAttacksApi.listAttacks).toHaveBeenCalledTimes(4))
+ expect(mockedAttacksApi.listAttacks.mock.calls[3][0]).not.toHaveProperty('cursor')
+ })
+
it('should load and display filter options from API', async () => {
mockedAttacksApi.listAttacks.mockResolvedValue({
items: [],
@@ -698,7 +751,7 @@ describe('AttackHistory', () => {
expect(onFiltersChange).toHaveBeenCalledWith(DEFAULT_HISTORY_FILTERS)
})
- it('should not show reset filters button when no filters are active', async () => {
+ it('should disable reset filters when no filters are active', async () => {
mockedAttacksApi.listAttacks.mockResolvedValue({
items: sampleAttacks,
pagination: { limit: 25, has_more: false },
@@ -714,7 +767,7 @@ describe('AttackHistory', () => {
expect(screen.getByText('Attack History')).toBeInTheDocument()
})
- expect(screen.queryByTestId('reset-filters-btn')).not.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Reset all filters' })).toBeDisabled()
})
it('should call onFiltersChange with attackTypes when attack type filter is selected', async () => {
@@ -746,6 +799,7 @@ describe('AttackHistory', () => {
})
// Open the attack type dropdown and select an option
+ fireEvent.click(screen.getByRole('button', { name: 'Show advanced filters' }))
const attackDropdown = screen.getByTestId('attack-type-filter')
fireEvent.click(attackDropdown)
@@ -818,6 +872,7 @@ describe('AttackHistory', () => {
expect(mockedAttacksApi.getConverterOptions).toHaveBeenCalled()
})
+ fireEvent.click(screen.getByRole('button', { name: 'Show advanced filters' }))
const converterDropdown = screen.getByTestId('converter-filter')
fireEvent.click(converterDropdown)
@@ -1083,7 +1138,7 @@ describe('AttackHistory', () => {
expect(callArgs).not.toHaveProperty('converter_types_match')
})
- it('should exclude scanner attacks by default and include them when enabled', async () => {
+ it('should include scanner attacks by default and exclude them when disabled', async () => {
mockedAttacksApi.listAttacks.mockResolvedValue({
items: [],
pagination: { limit: 25, has_more: false },
@@ -1097,7 +1152,7 @@ describe('AttackHistory', () => {
await waitFor(() => expect(mockedAttacksApi.listAttacks).toHaveBeenCalled())
expect(mockedAttacksApi.listAttacks.mock.calls[0][0]).toEqual(
- expect.objectContaining({ include_scenario_attacks: false })
+ expect.objectContaining({ include_scenario_attacks: true })
)
unmount()
@@ -1111,14 +1166,14 @@ describe('AttackHistory', () => {
)
await waitFor(() => expect(mockedAttacksApi.listAttacks).toHaveBeenCalled())
expect(mockedAttacksApi.listAttacks.mock.calls[0][0]).toEqual(
- expect.objectContaining({ include_scenario_attacks: true })
+ expect.objectContaining({ include_scenario_attacks: false })
)
})
diff --git a/frontend/src/components/History/AttackHistory.tsx b/frontend/src/components/History/AttackHistory.tsx
index 4452e12f7f..5e4e147d3f 100644
--- a/frontend/src/components/History/AttackHistory.tsx
+++ b/frontend/src/components/History/AttackHistory.tsx
@@ -73,16 +73,32 @@ export default function AttackHistory({
const [cursor, setCursor] = useState
(undefined)
const [isLastPage, setIsLastPage] = useState(true)
const [page, setPage] = useState(0)
+ const filterKey = JSON.stringify([
+ filters.attackTypes,
+ filters.outcome,
+ filters.converter,
+ filters.converterMatchMode,
+ filters.hasConverters,
+ filters.includeScenarioAttacks,
+ filters.operator,
+ filters.operation,
+ filters.otherLabels,
+ ])
+ const [settledFilterKey, setSettledFilterKey] = useState(null)
// Bumped from event handlers (Refresh button, pagination) to re-trigger the
// fetch effect without calling setState synchronously inside it.
- const [fetchToken, setFetchToken] = useState({ cursor: undefined as string | undefined, nonce: 0 })
+ const [fetchToken, setFetchToken] = useState({
+ cursor: undefined as string | undefined,
+ filterKey,
+ nonce: 0,
+ })
const fetchAttacks = useCallback((pageCursor?: string) => {
setLoading(true)
setError(null)
- setFetchToken(prev => ({ cursor: pageCursor, nonce: prev.nonce + 1 }))
- }, [])
+ setFetchToken(prev => ({ cursor: pageCursor, filterKey, nonce: prev.nonce + 1 }))
+ }, [filterKey])
// Load filter options on mount
useEffect(() => {
@@ -120,23 +136,28 @@ export default function AttackHistory({
// react-hooks/set-state-in-effect.
useEffect(() => {
let cancelled = false
- attacksApi.listAttacks(buildListParams(filters, fetchToken.cursor))
+ const effectiveCursor = fetchToken.filterKey === filterKey && settledFilterKey === filterKey
+ ? fetchToken.cursor
+ : undefined
+ attacksApi.listAttacks(buildListParams(filters, effectiveCursor))
.then(response => {
if (cancelled) return
setAttacks(response.items.map(attack => ({ ...attack, labels: attack.labels ?? {} })))
setIsLastPage(!response.pagination.has_more)
setCursor(response.pagination.next_cursor ?? undefined)
+ setSettledFilterKey(filterKey)
setError(null)
// Reset displayed page index when the trigger is a filter change (no
// explicit cursor). Pagination handlers pass an explicit cursor and
// update `page` themselves.
- if (!fetchToken.cursor) setPage(0)
+ if (!effectiveCursor) setPage(0)
})
.catch(err => {
if (cancelled) return
setAttacks([])
+ setSettledFilterKey(filterKey)
setError(toApiError(err).detail)
- if (!fetchToken.cursor) setPage(0)
+ if (!effectiveCursor) setPage(0)
})
.finally(() => {
if (!cancelled) setLoading(false)
@@ -158,6 +179,7 @@ export default function AttackHistory({
filters.operator,
filters.operation,
filters.otherLabels,
+ filterKey,
fetchToken,
])
@@ -187,8 +209,10 @@ export default function AttackHistory({
const hasActiveFilters =
filters.attackTypes.length > 0 || filters.outcome || filters.converter.length > 0 ||
filters.hasConverters !== undefined ||
- filters.includeScenarioAttacks ||
+ !filters.includeScenarioAttacks ||
filters.operator.length > 0 || filters.operation.length > 0 || filters.otherLabels.length > 0
+ const filtersPending = settledFilterKey !== filterKey
+ const displayLoading = loading || filtersPending
const emptyStateGuidance = activeTarget
? {
text: 'Start an attack to see it here.',
@@ -213,7 +237,7 @@ export default function AttackHistory({
appearance="subtle"
icon={}
onClick={() => fetchAttacks()}
- disabled={loading}
+ disabled={displayLoading}
data-testid="refresh-btn"
>
Refresh
@@ -231,7 +255,7 @@ export default function AttackHistory({