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.test.tsx b/frontend/src/components/Config/ConfigPage.test.tsx new file mode 100644 index 0000000000..1e1aae339f --- /dev/null +++ b/frontend/src/components/Config/ConfigPage.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/react' +import { FluentProvider, webLightTheme } from '@fluentui/react-components' + +import ConfigPage from './ConfigPage' + +jest.mock('./TargetConfig', () => { + const MockTargetConfig = () =>
+ MockTargetConfig.displayName = 'MockTargetConfig' + return { + __esModule: true, + default: MockTargetConfig, + } +}) + +const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} +) + +describe('ConfigPage', () => { + it('renders the target configuration panel', () => { + render( + + + , + ) + + expect(screen.getByTestId('target-config-panel')).toBeInTheDocument() + }) +}) + diff --git a/frontend/src/components/Config/ConfigPage.tsx b/frontend/src/components/Config/ConfigPage.tsx new file mode 100644 index 0000000000..1b56392db2 --- /dev/null +++ b/frontend/src/components/Config/ConfigPage.tsx @@ -0,0 +1,12 @@ +import type { TargetInstance } from '../../types' +import TargetConfig from './TargetConfig' + +interface ConfigPageProps { + activeTarget: TargetInstance | null + onSetActiveTarget: (target: TargetInstance) => void +} + +export default function ConfigPage({ activeTarget, onSetActiveTarget }: ConfigPageProps) { + return +} + diff --git a/frontend/src/components/Config/InitializerConfig.styles.ts b/frontend/src/components/Config/InitializerConfig.styles.ts new file mode 100644 index 0000000000..9d023a26ef --- /dev/null +++ b/frontend/src/components/Config/InitializerConfig.styles.ts @@ -0,0 +1,38 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +export const useInitializerConfigStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalL, + }, + 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..cf258b10f0 --- /dev/null +++ b/frontend/src/components/Config/InitializerConfig.test.tsx @@ -0,0 +1,193 @@ +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('./InitializerList', () => { + const MockInitializerList = ({ + 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} + + + +
+ ) + MockInitializerList.displayName = 'MockInitializerList' + return { + __esModule: true, + default: MockInitializerList, + } +}) + +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..d64f29f24c --- /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 InitializerList from './InitializerList' +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 ( +
+
+
+ + 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/InitializerList.styles.ts b/frontend/src/components/Config/InitializerList.styles.ts new file mode 100644 index 0000000000..3a42ba34a8 --- /dev/null +++ b/frontend/src/components/Config/InitializerList.styles.ts @@ -0,0 +1,63 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +export const useInitializerListStyles = makeStyles({ + list: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalL, + width: '100%', + }, + card: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalM, + padding: tokens.spacingVerticalL, + border: `1px solid ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusLarge, + }, + cardHeader: { + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'space-between', + flexWrap: 'wrap', + gap: tokens.spacingHorizontalM, + }, + titleGroup: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXXS, + }, + 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%', + }, + actionsRow: { + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + gap: tokens.spacingHorizontalS, + }, + 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/InitializerList.test.tsx b/frontend/src/components/Config/InitializerList.test.tsx new file mode 100644 index 0000000000..3fe66cc559 --- /dev/null +++ b/frontend/src/components/Config/InitializerList.test.tsx @@ -0,0 +1,182 @@ +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 InitializerList from './InitializerList' + +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('InitializerList', () => { + 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('list', { 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/InitializerList.tsx b/frontend/src/components/Config/InitializerList.tsx new file mode 100644 index 0000000000..833303cc61 --- /dev/null +++ b/frontend/src/components/Config/InitializerList.tsx @@ -0,0 +1,271 @@ +import { useState } from 'react' +import { Badge, Button, Field, Text, Textarea, Tooltip } from '@fluentui/react-components' + +import type { + EffectiveInitializerSetting, + UpdateInitializerSettingRequest, +} from '@/types' + +import { useInitializerListStyles } from './InitializerList.styles' + +interface RowDraft { + enabled: boolean + savedOrderIndex: number | null + parametersText: string + initialParametersText: string + error: string | null +} + +interface InitializerListProps { + 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 InitializerList({ + items, + savingInitializerName = null, + applyingInitializerName = null, + resettingInitializerName = null, + onSave, + onApply, + onReset, +}: InitializerListProps) { + const styles = useInitializerListStyles() + 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 ( +
+ {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} + + ))} +
+ +