diff --git a/crates/admin-ui/ui/src/components/layout/sidebar.tsx b/crates/admin-ui/ui/src/components/layout/sidebar.tsx index d418286..bab41c7 100644 --- a/crates/admin-ui/ui/src/components/layout/sidebar.tsx +++ b/crates/admin-ui/ui/src/components/layout/sidebar.tsx @@ -7,6 +7,7 @@ import { LayoutDashboard, Monitor, Moon, + Scale, Settings, Shield, Server, @@ -28,6 +29,7 @@ const NAV_GROUPS = [ { to: '/providers', labelKey: 'nav.providers', icon: Server }, { to: '/models', labelKey: 'nav.models', icon: Boxes }, { to: '/guardrails', labelKey: 'nav.guardrails', icon: Shield }, + { to: '/policies', labelKey: 'nav.policies', icon: Scale }, { to: '/apikeys', labelKey: 'nav.apiKeys', icon: KeyRound }, ], }, diff --git a/crates/admin-ui/ui/src/components/policies/policy-form.tsx b/crates/admin-ui/ui/src/components/policies/policy-form.tsx new file mode 100644 index 0000000..2589db8 --- /dev/null +++ b/crates/admin-ui/ui/src/components/policies/policy-form.tsx @@ -0,0 +1,541 @@ +import { Link } from '@tanstack/react-router'; +import { Plus, RefreshCw, Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { + POLICY_STAGE_VARIANTS, + type Guardrail, + type Policy, + type PolicyStage, +} from '@/lib/api/types'; +import { useGuardrails } from '@/lib/queries/guardrails'; + +export interface PolicyFormProps { + initial?: Policy; + onSubmit: (data: Policy) => void | Promise; + onCancel: () => void; + isPending: boolean; + error?: string; + submitLabel: string; + extraActions?: React.ReactNode; +} + +type PolicyActionDraft = { + stages: PolicyStage[]; + guardrail_ids: string[]; +}; + +type PolicyFormState = { + name: string; + enabled: boolean; + priority: string; + when: string; + actions: PolicyActionDraft[]; +}; + +type GuardrailOption = { + id: string; + name?: string; + type?: Guardrail['type']; + exists: boolean; +}; + +const POLICY_STAGES = Array.from(POLICY_STAGE_VARIANTS); + +function createEmptyAction(): PolicyActionDraft { + return { + stages: [...POLICY_STAGES], + guardrail_ids: [], + }; +} + +function normalizeStages(stages: PolicyStage[] | undefined): PolicyStage[] { + const selected = new Set(stages ?? POLICY_STAGES); + return POLICY_STAGES.filter((stage) => selected.has(stage)); +} + +function buildInitialState(initial?: Policy): PolicyFormState { + return { + name: initial?.name ?? '', + enabled: initial?.enabled ?? true, + priority: String(initial?.priority ?? 0), + when: initial?.when ?? '', + actions: + initial && initial.actions.length > 0 + ? initial.actions.map((action) => ({ + stages: normalizeStages(action.config.stages), + guardrail_ids: [...action.config.guardrail_ids], + })) + : [createEmptyAction()], + }; +} + +function parsePriority(raw: string): number | undefined { + const trimmed = raw.trim(); + if (!trimmed) { + return 0; + } + + if (!/^-?\d+$/.test(trimmed)) { + return undefined; + } + + return Number(trimmed); +} + +export function PolicyForm({ + initial, + onSubmit, + onCancel, + isPending, + error, + submitLabel, + extraActions, +}: PolicyFormProps) { + const { t } = useTranslation(); + const [clientError, setClientError] = useState(); + const [state, setState] = useState(() => buildInitialState(initial)); + const guardrailsQuery = useGuardrails(); + + const guardrailOptionsById = new Map(); + for (const { key, value } of guardrailsQuery.data?.list ?? []) { + const id = key.replace('/guardrails/', ''); + guardrailOptionsById.set(id, { + id, + name: value.name, + type: value.type, + exists: true, + }); + } + + for (const action of state.actions) { + for (const guardrailId of action.guardrail_ids) { + if (!guardrailOptionsById.has(guardrailId)) { + guardrailOptionsById.set(guardrailId, { + id: guardrailId, + exists: false, + }); + } + } + } + + const guardrailOptions = Array.from(guardrailOptionsById.values()).sort( + (a, b) => a.id.localeCompare(b.id), + ); + + let guardrailHint = t('policies.form.guardrailsHint'); + if (guardrailsQuery.isLoading) { + guardrailHint = t('policies.form.guardrailsLoading'); + } else if (guardrailsQuery.isError) { + guardrailHint = t('policies.form.guardrailsLoadError'); + } else if (guardrailOptions.length === 0) { + guardrailHint = t('policies.form.guardrailsEmpty'); + } + + function updateState(updater: (current: PolicyFormState) => PolicyFormState) { + setClientError(undefined); + setState((current) => updater(current)); + } + + function updateAction( + index: number, + updater: (current: PolicyActionDraft) => PolicyActionDraft, + ) { + updateState((current) => ({ + ...current, + actions: current.actions.map((action, actionIndex) => + actionIndex === index ? updater(action) : action, + ), + })); + } + + function handleToggleStage( + index: number, + stage: PolicyStage, + checked: boolean, + ) { + updateAction(index, (action) => { + const selected = new Set(action.stages); + if (checked) { + selected.add(stage); + } else { + selected.delete(stage); + } + + return { + ...action, + stages: POLICY_STAGES.filter((item) => selected.has(item)), + }; + }); + } + + function handleToggleGuardrail( + index: number, + guardrailId: string, + checked: boolean, + ) { + updateAction(index, (action) => ({ + ...action, + guardrail_ids: checked + ? action.guardrail_ids.includes(guardrailId) + ? action.guardrail_ids + : [...action.guardrail_ids, guardrailId] + : action.guardrail_ids.filter((id) => id !== guardrailId), + })); + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + + const name = state.name.trim(); + if (!name) { + setClientError(t('policies.form.nameRequired')); + return; + } + + const when = state.when.trim(); + if (!when) { + setClientError(t('policies.form.whenRequired')); + return; + } + + const priority = parsePriority(state.priority); + if (priority == null) { + setClientError(t('policies.form.priorityInvalid')); + return; + } + + if (state.actions.length === 0) { + setClientError(t('policies.form.actionsRequired')); + return; + } + + if (state.actions.some((action) => action.stages.length === 0)) { + setClientError(t('policies.form.stagesRequired')); + return; + } + + if (state.actions.some((action) => action.guardrail_ids.length === 0)) { + setClientError(t('policies.form.guardrailsRequired')); + return; + } + + setClientError(undefined); + await onSubmit({ + name, + enabled: state.enabled, + priority, + when, + actions: state.actions.map((action) => ({ + type: 'guardrail', + config: { + stages: action.stages, + guardrail_ids: action.guardrail_ids, + }, + })), + }); + } + + return ( +
+
+

+ {t('policies.form.basicInfo')} +

+ +
+ + { + updateState((current) => ({ + ...current, + name: event.target.value, + })); + }} + placeholder={t('policies.form.namePlaceholder')} + /> + + + + + + + + { + updateState((current) => ({ + ...current, + priority: event.target.value, + })); + }} + placeholder="0" + /> + + + +