From bdbd4732a46e64a9c64bf726430f54aa84ca278a Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Fri, 24 Jul 2026 16:58:02 -0400 Subject: [PATCH 1/2] Add GUI initializers feature Adds a DB-backed initializer settings feature: persistence, merge/apply-now API, and a frontend Initializers config tab. - pyrit/models/initializer_setting.py: new InitializerSetting model - pyrit/memory: schema, memory_interface support, and alembic migration for initializer settings - pyrit/backend: routes/service/models for listing, saving, applying, and resetting initializer settings, merged with baseline config-file initializers - pyrit/setup/configuration_loader.py: public initializer_configs accessor used by the backend instead of a private attribute - frontend/src/components/Config: new ConfigPage, InitializerConfig, and InitializerTable components for viewing and editing initializers in the GUI, including Save/Apply now/Reset actions per initializer and a no-op guard that disables Save until parameters change Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 42217662-8f83-4440-9b89-2d3645e6af08 --- frontend/src/App.tsx | 4 +- .../components/Config/ConfigPage.styles.ts | 25 ++ .../src/components/Config/ConfigPage.test.tsx | 69 ++++ frontend/src/components/Config/ConfigPage.tsx | 44 +++ .../Config/InitializerConfig.styles.ts | 42 +++ .../Config/InitializerConfig.test.tsx | 192 +++++++++++ .../components/Config/InitializerConfig.tsx | 168 ++++++++++ .../Config/InitializerTable.styles.ts | 56 ++++ .../Config/InitializerTable.test.tsx | 181 +++++++++++ .../components/Config/InitializerTable.tsx | 301 ++++++++++++++++++ frontend/src/services/api.ts | 38 +++ frontend/src/types/index.ts | 47 +++ pyrit/backend/models/initializers.py | 78 ++++- pyrit/backend/routes/initializers.py | 139 ++++++++ pyrit/backend/services/initializer_service.py | 290 +++++++++++++++-- ...6e1f2b7d_add_initializer_settings_table.py | 38 +++ pyrit/memory/memory_interface.py | 45 +++ pyrit/memory/memory_models.py | 45 +++ pyrit/models/__init__.py | 2 + pyrit/models/initializer_setting.py | 32 ++ .../components/initializer_registry.py | 2 +- pyrit/setup/configuration_loader.py | 5 + .../unit/backend/test_initializer_service.py | 266 +++++++++++++++- .../memory/test_initializer_setting_memory.py | 48 +++ tests/unit/models/test_initializer_setting.py | 20 ++ 25 files changed, 2143 insertions(+), 34 deletions(-) create mode 100644 frontend/src/components/Config/ConfigPage.styles.ts create mode 100644 frontend/src/components/Config/ConfigPage.test.tsx create mode 100644 frontend/src/components/Config/ConfigPage.tsx create mode 100644 frontend/src/components/Config/InitializerConfig.styles.ts create mode 100644 frontend/src/components/Config/InitializerConfig.test.tsx create mode 100644 frontend/src/components/Config/InitializerConfig.tsx create mode 100644 frontend/src/components/Config/InitializerTable.styles.ts create mode 100644 frontend/src/components/Config/InitializerTable.test.tsx create mode 100644 frontend/src/components/Config/InitializerTable.tsx create mode 100644 pyrit/memory/alembic/versions/4c9a6e1f2b7d_add_initializer_settings_table.py create mode 100644 pyrit/models/initializer_setting.py create mode 100644 tests/unit/memory/test_initializer_setting_memory.py create mode 100644 tests/unit/models/test_initializer_setting.py diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ae9a572dee..ec49e60dcc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,7 +7,7 @@ import MainLayout from './components/Layout/MainLayout' import ChatWindow from './components/Chat/ChatWindow' import AttackNotFound from './components/Chat/AttackNotFound' import Home from './components/Home/Home' -import TargetConfig from './components/Config/TargetConfig' +import ConfigPage from './components/Config/ConfigPage' import AttackHistory from './components/History/AttackHistory' import FeedbackDialog from './components/Feedback/FeedbackDialog' import type { HistoryFilters } from './components/History/historyFilters' @@ -386,7 +386,7 @@ function App() { diff --git a/frontend/src/components/Config/ConfigPage.styles.ts b/frontend/src/components/Config/ConfigPage.styles.ts new file mode 100644 index 0000000000..8c81d3af15 --- /dev/null +++ b/frontend/src/components/Config/ConfigPage.styles.ts @@ -0,0 +1,25 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +export const useConfigPageStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + height: '100%', + width: '100%', + minWidth: 0, + maxWidth: '100%', + backgroundColor: tokens.colorNeutralBackground2, + }, + tabBar: { + flexShrink: 0, + paddingLeft: tokens.spacingHorizontalXXL, + paddingRight: tokens.spacingHorizontalXXL, + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + backgroundColor: tokens.colorNeutralBackground1, + }, + tabPanel: { + flex: '1 1 auto', + minHeight: 0, + overflow: 'auto', + }, +}) diff --git a/frontend/src/components/Config/ConfigPage.test.tsx b/frontend/src/components/Config/ConfigPage.test.tsx new file mode 100644 index 0000000000..63070276a7 --- /dev/null +++ b/frontend/src/components/Config/ConfigPage.test.tsx @@ -0,0 +1,69 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FluentProvider, webLightTheme } from '@fluentui/react-components' + +import ConfigPage from './ConfigPage' + +jest.mock('./TargetConfig', () => { + const MockTargetConfig = () =>
+ MockTargetConfig.displayName = 'MockTargetConfig' + return { + __esModule: true, + default: MockTargetConfig, + } +}) + +jest.mock('./InitializerConfig', () => { + const MockInitializerConfig = () =>
+ MockInitializerConfig.displayName = 'MockInitializerConfig' + return { + __esModule: true, + default: MockInitializerConfig, + } +}) + +const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} +) + +describe('ConfigPage', () => { + it('shows the Targets tab by default', () => { + render( + + + , + ) + + expect(screen.getByTestId('target-config-panel')).toBeInTheDocument() + expect(screen.queryByTestId('initializer-config-panel')).not.toBeInTheDocument() + }) + + it('switches to the Initializers tab on click', async () => { + const user = userEvent.setup() + render( + + + , + ) + + await user.click(screen.getByTestId('config-tab-initializers')) + + expect(screen.getByTestId('initializer-config-panel')).toBeInTheDocument() + expect(screen.queryByTestId('target-config-panel')).not.toBeInTheDocument() + }) + + it('switches back to the Targets tab on click', async () => { + const user = userEvent.setup() + render( + + + , + ) + + await user.click(screen.getByTestId('config-tab-initializers')) + await user.click(screen.getByTestId('config-tab-targets')) + + expect(screen.getByTestId('target-config-panel')).toBeInTheDocument() + expect(screen.queryByTestId('initializer-config-panel')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/Config/ConfigPage.tsx b/frontend/src/components/Config/ConfigPage.tsx new file mode 100644 index 0000000000..f107b5f007 --- /dev/null +++ b/frontend/src/components/Config/ConfigPage.tsx @@ -0,0 +1,44 @@ +import { useState } from 'react' +import { Tab, TabList } from '@fluentui/react-components' +import type { SelectTabData, SelectTabEvent } from '@fluentui/react-components' + +import type { TargetInstance } from '../../types' +import TargetConfig from './TargetConfig' +import InitializerConfig from './InitializerConfig' +import { useConfigPageStyles } from './ConfigPage.styles' + +interface ConfigPageProps { + activeTarget: TargetInstance | null + onSetActiveTarget: (target: TargetInstance) => void +} + +type ConfigTab = 'targets' | 'initializers' + +export default function ConfigPage({ activeTarget, onSetActiveTarget }: ConfigPageProps) { + const styles = useConfigPageStyles() + const [selectedTab, setSelectedTab] = useState('targets') + + const handleTabSelect = (_: SelectTabEvent, data: SelectTabData): void => { + setSelectedTab(data.value as ConfigTab) + } + + return ( +
+ + Targets + Initializers + + +
+ {selectedTab === 'targets' && ( + + )} + {selectedTab === 'initializers' && } +
+
+ ) +} diff --git a/frontend/src/components/Config/InitializerConfig.styles.ts b/frontend/src/components/Config/InitializerConfig.styles.ts new file mode 100644 index 0000000000..804e5d5b4f --- /dev/null +++ b/frontend/src/components/Config/InitializerConfig.styles.ts @@ -0,0 +1,42 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +export const useInitializerConfigStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalL, + padding: tokens.spacingVerticalXXL, + '@media (max-width: 600px)': { + padding: `${tokens.spacingVerticalL} ${tokens.spacingHorizontalM}`, + }, + }, + header: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + flexWrap: 'wrap', + gap: tokens.spacingVerticalM, + }, + headerText: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXS, + }, + headerActions: { + display: 'flex', + alignItems: 'center', + gap: tokens.spacingHorizontalM, + }, + loadingState: { + display: 'flex', + justifyContent: 'center', + padding: tokens.spacingVerticalXXXL, + }, + emptyState: { + padding: tokens.spacingVerticalXL, + color: tokens.colorNeutralForeground3, + }, + message: { + width: '100%', + }, +}) diff --git a/frontend/src/components/Config/InitializerConfig.test.tsx b/frontend/src/components/Config/InitializerConfig.test.tsx new file mode 100644 index 0000000000..1f72a1e831 --- /dev/null +++ b/frontend/src/components/Config/InitializerConfig.test.tsx @@ -0,0 +1,192 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FluentProvider, webLightTheme } from '@fluentui/react-components' + +import { initializersApi } from '@/services/api' + +import InitializerConfig from './InitializerConfig' + +jest.mock('@/services/api', () => ({ + initializersApi: { + getSettings: jest.fn(), + updateSettings: jest.fn(), + clearSettings: jest.fn(), + applyNow: jest.fn(), + }, +})) + +jest.mock('./InitializerTable', () => { + const MockInitializerTable = ({ + items, + onSave, + onApply, + onReset, + }: { + items: Array<{ initializer_name: string }> + onSave: (initializerName: string, request: { enabled: boolean }) => Promise + onApply: (initializerName: string, parameters?: Record | null) => Promise + onReset: (initializerName: string) => Promise + }) => ( +
+ {items.length} + + + +
+ ) + MockInitializerTable.displayName = 'MockInitializerTable' + return { + __esModule: true, + default: MockInitializerTable, + } +}) + +const mockedInitializersApi = initializersApi as jest.Mocked + +const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} +) + +const SAMPLE_RESPONSE = { + items: [ + { + initializer_name: 'target', + initializer_type: 'TargetInitializer', + description: 'Registers targets.', + required_env_vars: [], + supported_parameters: [], + enabled: true, + parameters: null, + order_index: 0, + saved_order_index: null, + source: 'baseline' as const, + }, + ], +} + +describe('InitializerConfig', () => { + beforeEach(() => { + jest.clearAllMocks() + mockedInitializersApi.getSettings.mockResolvedValue(SAMPLE_RESPONSE) + mockedInitializersApi.updateSettings.mockResolvedValue({ + initializer_name: 'target', + enabled: false, + parameters: null, + order_index: null, + }) + mockedInitializersApi.applyNow.mockResolvedValue({ + initializer_name: 'target', + status: 'applied', + applied_parameters: { tags: ['extra'] }, + }) + mockedInitializersApi.clearSettings.mockResolvedValue() + }) + + it('should show loading state initially', () => { + mockedInitializersApi.getSettings.mockReturnValue(new Promise(() => {})) + + render( + + + , + ) + + expect(screen.getByText('Loading initializer settings...')).toBeInTheDocument() + }) + + it('should render fetched initializer settings', async () => { + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByTestId('initializer-table')).toBeInTheDocument() + expect(screen.getByTestId('initializer-count')).toHaveTextContent('1') + }) + }) + + it('should refresh settings when the refresh button is clicked', async () => { + const user = userEvent.setup() + + render( + + + , + ) + + await waitFor(() => { + expect(mockedInitializersApi.getSettings).toHaveBeenCalledTimes(1) + }) + + await user.click(screen.getByRole('button', { name: 'Refresh' })) + + await waitFor(() => { + expect(mockedInitializersApi.getSettings).toHaveBeenCalledTimes(2) + }) + }) + + it('should save initializer settings and show success feedback', async () => { + const user = userEvent.setup() + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByTestId('initializer-table')).toBeInTheDocument() + }) + + await user.click(screen.getByRole('button', { name: 'Save target' })) + + await waitFor(() => { + expect(mockedInitializersApi.updateSettings).toHaveBeenCalledWith('target', { enabled: false }) + expect(screen.getByText('Saved settings for target.')).toBeInTheDocument() + }) + }) + + it('should apply an initializer and show success feedback', async () => { + const user = userEvent.setup() + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByTestId('initializer-table')).toBeInTheDocument() + }) + + await user.click(screen.getByRole('button', { name: 'Apply target' })) + + await waitFor(() => { + expect(mockedInitializersApi.applyNow).toHaveBeenCalledWith('target', { parameters: { tags: ['extra'] } }) + expect(screen.getByText('Applied target.')).toBeInTheDocument() + }) + }) + + it('should clear saved settings and show success feedback', async () => { + const user = userEvent.setup() + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByTestId('initializer-table')).toBeInTheDocument() + }) + + await user.click(screen.getByRole('button', { name: 'Reset target' })) + + await waitFor(() => { + expect(mockedInitializersApi.clearSettings).toHaveBeenCalledWith('target') + expect(screen.getByText('Cleared saved settings for target.')).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/src/components/Config/InitializerConfig.tsx b/frontend/src/components/Config/InitializerConfig.tsx new file mode 100644 index 0000000000..8cc89edef9 --- /dev/null +++ b/frontend/src/components/Config/InitializerConfig.tsx @@ -0,0 +1,168 @@ +import { useEffect, useState } from 'react' +import { + Button, + MessageBar, + MessageBarBody, + Spinner, + Text, +} from '@fluentui/react-components' +import { ArrowSyncRegular } from '@fluentui/react-icons' + +import { initializersApi } from '@/services/api' +import { toApiError } from '@/services/errors' +import type { + EffectiveInitializerSetting, + UpdateInitializerSettingRequest, +} from '@/types' + +import InitializerTable from './InitializerTable' +import { useInitializerConfigStyles } from './InitializerConfig.styles' + +interface StatusMessage { + intent: 'success' | 'error' + text: string +} + +export default function InitializerConfig() { + const styles = useInitializerConfigStyles() + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(true) + const [statusMessage, setStatusMessage] = useState(null) + const [refetchCount, setRefetchCount] = useState(0) + const [savingInitializerName, setSavingInitializerName] = useState(null) + const [applyingInitializerName, setApplyingInitializerName] = useState(null) + const [resettingInitializerName, setResettingInitializerName] = useState(null) + + useEffect(() => { + let cancelled = false + + const loadSettingsAsync = async (): Promise => { + try { + const response = await initializersApi.getSettings() + if (cancelled) { + return + } + setItems(response.items) + } catch (error) { + if (cancelled) { + return + } + setStatusMessage({ intent: 'error', text: toApiError(error).detail }) + } finally { + if (!cancelled) { + setLoading(false) + } + } + } + + void loadSettingsAsync() + return () => { + cancelled = true + } + }, [refetchCount]) + + const refreshSettings = (clearStatusMessage: boolean = true): void => { + setLoading(true) + if (clearStatusMessage) { + setStatusMessage(null) + } + setRefetchCount((currentCount) => currentCount + 1) + } + + const handleSave = async ( + initializerName: string, + request: UpdateInitializerSettingRequest, + ): Promise => { + setSavingInitializerName(initializerName) + + try { + await initializersApi.updateSettings(initializerName, request) + setStatusMessage({ intent: 'success', text: `Saved settings for ${initializerName}.` }) + refreshSettings(false) + } catch (error) { + setStatusMessage({ intent: 'error', text: toApiError(error).detail }) + } finally { + setSavingInitializerName(null) + } + } + + const handleApply = async ( + initializerName: string, + parameters?: Record | null, + ): Promise => { + setApplyingInitializerName(initializerName) + + try { + // An explicit {} (rather than null/undefined) tells the backend "apply with no + // parameters," distinct from omitting parameters entirely, which would fall back + // to any saved override parameters instead. + await initializersApi.applyNow(initializerName, { parameters: parameters ?? {} }) + setStatusMessage({ intent: 'success', text: `Applied ${initializerName}.` }) + } catch (error) { + setStatusMessage({ intent: 'error', text: toApiError(error).detail }) + } finally { + setApplyingInitializerName(null) + } + } + + const handleReset = async (initializerName: string): Promise => { + setResettingInitializerName(initializerName) + + try { + await initializersApi.clearSettings(initializerName) + setStatusMessage({ intent: 'success', text: `Cleared saved settings for ${initializerName}.` }) + refreshSettings(false) + } catch (error) { + setStatusMessage({ intent: 'error', text: toApiError(error).detail }) + } finally { + setResettingInitializerName(null) + } + } + + return ( +
+
+
+ Initializers + + Review the effective initializer order, save overrides to the database, or apply a single initializer now. + +
+
+ +
+
+ + {statusMessage && ( + + {statusMessage.text} + + )} + + {loading ? ( +
+ +
+ ) : items.length === 0 ? ( + No initializer settings are available. + ) : ( + + )} +
+ ) +} diff --git a/frontend/src/components/Config/InitializerTable.styles.ts b/frontend/src/components/Config/InitializerTable.styles.ts new file mode 100644 index 0000000000..3cd64feee9 --- /dev/null +++ b/frontend/src/components/Config/InitializerTable.styles.ts @@ -0,0 +1,56 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +export const useInitializerTableStyles = makeStyles({ + tableContainer: { + width: '100%', + overflowX: 'auto', + }, + table: { + width: '100%', + tableLayout: 'fixed', + }, + cell: { + verticalAlign: 'top', + paddingTop: tokens.spacingVerticalM, + }, + parametersCell: { + minWidth: '26rem', + verticalAlign: 'top', + paddingTop: tokens.spacingVerticalM, + }, + parameterList: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXXS, + marginBottom: tokens.spacingVerticalS, + }, + parameterHint: { + color: tokens.colorNeutralForeground3, + }, + parametersEditor: { + fontFamily: 'Consolas, "Courier New", monospace', + minHeight: '10rem', + width: '100%', + }, + actionsCell: { + verticalAlign: 'top', + paddingTop: tokens.spacingVerticalM, + }, + actionsColumn: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXS, + }, + sourceBadge: { + textTransform: 'capitalize', + }, + errorText: { + color: tokens.colorPaletteRedForeground1, + marginTop: tokens.spacingVerticalXS, + }, + envVarText: { + color: tokens.colorNeutralForeground3, + display: 'block', + marginTop: tokens.spacingVerticalXXS, + }, +}) diff --git a/frontend/src/components/Config/InitializerTable.test.tsx b/frontend/src/components/Config/InitializerTable.test.tsx new file mode 100644 index 0000000000..3b37b739af --- /dev/null +++ b/frontend/src/components/Config/InitializerTable.test.tsx @@ -0,0 +1,181 @@ +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FluentProvider, webLightTheme } from '@fluentui/react-components' + +import type { EffectiveInitializerSetting } from '@/types' + +import InitializerTable from './InitializerTable' + +const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} +) + +const SAMPLE_ITEMS: EffectiveInitializerSetting[] = [ + { + initializer_name: 'target', + initializer_type: 'TargetInitializer', + description: 'Registers targets.', + required_env_vars: ['AZURE_OPENAI_ENDPOINT'], + supported_parameters: [ + { + name: 'tags', + type_name: 'list[str]', + required: false, + default: ['default'], + choices: null, + is_list: true, + description: 'Target tags.', + }, + ], + enabled: true, + parameters: { tags: ['default'] }, + order_index: 0, + saved_order_index: 2, + source: 'baseline+override', + }, + { + initializer_name: 'scorer', + initializer_type: 'ScorerInitializer', + description: 'Registers scorers.', + required_env_vars: [], + supported_parameters: [], + enabled: true, + parameters: null, + order_index: 1, + saved_order_index: null, + source: 'baseline', + }, +] + +describe('InitializerTable', () => { + const defaultProps = { + items: SAMPLE_ITEMS, + onSave: jest.fn().mockResolvedValue(undefined), + onApply: jest.fn().mockResolvedValue(undefined), + onReset: jest.fn().mockResolvedValue(undefined), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should render initializer rows and metadata', () => { + render( + + + , + ) + + expect(screen.getByRole('table', { name: 'Initializer settings' })).toBeInTheDocument() + expect(screen.getByText('target')).toBeInTheDocument() + expect(screen.getByText('Customized')).toBeInTheDocument() + }) + + it('should show the description as hover text on the initializer name', async () => { + const user = userEvent.setup() + + render( + + + , + ) + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument() + + await user.hover(screen.getByText('target')) + + expect(await screen.findByRole('tooltip')).toHaveTextContent('Registers targets.') + }) + + it('should call onSave with parsed settings', async () => { + const user = userEvent.setup() + + render( + + + , + ) + + const row = screen.getByTestId('initializer-row-target') + await user.clear(within(row).getByRole('textbox', { name: 'Parameters JSON' })) + await user.click(within(row).getByRole('textbox', { name: 'Parameters JSON' })) + await user.paste('{"tags":["extra"]}') + await user.click(within(row).getByRole('button', { name: 'Save' })) + + expect(defaultProps.onSave).toHaveBeenCalledWith('target', { + enabled: true, + parameters: { tags: ['extra'] }, + order_index: 2, + }) + }) + + it('should disable Save until parameters change, while keeping Apply now enabled', async () => { + const user = userEvent.setup() + + render( + + + , + ) + + const row = screen.getByTestId('initializer-row-target') + expect(within(row).getByRole('button', { name: 'Save' })).toBeDisabled() + expect(within(row).getByRole('button', { name: 'Apply now' })).toBeEnabled() + + await user.clear(within(row).getByRole('textbox', { name: 'Parameters JSON' })) + await user.click(within(row).getByRole('textbox', { name: 'Parameters JSON' })) + await user.paste('{"tags":["extra"]}') + + expect(within(row).getByRole('button', { name: 'Save' })).toBeEnabled() + expect(within(row).getByRole('button', { name: 'Apply now' })).toBeEnabled() + }) + + it('should call onApply with parsed parameters', async () => { + const user = userEvent.setup() + + render( + + + , + ) + + const row = screen.getByTestId('initializer-row-scorer') + await user.clear(within(row).getByRole('textbox', { name: 'Parameters JSON' })) + await user.click(within(row).getByRole('textbox', { name: 'Parameters JSON' })) + await user.paste('{"mode":"strict"}') + await user.click(within(row).getByRole('button', { name: 'Apply now' })) + + expect(defaultProps.onApply).toHaveBeenCalledWith('scorer', { mode: 'strict' }) + }) + + it('should show a validation error for invalid JSON', async () => { + const user = userEvent.setup() + + render( + + + , + ) + + const row = screen.getByTestId('initializer-row-target') + await user.clear(within(row).getByRole('textbox', { name: 'Parameters JSON' })) + await user.click(within(row).getByRole('textbox', { name: 'Parameters JSON' })) + await user.paste('{"tags":') + await user.click(within(row).getByRole('button', { name: 'Save' })) + + expect(await within(row).findByRole('alert')).toHaveTextContent('Unexpected end of JSON input') + expect(defaultProps.onSave).not.toHaveBeenCalled() + }) + + it('should only show reset action for saved overrides', () => { + render( + + + , + ) + + expect(screen.getByRole('button', { name: 'Reset saved' })).toBeInTheDocument() + const baselineRow = screen.getByTestId('initializer-row-scorer') + expect(within(baselineRow).queryByRole('button', { name: 'Reset saved' })).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/Config/InitializerTable.tsx b/frontend/src/components/Config/InitializerTable.tsx new file mode 100644 index 0000000000..190fb44a5a --- /dev/null +++ b/frontend/src/components/Config/InitializerTable.tsx @@ -0,0 +1,301 @@ +import { useState } from 'react' +import { + Badge, + Button, + Field, + Table, + TableBody, + TableCell, + TableHeader, + TableHeaderCell, + TableRow, + Text, + Textarea, + Tooltip, +} from '@fluentui/react-components' + +import type { + EffectiveInitializerSetting, + UpdateInitializerSettingRequest, +} from '@/types' + +import { useInitializerTableStyles } from './InitializerTable.styles' + +interface RowDraft { + enabled: boolean + savedOrderIndex: number | null + parametersText: string + initialParametersText: string + error: string | null +} + +interface InitializerTableProps { + items: EffectiveInitializerSetting[] + savingInitializerName?: string | null + applyingInitializerName?: string | null + resettingInitializerName?: string | null + onSave: (initializerName: string, request: UpdateInitializerSettingRequest) => Promise + onApply: (initializerName: string, parameters?: Record | null) => Promise + onReset: (initializerName: string) => Promise +} + +function serializeParameters(parameters?: Record | null): string { + return JSON.stringify(parameters ?? {}, null, 2) +} + +function parseParametersText(text: string): Record | null { + const trimmed = text.trim() + if (!trimmed) { + return null + } + + const parsed: unknown = JSON.parse(trimmed) + if (parsed === null) { + return null + } + if (typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Parameters must be a JSON object.') + } + return parsed as Record +} + +function formatSupportedParameterSummary(initializer: EffectiveInitializerSetting): string[] { + if (initializer.supported_parameters.length === 0) { + return ['No declared parameters.'] + } + + return initializer.supported_parameters.map((parameter) => { + const requiredLabel = parameter.required ? 'required' : 'optional' + return `${parameter.name} (${parameter.type_name}, ${requiredLabel})` + }) +} + +const SOURCE_DETAILS: Record< + EffectiveInitializerSetting['source'], + { label: string; tooltip: string } +> = { + baseline: { + label: 'From config file', + tooltip: + "This initializer comes from your deployment's config file (.pyrit_conf) and has not been changed here.", + }, + override: { + label: 'Added in GUI', + tooltip: + "This initializer is not in your deployment's config file. It was added and is saved only here in the GUI.", + }, + 'baseline+override': { + label: 'Customized', + tooltip: + "This initializer is defined in your deployment's config file, but its settings have been changed and saved here.", + }, +} + +export default function InitializerTable({ + items, + savingInitializerName = null, + applyingInitializerName = null, + resettingInitializerName = null, + onSave, + onApply, + onReset, +}: InitializerTableProps) { + const styles = useInitializerTableStyles() + const [seenItems, setSeenItems] = useState(null) + const [drafts, setDrafts] = useState>({}) + + if (items !== seenItems) { + const nextDrafts = items.reduce>((accumulator, item) => { + const initialParametersText = serializeParameters(item.parameters) + accumulator[item.initializer_name] = { + enabled: item.enabled, + savedOrderIndex: item.saved_order_index ?? null, + parametersText: initialParametersText, + initialParametersText, + error: null, + } + return accumulator + }, {}) + setSeenItems(items) + setDrafts(nextDrafts) + } + + const updateDraft = (initializerName: string, patch: Partial): void => { + setDrafts((currentDrafts) => ({ + ...currentDrafts, + [initializerName]: { + ...currentDrafts[initializerName], + ...patch, + }, + })) + } + + const parseDraft = (initializerName: string): UpdateInitializerSettingRequest | null => { + const draft = drafts[initializerName] + if (!draft) { + return null + } + + try { + return { + enabled: draft.enabled, + parameters: parseParametersText(draft.parametersText), + order_index: draft.savedOrderIndex, + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Invalid initializer settings.' + updateDraft(initializerName, { error: message }) + return null + } + } + + const handleSave = async (initializerName: string): Promise => { + const parsedDraft = parseDraft(initializerName) + if (!parsedDraft) { + return + } + + updateDraft(initializerName, { error: null }) + await onSave(initializerName, parsedDraft) + } + + const handleApply = async (initializerName: string): Promise => { + const parsedDraft = parseDraft(initializerName) + if (!parsedDraft) { + return + } + + updateDraft(initializerName, { error: null }) + await onApply(initializerName, parsedDraft.parameters) + } + + const handleReset = async (initializerName: string): Promise => { + updateDraft(initializerName, { error: null }) + await onReset(initializerName) + } + + return ( +
+ + + + Name + + + Source + + + Parameters + Actions + + + + {items.map((item) => { + const draft = drafts[item.initializer_name] + if (!draft) { + return null + } + + const isSaving = savingInitializerName === item.initializer_name + const isApplying = applyingInitializerName === item.initializer_name + const isResetting = resettingInitializerName === item.initializer_name + const isBusy = isSaving || isApplying || isResetting + const isUnchanged = draft.parametersText === draft.initialParametersText + + return ( + + + + {item.initializer_name} + + {item.required_env_vars.length > 0 && ( + + Required env vars: {item.required_env_vars.join(', ')} + + )} + + + + + {SOURCE_DETAILS[item.source].label} + + + + +
+ {formatSupportedParameterSummary(item).map((summary) => ( + + {summary} + + ))} +
+ +