Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
110 commits
Select commit Hold shift + click to select a range
f36db28
FIX: Reject path parameters by default when building components from …
varunj-msft Oct 26, 2026
291c462
Leave out target types that require a path callers may not set
varunj-msft Oct 26, 2026
797831b
Test a target whose required path is the server upload folder
varunj-msft Oct 26, 2026
214a0fa
Merge origin/main
varunj-msft Nov 2, 2026
521ec7e
TEST: Cover saved converter copies in the server path check
varunj-msft Nov 2, 2026
66fd155
FEAT: Report the documents a storage listing cannot use
varunj-msft Nov 2, 2026
2d8c465
Report non-files and missing containers instead of skipping them
varunj-msft Nov 2, 2026
5dec828
Report an entry that can't be checked instead of failing the whole li…
varunj-msft Nov 2, 2026
bf662b7
FEAT: Read saved registry recipes
varunj-msft Nov 2, 2026
e8b0604
Merge the server path check for API input
varunj-msft Nov 2, 2026
9143ea6
FEAT: Rebuild saved registry entries in dependency order
varunj-msft Nov 2, 2026
c29d812
FEAT: Restore saved registry entries and stay not ready if any fail
varunj-msft Nov 2, 2026
cfa4fc0
Limit technique descriptions like the create request and export the r…
varunj-msft Nov 2, 2026
33735f1
Merge the recipe model limits and exports
varunj-msft Nov 2, 2026
11d0519
Keep restore failure messages safe and complete, and check technique …
varunj-msft Nov 2, 2026
8ab7116
Merge the restore message and credential fixes
varunj-msft Nov 2, 2026
b7bf012
Document the recipes setting in the example config and pin the restor…
varunj-msft Nov 2, 2026
e45865b
Test that a description at the limit is accepted
varunj-msft Nov 3, 2026
b6927a9
Merge the description limit test
varunj-msft Nov 3, 2026
c94ffbd
Show only the error type when a URL in it may carry credentials
varunj-msft Nov 3, 2026
ac849a9
Merge the safer failure messages
varunj-msft Nov 3, 2026
26377ea
Point the restore message at where failures are listed and guard the …
varunj-msft Nov 3, 2026
8663852
Cut failure messages at any URL query or fragment and hide any with u…
varunj-msft Nov 3, 2026
4159905
Merge the stricter failure messages
varunj-msft Nov 3, 2026
9a419b4
Escape text that isn't UTF-8 in restore reports so they can be sent a…
varunj-msft Nov 3, 2026
8fb8095
Merge the report text escaping
varunj-msft Nov 3, 2026
9a67367
Read error text as plain normalized text and say when part of it is h…
varunj-msft Nov 3, 2026
82c957f
Merge the plain, normalized failure text
varunj-msft Nov 3, 2026
274b4ce
Escape entry names in restore reports too
varunj-msft Nov 3, 2026
95f9492
Merge the entry name escaping
varunj-msft Nov 3, 2026
a60323a
Show error text with its original characters
varunj-msft Nov 3, 2026
e7558a4
Test that a reference name that isn't valid text can still be reported
varunj-msft Nov 3, 2026
0e8490c
Merge the original-character error text and escaped entry names
varunj-msft Nov 3, 2026
686d475
Test that the runtime status can report a reference name that isn't v…
varunj-msft Nov 3, 2026
c391b0d
Escape report text only when it is sent as JSON
varunj-msft Nov 3, 2026
0b44db6
Merge the JSON-only report escaping
varunj-msft Nov 3, 2026
f986da5
Check each character on its own so long error messages stay fast
varunj-msft Nov 3, 2026
807a904
Merge the per-character check and JSON-only report escaping
varunj-msft Nov 3, 2026
c86bfe6
Check the exact message for every form of each mark and bound the wor…
varunj-msft Nov 3, 2026
454d05b
Merge the exact look-alike message checks
varunj-msft Nov 3, 2026
9f91282
Test that the runtime status can list a file name that isn't valid text
varunj-msft Nov 3, 2026
a455494
Pass the look-alike check's arguments by keyword
varunj-msft Nov 3, 2026
ce8b684
Test that a technique waits for a saved converter in either list and …
varunj-msft Nov 3, 2026
d48a12c
Merge the converter-list and attack-type tests
varunj-msft Nov 3, 2026
74412fe
Test that restore runs after the startup scripts, at startup and on a…
varunj-msft Nov 3, 2026
784cd45
Keep the offline browser fixture away from saved registry entries
varunj-msft Nov 3, 2026
507567b
Check the opened file before reading it and skip links deleted since …
varunj-msft Nov 3, 2026
9d37ba3
Merge the opened-file check
varunj-msft Nov 3, 2026
ff2c836
Merge the opened-file check
varunj-msft Nov 3, 2026
3afdcaa
Merge origin/main
varunj-msft Nov 3, 2026
de2f4e3
Let restore set the path parameters a target lets callers set, as the…
varunj-msft Nov 3, 2026
e821e7e
Merge the opened-file check, main and the caller path parameters
varunj-msft Nov 3, 2026
a45ac23
Close a file that isn't regular before reporting it and report entrie…
varunj-msft Nov 3, 2026
60477e8
Merge the file handle fix
varunj-msft Nov 3, 2026
28f2262
Merge the file handle fix
varunj-msft Nov 3, 2026
ad21f0c
Give only targets the path parameters their class lets callers set
varunj-msft Nov 3, 2026
3dc4768
Merge the file handle fix and the target-only caller paths
varunj-msft Nov 3, 2026
db245dc
Test a file replaced just before it's opened and stop a blocked read …
varunj-msft Nov 4, 2026
5d3d2d0
Merge the opened-file tests
varunj-msft Nov 4, 2026
07a617f
Merge the opened-file tests
varunj-msft Nov 4, 2026
2b4f1cc
Merge the opened-file tests
varunj-msft Nov 4, 2026
80754a2
Read saved recipes separately from building them
varunj-msft Nov 4, 2026
7965020
Merge the separate recipe read
varunj-msft Nov 4, 2026
bd84ef0
Build restored entries on the runtime loop and read them in a worker …
varunj-msft Nov 4, 2026
1a192f7
Show every signed-in user why saved registry entries couldn't be rest…
varunj-msft Nov 4, 2026
4fb7f35
Retry restoring saved registry entries without a restart
varunj-msft Nov 4, 2026
27ef572
Drop a late retry answer when the runtime failed again between polls
varunj-msft Nov 4, 2026
fa1ae56
Keep Retry in reach and say when the failure list couldn't be loaded
varunj-msft Nov 5, 2026
d1bdd82
List presets, suites, and initializers through the checked listing
varunj-msft Nov 5, 2026
f5ed760
Merge the shared document listing
varunj-msft Nov 5, 2026
4b55b17
Merge the shared document listing
varunj-msft Nov 5, 2026
a1866d5
Merge the shared document listing
varunj-msft Nov 5, 2026
e8dc897
Merge the shared document listing
varunj-msft Nov 5, 2026
76dbf9c
Report a preset, suite, or initializer folder that can't be read as u…
varunj-msft Nov 5, 2026
f823e4b
Merge the storage error mapping for unreadable folders
varunj-msft Nov 5, 2026
99da684
Merge the storage error mapping for unreadable folders
varunj-msft Nov 5, 2026
2c58145
Merge the storage error mapping for unreadable folders
varunj-msft Nov 5, 2026
5a0afa6
Merge the storage error mapping for unreadable folders
varunj-msft Nov 5, 2026
6800d15
Merge origin/main
varunj-msft Nov 6, 2026
74839cd
Merge origin/main
varunj-msft Nov 6, 2026
7d9ac2f
Log why storage is unavailable, without its path or URL
varunj-msft Nov 7, 2026
7b5bfeb
Merge the storage error logging
varunj-msft Nov 7, 2026
22806d8
Merge the storage error logging
varunj-msft Nov 7, 2026
34011c9
Hide a restore message with a percent escape, which can hide a creden…
varunj-msft Nov 7, 2026
8a0113f
Merge the storage error logging and the percent-escape fix
varunj-msft Nov 7, 2026
b85189b
Merge the storage error logging and the percent-escape fix
varunj-msft Nov 7, 2026
031dc61
Merge origin/main
varunj-msft Nov 8, 2026
7f8ab0d
Merge origin/main
varunj-msft Nov 8, 2026
19205b1
Import the saved recipe models from their package, as main now requires
varunj-msft Nov 8, 2026
0348a0b
Merge origin/main
varunj-msft Nov 8, 2026
bba3afd
Import from public packages, as main now requires
varunj-msft Nov 8, 2026
d4ccd50
Merge origin/main
varunj-msft Nov 8, 2026
0e39617
Merge origin/main
varunj-msft Nov 8, 2026
60d409b
Import from public packages, as main now requires
varunj-msft Nov 8, 2026
622bbb8
Report storage failures with one error and answer them with 503
varunj-msft Nov 9, 2026
1cae0b9
Merge the single storage error
varunj-msft Nov 9, 2026
eba7bf2
Escape file names once and simplify the saved recipe models
varunj-msft Nov 9, 2026
9f24124
Merge the recipe model simplification
varunj-msft Nov 9, 2026
2bbfc8f
Report restore bugs as build errors and log each failure once
varunj-msft Nov 9, 2026
3735dbc
Merge the restore logging and build error fixes
varunj-msft Nov 9, 2026
ca7563d
Stay ready when a saved entry can't be restored, and hold its name
varunj-msft Nov 9, 2026
8cf0c49
Merge the stay-ready restore
varunj-msft Nov 9, 2026
ea245b2
Drop Retry and show restore failures while the runtime is ready
varunj-msft Nov 10, 2026
67416f1
Reset the registries between runtime tests, as a real startup does
varunj-msft Nov 10, 2026
d953906
Merge the runtime test isolation
varunj-msft Nov 10, 2026
6ee84f0
Merge origin/main
varunj-msft Nov 10, 2026
d43a7e8
Merge origin/main
varunj-msft Nov 10, 2026
0664063
Merge origin/main
varunj-msft Nov 10, 2026
3b3ac59
Merge origin/main
varunj-msft Nov 10, 2026
ed358ba
Merge origin/main
varunj-msft Nov 10, 2026
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
8 changes: 8 additions & 0 deletions .pyrit_conf_example
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,14 @@ allow_custom_initializers: false
# as scenario_presets_source. Defaults to ~/.pyrit/scenario_suites.
# scenario_suites_source: https://account.blob.core.windows.net/container/scenario_suites

# Optional storage for saved targets, converters, scorers, and techniques, so they
# come back when the backend restarts. Each kind has its own folder under the source.
# This supports the same local directory and Azure Blob sources as
# scenario_presets_source. If a saved entry can't be restored, the backend still
# starts, lists the failure in its runtime status, and refuses to use that entry's
# name until the file is fixed or deleted. Defaults to ~/.pyrit/registry_recipes.
# registry_recipes_source: https://account.blob.core.windows.net/container/registry_recipes

# Upload Folder for API-Created Targets
# -------------------------------------
# Folder that targets created through the REST API may upload local files from
Expand Down
12 changes: 12 additions & 0 deletions doc/getting_started/pyrit_conf.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,18 @@ Suites store ordered references to scenario presets and resolve each preset's cu

A SAS query string may be included; otherwise, PyRIT uses `DefaultAzureCredential`. The default is `~/.pyrit/scenario_suites`. Local writes use the same hidden lock-file and atomic replacement behavior as scenario presets. Startup scans the configured source so malformed suite documents are reported immediately without preventing the backend from starting.

### `registry_recipes_source`

Stores saved targets, converters, scorers, and techniques in a local directory or Azure Blob container, so they come back when the backend restarts. An Azure URI may include a blob-name prefix:

```yaml
registry_recipes_source: https://account.blob.core.windows.net/pyrit-storage/registry-recipes
```

Each kind has its own folder under the source (`targets/`, `converters/`, `scorers/`, `techniques/`), with one JSON file per saved entry named after the entry. A file holds what's needed to rebuild the entry; restore refuses a file that stores a value for a parameter its class marks as a credential, such as `api_key`. A SAS query string may be included; otherwise, PyRIT uses `DefaultAzureCredential`. The default is `~/.pyrit/registry_recipes`.

The backend restores saved entries at startup and after a live apply, once the configured initializers have registered theirs. If a saved entry can't be restored, the backend still becomes ready and lists each failure with its reason in `GET /api/config/runtime`. Every signed-in user can see the same list in `GET /api/restore-failures` and in the GUI's banner; users who aren't administrators get a short explanation of each reason instead of the full message. The failed entry keeps its name: looking it up, or starting a run that uses it, fails with the reason (409) instead of finding nothing or something else, and nothing else can take the name. While a saved technique is broken, a launch has to name its techniques, because choosing them by `all`, `default`, or a tag could quietly leave it out. If a whole folder can't be listed, nothing is restored and every name of that kind that isn't registered is held the same way. Fix or delete the listed files, then apply or restart. Files whose names aren't valid entry names (up to 64 lowercase letters, digits, and underscores, starting with a letter) are listed as ignored.

### `initialization_scripts`

Local paths to custom Python scripts containing `PyRITInitializer` subclasses. Paths can be absolute or relative to the current working directory.
Expand Down
30 changes: 17 additions & 13 deletions frontend/e2e/fixtures/manual_send_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"""Offline backend for manual-send browser tests, with no real configuration or providers."""

import os
import tempfile
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, patch
Expand All @@ -27,19 +28,22 @@ async def _initialize_offline_async(self: ConfigurationLoader, *, raise_on_initi

@asynccontextmanager
async def _offline_lifespan_async(application: FastAPI) -> AsyncGenerator[None, None]:
config = ConfigurationLoader(memory_db_type="in_memory", env_files=[], env_akv_ref=[])
with (
patch.dict(os.environ, {"PYRIT_AUTH_MODE": "local", "PYRIT_DEV_MODE": "true"}, clear=True),
patch.object(
ConfigurationFileService,
"_read_source_async",
new=AsyncMock(return_value="memory_db_type: in_memory\nenv_files: []\ninitializers: []\n"),
),
patch.object(ConfigurationLoader, "load_with_overrides", return_value=config),
patch.object(ConfigurationLoader, "initialize_pyrit_async", new=_initialize_offline_async),
):
async with lifespan(application):
yield
with tempfile.TemporaryDirectory() as recipes:
config = ConfigurationLoader(
memory_db_type="in_memory", env_files=[], env_akv_ref=[], registry_recipes_source=recipes
)
with (
patch.dict(os.environ, {"PYRIT_AUTH_MODE": "local", "PYRIT_DEV_MODE": "true"}, clear=True),
patch.object(
ConfigurationFileService,
"_read_source_async",
new=AsyncMock(return_value="memory_db_type: in_memory\nenv_files: []\ninitializers: []\n"),
),
patch.object(ConfigurationLoader, "load_with_overrides", return_value=config),
patch.object(ConfigurationLoader, "initialize_pyrit_async", new=_initialize_offline_async),
):
async with lifespan(application):
yield


app.router.lifespan_context = _offline_lifespan_async
9 changes: 9 additions & 0 deletions frontend/src/hooks/useRuntime.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { makeStyles } from '@fluentui/react-components'

export const useRuntimeBannerStyles = makeStyles({
// The app doesn't scroll, so a long list scrolls on its own.
failureList: {
maxHeight: '30vh',
overflowY: 'auto',
},
})
185 changes: 182 additions & 3 deletions frontend/src/hooks/useRuntime.test.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import { useState } from 'react'
import type { ReactNode } from 'react'

import { act, render, screen, waitFor } from '@testing-library/react'
import { FluentProvider, webLightTheme } from '@fluentui/react-components'
import { act, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

import { runtimeApi } from '@/services/api'
import type { RestoreFailure } from '@/types'

import { RuntimeProvider, useRuntime } from './useRuntime'
import { RuntimeBanner, RuntimeProvider, useRuntime } from './useRuntime'

jest.mock('@/services/api', () => ({ runtimeApi: { getReadiness: jest.fn() } }))
jest.mock('@/services/api', () => ({
runtimeApi: { getReadiness: jest.fn(), getRestoreFailures: jest.fn() },
}))

function TestWrapper({ children }: { children: ReactNode }) {
return <FluentProvider theme={webLightTheme}>{children}</FluentProvider>
}

function Consumer() {
const runtime = useRuntime()
Expand Down Expand Up @@ -41,3 +50,173 @@ describe('RuntimeProvider', () => {
expect(screen.getByRole('textbox', { name: 'Draft' })).toHaveValue('keep my draft')
})
})

function brokenTarget(name: string): RestoreFailure {
return { kind: 'targets', name, reason: 'unknown_class', message: 'It is broken.', version: 'v1', dependency: null }
}

function renderBanner() {
render(<TestWrapper><RuntimeProvider><RuntimeBanner /></RuntimeProvider></TestWrapper>)
}

describe('RuntimeBanner', () => {
beforeEach(() => { jest.clearAllMocks() })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Restore real timers after each banner test

Eleven RuntimeBanner tests call jest.useFakeTimers() and call jest.useRealTimers() only on their last line. If an assertion fails first, fake timers stay on for the next tests. Then "retries and shows what still could not be restored" and "shows why a retry was refused" call userEvent.setup() without advanceTimers. user-event 14 waits on setTimeout(0) for each action, so each of these tests waits for the 15 s timeout and fails, which adds two failures that look unrelated to the first one. Add afterEach(() => { jest.useRealTimers() }) next to this beforeEach, as the RuntimeProvider block does at line 36, and remove the last-line calls.


it('lists the saved entries that could not be restored while the runtime is ready', async () => {
jest.mocked(runtimeApi.getReadiness).mockResolvedValue({
ready: true,
state: 'ready',
generation: 'first',
restore_failures: 2,
})
jest.mocked(runtimeApi.getRestoreFailures).mockResolvedValue({
items: [
{
kind: 'targets',
name: 'pool',
reason: 'missing_dependency',
message: 'It uses an entry that does not exist.',
version: 'v1',
dependency: { kind: 'targets', name: 'gone' },
},
{
kind: 'converters',
name: null,
reason: 'unreadable_storage',
message: 'The folder could not be read.',
version: null,
dependency: null,
},
],
})

renderBanner()

const list = await screen.findByRole('list', { name: "Saved entries that couldn't be restored" })
expect(await within(list).findByText('Target pool: It uses an entry that does not exist. (target gone)')).toBeInTheDocument()
expect(within(list).getByText('Saved converters: The folder could not be read.')).toBeInTheDocument()
expect(screen.getByText(/fixes or\s+deletes their saved files, then applies the configuration/)).toBeInTheDocument()
expect(screen.queryByText(/has to name its techniques/)).not.toBeInTheDocument()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})

it('says a scan has to name its techniques while a saved technique is broken', async () => {
jest.mocked(runtimeApi.getReadiness).mockResolvedValue({
ready: true,
state: 'ready',
generation: 'first',
restore_failures: 1,
})
jest.mocked(runtimeApi.getRestoreFailures).mockResolvedValue({
items: [{ ...brokenTarget('formal'), kind: 'techniques' }],
})

renderBanner()

expect(await screen.findByText(/a scan has to name its techniques/)).toBeInTheDocument()
})

it('shows nothing and asks for no list when every saved entry was restored', async () => {
jest.mocked(runtimeApi.getReadiness).mockResolvedValue({
ready: true,
state: 'ready',
generation: 'first',
restore_failures: 0,
})

renderBanner()

await waitFor(() => expect(runtimeApi.getReadiness).toHaveBeenCalled())
expect(screen.queryByRole('list')).not.toBeInTheDocument()
expect(runtimeApi.getRestoreFailures).not.toHaveBeenCalled()
})

it('does not ask for restore failures while the runtime is not ready', async () => {
jest.mocked(runtimeApi.getReadiness).mockResolvedValue({
ready: false,
state: 'restart-required',
generation: '',
restore_failures: 1,
})

renderBanner()

expect(await screen.findByText(/PyRIT runtime: restart-required/)).toBeInTheDocument()
expect(screen.queryByRole('list')).not.toBeInTheDocument()
expect(runtimeApi.getRestoreFailures).not.toHaveBeenCalled()
})

it('reads the list again only when the count or the generation changes', async () => {
jest.useFakeTimers()
const getReadiness = jest.mocked(runtimeApi.getReadiness)
getReadiness.mockResolvedValue({ ready: true, state: 'ready', generation: 'first', restore_failures: 1 })
jest.mocked(runtimeApi.getRestoreFailures).mockResolvedValue({ items: [brokenTarget('ghost')] })

renderBanner()
expect(await screen.findByText('Target ghost: It is broken.')).toBeInTheDocument()
await act(async () => { jest.advanceTimersByTime(4_000) })
expect(runtimeApi.getRestoreFailures).toHaveBeenCalledTimes(1)

jest.mocked(runtimeApi.getRestoreFailures).mockResolvedValue({ items: [brokenTarget('other')] })
getReadiness.mockResolvedValue({ ready: true, state: 'ready', generation: 'second', restore_failures: 1 })
await act(async () => { jest.advanceTimersByTime(2_000) })

expect(await screen.findByText('Target other: It is broken.')).toBeInTheDocument()
expect(runtimeApi.getRestoreFailures).toHaveBeenCalledTimes(2)
jest.useRealTimers()
})

it('hides the list once nothing fails any more', async () => {
jest.useFakeTimers()
const getReadiness = jest.mocked(runtimeApi.getReadiness)
getReadiness.mockResolvedValue({ ready: true, state: 'ready', generation: 'first', restore_failures: 1 })
jest.mocked(runtimeApi.getRestoreFailures).mockResolvedValue({ items: [brokenTarget('ghost')] })

renderBanner()
expect(await screen.findByText('Target ghost: It is broken.')).toBeInTheDocument()
getReadiness.mockResolvedValue({ ready: true, state: 'ready', generation: 'second', restore_failures: 0 })
await act(async () => { jest.advanceTimersByTime(2_000) })

await waitFor(() => expect(screen.queryByText('Target ghost: It is broken.')).not.toBeInTheDocument())
jest.useRealTimers()
})

it('tries again when the list could not be loaded, keeping the last list', async () => {
jest.useFakeTimers()
jest.mocked(runtimeApi.getReadiness).mockResolvedValue({
ready: true,
state: 'ready',
generation: 'first',
restore_failures: 1,
})
jest.mocked(runtimeApi.getRestoreFailures)
.mockRejectedValueOnce(new Error('Network Error'))
.mockResolvedValue({ items: [brokenTarget('ghost')] })

renderBanner()
expect(await screen.findByText(/list of saved entries couldn't be loaded/)).toBeInTheDocument()
await act(async () => { jest.advanceTimersByTime(2_000) })

expect(await screen.findByText('Target ghost: It is broken.')).toBeInTheDocument()
expect(screen.queryByText(/list of saved entries couldn't be loaded/)).not.toBeInTheDocument()
jest.useRealTimers()
})

it('lets a long list scroll on its own', async () => {
jest.mocked(runtimeApi.getReadiness).mockResolvedValue({
ready: true,
state: 'ready',
generation: 'first',
restore_failures: 40,
})
jest.mocked(runtimeApi.getRestoreFailures).mockResolvedValue({
items: Array.from({ length: 40 }, (_, index) => brokenTarget(`target_${index}`)),
})

renderBanner()

const list = await screen.findByRole('list', { name: "Saved entries that couldn't be restored" })
expect(list).toHaveAttribute('tabindex', '0')
expect(within(list).getAllByRole('listitem')).toHaveLength(40)
})
})
87 changes: 85 additions & 2 deletions frontend/src/hooks/useRuntime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,17 @@
import { MessageBar, MessageBarBody } from '@fluentui/react-components'

import { runtimeApi } from '@/services/api'
import type { RuntimeReadiness } from '@/types'
import type { RegistryRecipeKind, RestoreFailure, RuntimeReadiness } from '@/types'

import { useRuntimeBannerStyles } from './useRuntime.styles'

const POLL_INTERVAL_MS = 2_000
const KIND_LABELS: Record<RegistryRecipeKind, string> = {
targets: 'Target',
converters: 'Converter',
scorers: 'Scorer',
techniques: 'Technique',
}
const RuntimeContext = createContext<RuntimeReadiness>({ ready: true, state: 'ready', generation: '' })

// eslint-disable-next-line react-refresh/only-export-components
Expand Down Expand Up @@ -38,9 +46,84 @@
return <RuntimeContext.Provider value={runtime}>{children}</RuntimeContext.Provider>
}

interface RestoreFailures {
failures: RestoreFailure[]
listFailed: boolean
}

// The list is read when the count or the runtime generation changes, rather than on every readiness poll.
function useRestoreFailures(active: boolean, count: number, generation: string): RestoreFailures {
const [failures, setFailures] = useState<RestoreFailure[]>([])
const [listFailed, setListFailed] = useState(false)
useEffect(() => {
if (!active) return
let cancelled = false
let retryTimer: ReturnType<typeof setTimeout> | undefined
const load = async (): Promise<void> => {
try {
const list = await runtimeApi.getRestoreFailures()
if (!cancelled) {
setFailures(list.items)
setListFailed(false)
}
} catch {
Comment thread
varunj-msft marked this conversation as resolved.
Resolved
// Keep the last list and say it couldn't be refreshed; try again shortly.
if (!cancelled) {
setListFailed(true)
retryTimer = setTimeout(() => { void load() }, POLL_INTERVAL_MS)
}
}
}
void load()
return () => {
cancelled = true
clearTimeout(retryTimer)
}
}, [active, count, generation])
return { failures: active ? failures : [], listFailed: active && listFailed }
}

function describeFailure(failure: RestoreFailure): string {
const subject = failure.name === null ? `Saved ${failure.kind}` : `${KIND_LABELS[failure.kind]} ${failure.name}`
const dependency = failure.dependency
? ` (${KIND_LABELS[failure.dependency.kind].toLowerCase()} ${failure.dependency.name})`
: ''
return `${subject}: ${failure.message}${dependency}`
}

export function RuntimeBanner() {
const runtime = useRuntime()
if (runtime.ready) return null
const failureCount = runtime.restore_failures ?? 0
const styles = useRuntimeBannerStyles()
const { failures, listFailed } = useRestoreFailures(
runtime.ready && failureCount > 0,
failureCount,
runtime.generation,
)
if (runtime.ready) {
if (failureCount === 0) return null
const techniqueFailed = failures.some((failure) => failure.kind === 'techniques')
return (
<MessageBar intent="warning">
<MessageBarBody>
Some saved entries couldn&apos;t be restored, so they can&apos;t be used until an administrator fixes or
deletes their saved files, then applies the configuration or restarts the backend.
{techniqueFailed && ' Until then, a scan has to name its techniques instead of choosing all, default, or a tag.'}
<div aria-live="polite">
{failures.length > 0 && (
// Focusable so the list can be scrolled from the keyboard.
<ul aria-label="Saved entries that couldn't be restored" className={styles.failureList} tabIndex={0}>

Check failure on line 115 in frontend/src/hooks/useRuntime.tsx

View workflow job for this annotation

GitHub Actions / Frontend Lint & Type Check

`tabIndex` should only be declared on interactive elements
{failures.map((failure) => (
Comment thread
varunj-msft marked this conversation as resolved.
Resolved
<li key={`${failure.kind}/${failure.name ?? ''}`}>{describeFailure(failure)}</li>
))}
</ul>
)}
{listFailed && <div>The list of saved entries couldn&apos;t be loaded. Trying again.</div>}
</div>
</MessageBarBody>
</MessageBar>
)
}
return (
<MessageBar intent="warning">
<MessageBarBody>
Expand Down
Loading
Loading