From d55dce34785bcc9515ab7e8fa1a8a08163bce85f Mon Sep 17 00:00:00 2001 From: bzp2010 Date: Tue, 19 May 2026 10:24:31 +0800 Subject: [PATCH 1/2] feat(guardrail): admin ui --- .../components/guardrails/guardrail-form.tsx | 486 ++++++++++++++++++ .../ui/src/components/layout/sidebar.tsx | 2 + crates/admin-ui/ui/src/i18n/locales/en.json | 66 +++ .../admin-ui/ui/src/i18n/locales/zh-CN.json | 66 +++ crates/admin-ui/ui/src/lib/api/client.ts | 19 + crates/admin-ui/ui/src/lib/api/types.ts | 31 ++ .../admin-ui/ui/src/lib/queries/guardrails.ts | 73 +++ crates/admin-ui/ui/src/routeTree.gen.ts | 63 +++ .../ui/src/routes/_layout/guardrails/$id.tsx | 100 ++++ .../src/routes/_layout/guardrails/create.tsx | 61 +++ .../src/routes/_layout/guardrails/index.tsx | 221 ++++++++ 11 files changed, 1188 insertions(+) create mode 100644 crates/admin-ui/ui/src/components/guardrails/guardrail-form.tsx create mode 100644 crates/admin-ui/ui/src/lib/queries/guardrails.ts create mode 100644 crates/admin-ui/ui/src/routes/_layout/guardrails/$id.tsx create mode 100644 crates/admin-ui/ui/src/routes/_layout/guardrails/create.tsx create mode 100644 crates/admin-ui/ui/src/routes/_layout/guardrails/index.tsx diff --git a/crates/admin-ui/ui/src/components/guardrails/guardrail-form.tsx b/crates/admin-ui/ui/src/components/guardrails/guardrail-form.tsx new file mode 100644 index 0000000..96d1b9f --- /dev/null +++ b/crates/admin-ui/ui/src/components/guardrails/guardrail-form.tsx @@ -0,0 +1,486 @@ +import { useForm } from '@tanstack/react-form'; +import { Eye, EyeOff } from 'lucide-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from '@/components/ui/input-group'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { + GUARDRAIL_TYPE_VARIANTS, + type Guardrail, + type GuardrailType, +} from '@/lib/api/types'; + +export interface GuardrailFormProps { + initial?: Guardrail; + onSubmit: (data: Guardrail) => void | Promise; + onCancel: () => void; + isPending: boolean; + error?: string; + submitLabel: string; + extraActions?: React.ReactNode; +} + +const GUARDRAIL_TYPES = Array.from(GUARDRAIL_TYPE_VARIANTS); + +function trimOptional(value: string): string | undefined { + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +export function GuardrailForm({ + initial, + onSubmit, + onCancel, + isPending, + error, + submitLabel, + extraActions, +}: GuardrailFormProps) { + const { t } = useTranslation(); + const [clientError, setClientError] = useState(); + + const form = useForm({ + defaultValues: { + name: initial?.name ?? '', + type: initial?.type ?? ('regex' as GuardrailType), + pattern: initial?.type === 'regex' ? initial.config.pattern : '', + block_reason: + initial?.type === 'regex' ? (initial.config.block_reason ?? '') : '', + identifier: initial?.type === 'bedrock' ? initial.config.identifier : '', + version: initial?.type === 'bedrock' ? initial.config.version : '', + region: initial?.type === 'bedrock' ? initial.config.region : '', + access_key_id: + initial?.type === 'bedrock' ? initial.config.access_key_id : '', + secret_access_key: + initial?.type === 'bedrock' ? initial.config.secret_access_key : '', + session_token: + initial?.type === 'bedrock' ? (initial.config.session_token ?? '') : '', + endpoint: + initial?.type === 'bedrock' ? (initial.config.endpoint ?? '') : '', + }, + onSubmit: async ({ value }) => { + const name = value.name.trim(); + if (!name) { + setClientError(t('guardrails.form.nameRequired')); + return; + } + + if (value.type === 'regex') { + const pattern = value.pattern.trim(); + if (!pattern) { + setClientError(t('guardrails.form.patternRequired')); + return; + } + + setClientError(undefined); + await onSubmit({ + name, + type: 'regex', + config: { + pattern, + ...(trimOptional(value.block_reason) + ? { block_reason: trimOptional(value.block_reason) } + : {}), + }, + }); + return; + } + + const identifier = value.identifier.trim(); + const version = value.version.trim(); + const region = value.region.trim(); + const accessKeyId = value.access_key_id.trim(); + const secretAccessKey = value.secret_access_key.trim(); + + if (!identifier) { + setClientError(t('guardrails.form.identifierRequired')); + return; + } + + if (!version) { + setClientError(t('guardrails.form.versionRequired')); + return; + } + + if (!region) { + setClientError(t('guardrails.form.regionRequired')); + return; + } + + if (!accessKeyId) { + setClientError(t('guardrails.form.accessKeyIdRequired')); + return; + } + + if (!secretAccessKey) { + setClientError(t('guardrails.form.secretAccessKeyRequired')); + return; + } + + setClientError(undefined); + await onSubmit({ + name, + type: 'bedrock', + config: { + identifier, + version, + region, + access_key_id: accessKeyId, + secret_access_key: secretAccessKey, + ...(trimOptional(value.session_token) + ? { session_token: trimOptional(value.session_token) } + : {}), + ...(trimOptional(value.endpoint) + ? { endpoint: trimOptional(value.endpoint) } + : {}), + }, + }); + }, + }); + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + className="space-y-5" + > +
+

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

+ +
+ + {(field) => ( + + { + setClientError(undefined); + field.handleChange(e.target.value); + }} + onBlur={field.handleBlur} + placeholder={t('guardrails.form.namePlaceholder')} + /> + + )} + + + + {(field) => ( + + + + )} + +
+
+ + state.values.type}> + {(guardrailType) => ( +
+

+ {t('guardrails.form.config')} +

+ + {guardrailType === 'regex' ? ( +
+ + {(field) => ( + + { + setClientError(undefined); + field.handleChange(e.target.value); + }} + onBlur={field.handleBlur} + placeholder={t('guardrails.form.patternPlaceholder')} + /> + + )} + + + + {(field) => ( + + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + placeholder={t( + 'guardrails.form.blockReasonPlaceholder', + )} + /> + + )} + +
+ ) : ( +
+ + {(field) => ( + + { + setClientError(undefined); + field.handleChange(e.target.value); + }} + onBlur={field.handleBlur} + placeholder={t('guardrails.form.identifierPlaceholder')} + /> + + )} + + + + {(field) => ( + + { + setClientError(undefined); + field.handleChange(e.target.value); + }} + onBlur={field.handleBlur} + placeholder={t('guardrails.form.versionPlaceholder')} + /> + + )} + + + + {(field) => ( + + { + setClientError(undefined); + field.handleChange(e.target.value); + }} + onBlur={field.handleBlur} + placeholder="us-east-1" + /> + + )} + + + + {(field) => ( + + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + placeholder="https://bedrock-runtime.us-east-1.amazonaws.com" + /> + + )} + + + + {(field) => ( + + { + setClientError(undefined); + field.handleChange(e.target.value); + }} + onBlur={field.handleBlur} + placeholder="AKIA..." + autoComplete="off" + /> + + )} + + + + {(field) => ( + + { + setClientError(undefined); + field.handleChange(e.target.value); + }} + onBlur={field.handleBlur} + placeholder={t( + 'guardrails.form.secretAccessKeyPlaceholder', + )} + autoComplete="new-password" + showLabel={t('guardrails.form.showSecret')} + hideLabel={t('guardrails.form.hideSecret')} + /> + + )} + + + + {(field) => ( + + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + placeholder={t( + 'guardrails.form.sessionTokenPlaceholder', + )} + autoComplete="off" + /> + + )} + +
+ )} +
+ )} +
+ + {(clientError ?? error) && ( +

+ {clientError ?? error} +

+ )} + +
+ {extraActions ?? } +
+ + state.isSubmitting}> + {(isSubmitting) => ( + + )} + +
+
+
+ ); +} + +function Field({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} + {hint &&

{hint}

} +
+ ); +} + +type SecretInputProps = Omit< + React.ComponentProps, + 'type' +> & { + showLabel: string; + hideLabel: string; +}; + +function SecretInput({ + showLabel, + hideLabel, + disabled, + ...props +}: SecretInputProps) { + const [isVisible, setIsVisible] = useState(false); + + return ( + + + + + + setIsVisible((visible) => !visible)} + > + {isVisible ? : } + + + +

{isVisible ? hideLabel : showLabel}

+
+
+
+
+ ); +} diff --git a/crates/admin-ui/ui/src/components/layout/sidebar.tsx b/crates/admin-ui/ui/src/components/layout/sidebar.tsx index 8823d58..d418286 100644 --- a/crates/admin-ui/ui/src/components/layout/sidebar.tsx +++ b/crates/admin-ui/ui/src/components/layout/sidebar.tsx @@ -8,6 +8,7 @@ import { Monitor, Moon, Settings, + Shield, Server, Sun, Zap, @@ -26,6 +27,7 @@ const NAV_GROUPS = [ { to: '/playground', labelKey: 'nav.playground', icon: LayoutDashboard }, { to: '/providers', labelKey: 'nav.providers', icon: Server }, { to: '/models', labelKey: 'nav.models', icon: Boxes }, + { to: '/guardrails', labelKey: 'nav.guardrails', icon: Shield }, { to: '/apikeys', labelKey: 'nav.apiKeys', icon: KeyRound }, ], }, diff --git a/crates/admin-ui/ui/src/i18n/locales/en.json b/crates/admin-ui/ui/src/i18n/locales/en.json index dd8eeff..b462a64 100644 --- a/crates/admin-ui/ui/src/i18n/locales/en.json +++ b/crates/admin-ui/ui/src/i18n/locales/en.json @@ -5,6 +5,7 @@ "playground": "Playground", "providers": "Providers", "models": "Models", + "guardrails": "Guardrails", "apiKeys": "API Keys", "settings": "Settings" }, @@ -62,6 +63,10 @@ "deepseek": "DeepSeek", "bedrock": "AWS Bedrock" }, + "guardrailTypes": { + "regex": "Regex", + "bedrock": "AWS Bedrock" + }, "playground": { "title": "Playground", "addComparison": "Add Comparison", @@ -170,6 +175,67 @@ "concurrency": "Concurrency" } }, + "guardrails": { + "title": "Guardrails", + "addGuardrail": "Add Guardrail", + "deleteGuardrail": "Delete Guardrail", + "createTitle": "Create guardrail resource", + "createDesc": "Required: name, type, config. Config fields depend on the selected guardrail type.", + "editTitle": "Edit guardrail resource", + "createGuardrail": "Create Guardrail", + "columns": { + "id": "ID", + "name": "Name", + "type": "Type", + "config": "Config", + "action": "Action" + }, + "search": "Search guardrails…", + "count_one": "{{count}} guardrail", + "count_other": "{{count}} guardrails", + "empty": "No guardrails yet.", + "emptyAction": "Add one", + "errorLoad": "Failed to load guardrails.", + "errorLoadSingle": "Failed to load guardrail.", + "deleteConfirm": "Delete guardrail \"{{id}}\"?", + "regexSummary": "Pattern: {{pattern}}", + "bedrockSummary": "{{identifier}} · v{{version}} · {{region}}", + "form": { + "basicInfo": "Basic Information", + "nameLabel": "Name *", + "namePlaceholder": "e.g. tenant-a-output-guardrail", + "nameRequired": "Please enter a guardrail name before saving.", + "typeLabel": "Guardrail Type *", + "typePlaceholder": "Select a guardrail type", + "config": "Guardrail Config", + "patternLabel": "Regex Pattern *", + "patternPlaceholder": "e.g. secret token", + "patternRequired": "Please enter a regex pattern.", + "blockReasonLabel": "Block Reason", + "blockReasonPlaceholder": "Optional reason returned to the client", + "identifierLabel": "Guardrail Identifier *", + "identifierPlaceholder": "e.g. gr-123abc", + "identifierRequired": "Please enter the Bedrock guardrail identifier.", + "versionLabel": "Version *", + "versionPlaceholder": "e.g. 1", + "versionRequired": "Please enter the Bedrock guardrail version.", + "regionLabel": "AWS Region *", + "regionHint": "Used to choose the default Bedrock runtime endpoint.", + "regionRequired": "Please enter an AWS region.", + "accessKeyIdLabel": "AWS Access Key ID *", + "accessKeyIdRequired": "Please enter the AWS access key ID.", + "secretAccessKeyLabel": "AWS Secret Access Key *", + "secretAccessKeyPlaceholder": "AWS secret access key", + "secretAccessKeyRequired": "Please enter the AWS secret access key.", + "sessionTokenLabel": "AWS Session Token", + "sessionTokenHint": "Optional temporary credential when using STS or assumed roles.", + "sessionTokenPlaceholder": "Optional temporary credential", + "endpointLabel": "Bedrock Endpoint Override", + "endpointHint": "Leave blank to use the standard runtime endpoint for the selected region.", + "showSecret": "Show value", + "hideSecret": "Hide value" + } + }, "providers": { "title": "Providers", "addProvider": "Add Provider", diff --git a/crates/admin-ui/ui/src/i18n/locales/zh-CN.json b/crates/admin-ui/ui/src/i18n/locales/zh-CN.json index ab8e19f..41a3d4b 100644 --- a/crates/admin-ui/ui/src/i18n/locales/zh-CN.json +++ b/crates/admin-ui/ui/src/i18n/locales/zh-CN.json @@ -5,6 +5,7 @@ "playground": "Playground", "providers": "模型提供商", "models": "模型", + "guardrails": "Guardrail", "apiKeys": "API 密钥", "settings": "设置" }, @@ -62,6 +63,10 @@ "deepseek": "DeepSeek", "bedrock": "AWS Bedrock" }, + "guardrailTypes": { + "regex": "Regex", + "bedrock": "AWS Bedrock" + }, "playground": { "title": "Playground", "addComparison": "添加对比", @@ -170,6 +175,67 @@ "concurrency": "并发" } }, + "guardrails": { + "title": "Guardrail", + "addGuardrail": "添加 Guardrail", + "deleteGuardrail": "删除 Guardrail", + "createTitle": "创建 Guardrail 资源", + "createDesc": "必填:name、type、config。config 字段会随 Guardrail 类型变化。", + "editTitle": "编辑 Guardrail 资源", + "createGuardrail": "创建 Guardrail", + "columns": { + "id": "ID", + "name": "名称", + "type": "类型", + "config": "配置", + "action": "操作" + }, + "search": "搜索 Guardrail…", + "count_one": "{{count}} 个 Guardrail", + "count_other": "{{count}} 个 Guardrail", + "empty": "暂无 Guardrail。", + "emptyAction": "添加一个", + "errorLoad": "加载 Guardrail 失败。", + "errorLoadSingle": "加载 Guardrail 失败。", + "deleteConfirm": "确认删除 Guardrail \"{{id}}\"?", + "regexSummary": "模式:{{pattern}}", + "bedrockSummary": "{{identifier}} · v{{version}} · {{region}}", + "form": { + "basicInfo": "基础信息", + "nameLabel": "名称 *", + "namePlaceholder": "例如:tenant-a-output-guardrail", + "nameRequired": "保存前请先输入 Guardrail 名称。", + "typeLabel": "Guardrail 类型 *", + "typePlaceholder": "选择 Guardrail 类型", + "config": "Guardrail 配置", + "patternLabel": "正则表达式 *", + "patternPlaceholder": "例如:secret token", + "patternRequired": "请输入正则表达式。", + "blockReasonLabel": "拦截原因", + "blockReasonPlaceholder": "返回给客户端的可选原因", + "identifierLabel": "Guardrail Identifier *", + "identifierPlaceholder": "例如:gr-123abc", + "identifierRequired": "请输入 Bedrock guardrail identifier。", + "versionLabel": "Version *", + "versionPlaceholder": "例如:1", + "versionRequired": "请输入 Bedrock guardrail version。", + "regionLabel": "AWS Region *", + "regionHint": "用于选择默认的 Bedrock 运行时地址。", + "regionRequired": "请输入 AWS Region。", + "accessKeyIdLabel": "AWS Access Key ID *", + "accessKeyIdRequired": "请输入 AWS Access Key ID。", + "secretAccessKeyLabel": "AWS Secret Access Key *", + "secretAccessKeyPlaceholder": "AWS secret access key", + "secretAccessKeyRequired": "请输入 AWS Secret Access Key。", + "sessionTokenLabel": "AWS Session Token", + "sessionTokenHint": "使用 STS 或 AssumeRole 临时凭证时可选。", + "sessionTokenPlaceholder": "可选的临时凭证", + "endpointLabel": "Bedrock Endpoint Override", + "endpointHint": "留空则根据所选 Region 使用标准运行时地址。", + "showSecret": "显示明文", + "hideSecret": "隐藏明文" + } + }, "providers": { "title": "模型提供商", "addProvider": "添加模型提供商", diff --git a/crates/admin-ui/ui/src/lib/api/client.ts b/crates/admin-ui/ui/src/lib/api/client.ts index 91d1780..69f20a4 100644 --- a/crates/admin-ui/ui/src/lib/api/client.ts +++ b/crates/admin-ui/ui/src/lib/api/client.ts @@ -2,6 +2,7 @@ import type { ApiError, ApiKey, DeleteResponse, + Guardrail, ItemResponse, ListResponse, Model, @@ -105,3 +106,21 @@ export const apiKeysApi = { delete: (adminKey: string, id: string) => request('DELETE', `/apikeys/${id}`, adminKey), }; + +// ── Guardrails ───────────────────────────────────────────────────────────────── +export const guardrailsApi = { + list: (adminKey: string) => + request>('GET', '/guardrails', adminKey), + + get: (adminKey: string, id: string) => + request>('GET', `/guardrails/${id}`, adminKey), + + create: (adminKey: string, data: Guardrail) => + request>('POST', '/guardrails', adminKey, data), + + update: (adminKey: string, id: string, data: Guardrail) => + request>('PUT', `/guardrails/${id}`, adminKey, data), + + delete: (adminKey: string, id: string) => + request('DELETE', `/guardrails/${id}`, adminKey), +}; diff --git a/crates/admin-ui/ui/src/lib/api/types.ts b/crates/admin-ui/ui/src/lib/api/types.ts index d31fe83..54808af 100644 --- a/crates/admin-ui/ui/src/lib/api/types.ts +++ b/crates/admin-ui/ui/src/lib/api/types.ts @@ -37,6 +37,37 @@ export interface Model { rate_limit?: RateLimit; } +export const GUARDRAIL_TYPE_VARIANTS = ['regex', 'bedrock'] as const; + +export type GuardrailType = (typeof GUARDRAIL_TYPE_VARIANTS)[number]; + +export interface RegexGuardrailConfig { + pattern: string; + block_reason?: string; +} + +export interface BedrockGuardrailConfig { + identifier: string; + version: string; + region: string; + access_key_id: string; + secret_access_key: string; + session_token?: string; + endpoint?: string; +} + +export type Guardrail = + | { + name: string; + type: 'regex'; + config: RegexGuardrailConfig; + } + | { + name: string; + type: 'bedrock'; + config: BedrockGuardrailConfig; + }; + export const PROVIDER_TYPE_VARIANTS = [ 'openai', 'openrouter', diff --git a/crates/admin-ui/ui/src/lib/queries/guardrails.ts b/crates/admin-ui/ui/src/lib/queries/guardrails.ts new file mode 100644 index 0000000..5ac1ecd --- /dev/null +++ b/crates/admin-ui/ui/src/lib/queries/guardrails.ts @@ -0,0 +1,73 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { useAdminKey } from '@/hooks/use-admin-key'; +import { ApiClientError, guardrailsApi } from '@/lib/api/client'; +import type { Guardrail } from '@/lib/api/types'; + +export const guardrailKeys = { + all: ['guardrails'] as const, + list: () => [...guardrailKeys.all, 'list'] as const, + detail: (id: string) => [...guardrailKeys.all, 'detail', id] as const, +}; + +export function useGuardrails() { + const { key, openModal } = useAdminKey(); + return useQuery({ + queryKey: guardrailKeys.list(), + queryFn: () => guardrailsApi.list(key!), + enabled: !!key, + retry: (count, err) => { + if (err instanceof ApiClientError && err.status === 401) { + openModal(); + return false; + } + return count < 2; + }, + }); +} + +export function useGuardrail(id: string) { + const { key, openModal } = useAdminKey(); + return useQuery({ + queryKey: guardrailKeys.detail(id), + queryFn: () => guardrailsApi.get(key!, id), + enabled: !!key && !!id, + retry: (count, err) => { + if (err instanceof ApiClientError && err.status === 401) { + openModal(); + return false; + } + return count < 2; + }, + }); +} + +export function useCreateGuardrail() { + const qc = useQueryClient(); + const { key } = useAdminKey(); + return useMutation({ + mutationFn: (data: Guardrail) => guardrailsApi.create(key!, data), + onSuccess: () => qc.invalidateQueries({ queryKey: guardrailKeys.list() }), + }); +} + +export function useUpdateGuardrail(id: string) { + const qc = useQueryClient(); + const { key } = useAdminKey(); + return useMutation({ + mutationFn: (data: Guardrail) => guardrailsApi.update(key!, id, data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: guardrailKeys.list() }); + qc.invalidateQueries({ queryKey: guardrailKeys.detail(id) }); + }, + }); +} + +export function useDeleteGuardrail() { + const qc = useQueryClient(); + const { key } = useAdminKey(); + return useMutation({ + mutationFn: (id: string) => guardrailsApi.delete(key!, id), + onSuccess: () => qc.invalidateQueries({ queryKey: guardrailKeys.list() }), + }); +} diff --git a/crates/admin-ui/ui/src/routeTree.gen.ts b/crates/admin-ui/ui/src/routeTree.gen.ts index a6c9062..4a9613d 100644 --- a/crates/admin-ui/ui/src/routeTree.gen.ts +++ b/crates/admin-ui/ui/src/routeTree.gen.ts @@ -15,11 +15,14 @@ import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings' import { Route as LayoutProvidersIndexRouteImport } from './routes/_layout/providers/index' import { Route as LayoutPlaygroundIndexRouteImport } from './routes/_layout/playground/index' import { Route as LayoutModelsIndexRouteImport } from './routes/_layout/models/index' +import { Route as LayoutGuardrailsIndexRouteImport } from './routes/_layout/guardrails/index' import { Route as LayoutApikeysIndexRouteImport } from './routes/_layout/apikeys/index' import { Route as LayoutProvidersCreateRouteImport } from './routes/_layout/providers/create' import { Route as LayoutProvidersIdRouteImport } from './routes/_layout/providers/$id' import { Route as LayoutModelsCreateRouteImport } from './routes/_layout/models/create' import { Route as LayoutModelsIdRouteImport } from './routes/_layout/models/$id' +import { Route as LayoutGuardrailsCreateRouteImport } from './routes/_layout/guardrails/create' +import { Route as LayoutGuardrailsIdRouteImport } from './routes/_layout/guardrails/$id' import { Route as LayoutApikeysCreateRouteImport } from './routes/_layout/apikeys/create' import { Route as LayoutApikeysIdRouteImport } from './routes/_layout/apikeys/$id' @@ -52,6 +55,11 @@ const LayoutModelsIndexRoute = LayoutModelsIndexRouteImport.update({ path: '/models/', getParentRoute: () => LayoutRoute, } as any) +const LayoutGuardrailsIndexRoute = LayoutGuardrailsIndexRouteImport.update({ + id: '/guardrails/', + path: '/guardrails/', + getParentRoute: () => LayoutRoute, +} as any) const LayoutApikeysIndexRoute = LayoutApikeysIndexRouteImport.update({ id: '/apikeys/', path: '/apikeys/', @@ -77,6 +85,16 @@ const LayoutModelsIdRoute = LayoutModelsIdRouteImport.update({ path: '/models/$id', getParentRoute: () => LayoutRoute, } as any) +const LayoutGuardrailsCreateRoute = LayoutGuardrailsCreateRouteImport.update({ + id: '/guardrails/create', + path: '/guardrails/create', + getParentRoute: () => LayoutRoute, +} as any) +const LayoutGuardrailsIdRoute = LayoutGuardrailsIdRouteImport.update({ + id: '/guardrails/$id', + path: '/guardrails/$id', + getParentRoute: () => LayoutRoute, +} as any) const LayoutApikeysCreateRoute = LayoutApikeysCreateRouteImport.update({ id: '/apikeys/create', path: '/apikeys/create', @@ -93,11 +111,14 @@ export interface FileRoutesByFullPath { '/settings': typeof LayoutSettingsRoute '/apikeys/$id': typeof LayoutApikeysIdRoute '/apikeys/create': typeof LayoutApikeysCreateRoute + '/guardrails/$id': typeof LayoutGuardrailsIdRoute + '/guardrails/create': typeof LayoutGuardrailsCreateRoute '/models/$id': typeof LayoutModelsIdRoute '/models/create': typeof LayoutModelsCreateRoute '/providers/$id': typeof LayoutProvidersIdRoute '/providers/create': typeof LayoutProvidersCreateRoute '/apikeys/': typeof LayoutApikeysIndexRoute + '/guardrails/': typeof LayoutGuardrailsIndexRoute '/models/': typeof LayoutModelsIndexRoute '/playground/': typeof LayoutPlaygroundIndexRoute '/providers/': typeof LayoutProvidersIndexRoute @@ -107,11 +128,14 @@ export interface FileRoutesByTo { '/settings': typeof LayoutSettingsRoute '/apikeys/$id': typeof LayoutApikeysIdRoute '/apikeys/create': typeof LayoutApikeysCreateRoute + '/guardrails/$id': typeof LayoutGuardrailsIdRoute + '/guardrails/create': typeof LayoutGuardrailsCreateRoute '/models/$id': typeof LayoutModelsIdRoute '/models/create': typeof LayoutModelsCreateRoute '/providers/$id': typeof LayoutProvidersIdRoute '/providers/create': typeof LayoutProvidersCreateRoute '/apikeys': typeof LayoutApikeysIndexRoute + '/guardrails': typeof LayoutGuardrailsIndexRoute '/models': typeof LayoutModelsIndexRoute '/playground': typeof LayoutPlaygroundIndexRoute '/providers': typeof LayoutProvidersIndexRoute @@ -123,11 +147,14 @@ export interface FileRoutesById { '/_layout/settings': typeof LayoutSettingsRoute '/_layout/apikeys/$id': typeof LayoutApikeysIdRoute '/_layout/apikeys/create': typeof LayoutApikeysCreateRoute + '/_layout/guardrails/$id': typeof LayoutGuardrailsIdRoute + '/_layout/guardrails/create': typeof LayoutGuardrailsCreateRoute '/_layout/models/$id': typeof LayoutModelsIdRoute '/_layout/models/create': typeof LayoutModelsCreateRoute '/_layout/providers/$id': typeof LayoutProvidersIdRoute '/_layout/providers/create': typeof LayoutProvidersCreateRoute '/_layout/apikeys/': typeof LayoutApikeysIndexRoute + '/_layout/guardrails/': typeof LayoutGuardrailsIndexRoute '/_layout/models/': typeof LayoutModelsIndexRoute '/_layout/playground/': typeof LayoutPlaygroundIndexRoute '/_layout/providers/': typeof LayoutProvidersIndexRoute @@ -139,11 +166,14 @@ export interface FileRouteTypes { | '/settings' | '/apikeys/$id' | '/apikeys/create' + | '/guardrails/$id' + | '/guardrails/create' | '/models/$id' | '/models/create' | '/providers/$id' | '/providers/create' | '/apikeys/' + | '/guardrails/' | '/models/' | '/playground/' | '/providers/' @@ -153,11 +183,14 @@ export interface FileRouteTypes { | '/settings' | '/apikeys/$id' | '/apikeys/create' + | '/guardrails/$id' + | '/guardrails/create' | '/models/$id' | '/models/create' | '/providers/$id' | '/providers/create' | '/apikeys' + | '/guardrails' | '/models' | '/playground' | '/providers' @@ -168,11 +201,14 @@ export interface FileRouteTypes { | '/_layout/settings' | '/_layout/apikeys/$id' | '/_layout/apikeys/create' + | '/_layout/guardrails/$id' + | '/_layout/guardrails/create' | '/_layout/models/$id' | '/_layout/models/create' | '/_layout/providers/$id' | '/_layout/providers/create' | '/_layout/apikeys/' + | '/_layout/guardrails/' | '/_layout/models/' | '/_layout/playground/' | '/_layout/providers/' @@ -227,6 +263,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutModelsIndexRouteImport parentRoute: typeof LayoutRoute } + '/_layout/guardrails/': { + id: '/_layout/guardrails/' + path: '/guardrails' + fullPath: '/guardrails/' + preLoaderRoute: typeof LayoutGuardrailsIndexRouteImport + parentRoute: typeof LayoutRoute + } '/_layout/apikeys/': { id: '/_layout/apikeys/' path: '/apikeys' @@ -262,6 +305,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutModelsIdRouteImport parentRoute: typeof LayoutRoute } + '/_layout/guardrails/create': { + id: '/_layout/guardrails/create' + path: '/guardrails/create' + fullPath: '/guardrails/create' + preLoaderRoute: typeof LayoutGuardrailsCreateRouteImport + parentRoute: typeof LayoutRoute + } + '/_layout/guardrails/$id': { + id: '/_layout/guardrails/$id' + path: '/guardrails/$id' + fullPath: '/guardrails/$id' + preLoaderRoute: typeof LayoutGuardrailsIdRouteImport + parentRoute: typeof LayoutRoute + } '/_layout/apikeys/create': { id: '/_layout/apikeys/create' path: '/apikeys/create' @@ -283,11 +340,14 @@ interface LayoutRouteChildren { LayoutSettingsRoute: typeof LayoutSettingsRoute LayoutApikeysIdRoute: typeof LayoutApikeysIdRoute LayoutApikeysCreateRoute: typeof LayoutApikeysCreateRoute + LayoutGuardrailsIdRoute: typeof LayoutGuardrailsIdRoute + LayoutGuardrailsCreateRoute: typeof LayoutGuardrailsCreateRoute LayoutModelsIdRoute: typeof LayoutModelsIdRoute LayoutModelsCreateRoute: typeof LayoutModelsCreateRoute LayoutProvidersIdRoute: typeof LayoutProvidersIdRoute LayoutProvidersCreateRoute: typeof LayoutProvidersCreateRoute LayoutApikeysIndexRoute: typeof LayoutApikeysIndexRoute + LayoutGuardrailsIndexRoute: typeof LayoutGuardrailsIndexRoute LayoutModelsIndexRoute: typeof LayoutModelsIndexRoute LayoutPlaygroundIndexRoute: typeof LayoutPlaygroundIndexRoute LayoutProvidersIndexRoute: typeof LayoutProvidersIndexRoute @@ -297,11 +357,14 @@ const LayoutRouteChildren: LayoutRouteChildren = { LayoutSettingsRoute: LayoutSettingsRoute, LayoutApikeysIdRoute: LayoutApikeysIdRoute, LayoutApikeysCreateRoute: LayoutApikeysCreateRoute, + LayoutGuardrailsIdRoute: LayoutGuardrailsIdRoute, + LayoutGuardrailsCreateRoute: LayoutGuardrailsCreateRoute, LayoutModelsIdRoute: LayoutModelsIdRoute, LayoutModelsCreateRoute: LayoutModelsCreateRoute, LayoutProvidersIdRoute: LayoutProvidersIdRoute, LayoutProvidersCreateRoute: LayoutProvidersCreateRoute, LayoutApikeysIndexRoute: LayoutApikeysIndexRoute, + LayoutGuardrailsIndexRoute: LayoutGuardrailsIndexRoute, LayoutModelsIndexRoute: LayoutModelsIndexRoute, LayoutPlaygroundIndexRoute: LayoutPlaygroundIndexRoute, LayoutProvidersIndexRoute: LayoutProvidersIndexRoute, diff --git a/crates/admin-ui/ui/src/routes/_layout/guardrails/$id.tsx b/crates/admin-ui/ui/src/routes/_layout/guardrails/$id.tsx new file mode 100644 index 0000000..0d5e559 --- /dev/null +++ b/crates/admin-ui/ui/src/routes/_layout/guardrails/$id.tsx @@ -0,0 +1,100 @@ +import { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { Trash2, X } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import { GuardrailForm } from '@/components/guardrails/guardrail-form'; +import { PageHeader } from '@/components/layout/page-header'; +import { Button } from '@/components/ui/button'; +import { + useDeleteGuardrail, + useGuardrail, + useUpdateGuardrail, +} from '@/lib/queries/guardrails'; + +export const Route = createFileRoute('/_layout/guardrails/$id')({ + component: GuardrailEditPage, +}); + +function GuardrailEditPage() { + const { t } = useTranslation(); + const { id } = Route.useParams(); + const navigate = useNavigate(); + + const { data, isLoading, isError } = useGuardrail(id); + const updateGuardrail = useUpdateGuardrail(id); + const deleteGuardrail = useDeleteGuardrail(); + + async function handleDelete() { + if (!confirm(t('guardrails.deleteConfirm', { id }))) return; + await deleteGuardrail.mutateAsync(id); + navigate({ to: '/guardrails' }); + } + + if (isLoading) { + return ( +
+ {t('common.loading')} +
+ ); + } + + if (isError || !data) { + return ( +
+ {t('guardrails.errorLoadSingle')} +
+ ); + } + + return ( +
+ +

+ {t('guardrails.title')} +

+ +
+ +
+
+
+

+ {t('guardrails.editTitle')} +

+

{id}

+
+ + { + await updateGuardrail.mutateAsync(payload); + navigate({ to: '/guardrails' }); + }} + isPending={updateGuardrail.isPending} + error={updateGuardrail.error?.message} + onCancel={() => navigate({ to: '/guardrails' })} + submitLabel={t('common.saveChanges')} + extraActions={ + + } + /> +
+
+
+ ); +} diff --git a/crates/admin-ui/ui/src/routes/_layout/guardrails/create.tsx b/crates/admin-ui/ui/src/routes/_layout/guardrails/create.tsx new file mode 100644 index 0000000..f05c49f --- /dev/null +++ b/crates/admin-ui/ui/src/routes/_layout/guardrails/create.tsx @@ -0,0 +1,61 @@ +import { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { X } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import { GuardrailForm } from '@/components/guardrails/guardrail-form'; +import { PageHeader } from '@/components/layout/page-header'; +import { Button } from '@/components/ui/button'; +import type { Guardrail } from '@/lib/api/types'; +import { useCreateGuardrail } from '@/lib/queries/guardrails'; + +export const Route = createFileRoute('/_layout/guardrails/create')({ + component: GuardrailCreatePage, +}); + +function GuardrailCreatePage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const createGuardrail = useCreateGuardrail(); + + async function handleSubmit(data: Guardrail) { + await createGuardrail.mutateAsync(data); + navigate({ to: '/guardrails' }); + } + + return ( +
+ +

{t('guardrails.title')}

+ +
+ +
+
+
+

+ {t('guardrails.createTitle')} +

+

+ {t('guardrails.createDesc')} +

+
+ + navigate({ to: '/guardrails' })} + submitLabel={t('guardrails.createGuardrail')} + /> +
+
+
+ ); +} \ No newline at end of file diff --git a/crates/admin-ui/ui/src/routes/_layout/guardrails/index.tsx b/crates/admin-ui/ui/src/routes/_layout/guardrails/index.tsx new file mode 100644 index 0000000..fcbeb07 --- /dev/null +++ b/crates/admin-ui/ui/src/routes/_layout/guardrails/index.tsx @@ -0,0 +1,221 @@ +import { Link, createFileRoute, useNavigate } from '@tanstack/react-router'; +import { createColumnHelper, type ColumnDef } from '@tanstack/react-table'; +import { Pencil, Plus, Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { PageHeader } from '@/components/layout/page-header'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { DataTable, type RowSelectionState } from '@/components/ui/data-table'; +import { Input } from '@/components/ui/input'; +import type { Guardrail, ItemResponse } from '@/lib/api/types'; +import { useDeleteGuardrail, useGuardrails } from '@/lib/queries/guardrails'; + +export const Route = createFileRoute('/_layout/guardrails/')({ + component: GuardrailsPage, +}); + +type GuardrailRow = ItemResponse & { + id: string; + summary: string; +}; + +const col = createColumnHelper(); + +function GuardrailsPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { data, isLoading, isError } = useGuardrails(); + const deleteGuardrail = useDeleteGuardrail(); + + const [search, setSearch] = useState(''); + const [rowSelection, setRowSelection] = useState({}); + + const items = (data?.list ?? []).map( + ({ key, value, ...rest }) => + ({ + id: key.replace('/guardrails/', ''), + key, + value, + summary: describeGuardrailConfig(value, t), + ...rest, + }) as GuardrailRow, + ); + const selectedKeys = Object.keys(rowSelection); + + const columns = [ + col.display({ + id: 'select', + size: 40, + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + /> + ), + }), + col.accessor('id', { + header: () => t('guardrails.columns.id'), + size: 280, + cell: (info) => ( + + {info.getValue()} + + ), + }), + col.accessor('value.name', { + header: () => t('guardrails.columns.name'), + size: 240, + cell: (info) => ( + {info.getValue()} + ), + }), + col.accessor('value.type', { + header: () => t('guardrails.columns.type'), + size: 140, + cell: (info) => ( + + {t(`guardrailTypes.${info.getValue()}`)} + + ), + }), + col.accessor('summary', { + header: () => t('guardrails.columns.config'), + cell: (info) => ( + {info.getValue()} + ), + }), + col.display({ + id: 'actions', + size: 96, + header: () => ( +
{t('guardrails.columns.action')}
+ ), + cell: ({ row }) => ( +
+ + +
+ ), + }), + ]; + + async function handleDeleteSelected() { + for (const id of selectedKeys) { + await deleteGuardrail.mutateAsync(id); + } + setRowSelection({}); + } + + return ( +
+ +

+ {t('guardrails.title')} +

+ {selectedKeys.length > 0 && ( + + )} + +
+ +
+
+

+ {isLoading + ? t('common.loading') + : t('guardrails.count', { count: items.length })} +

+ setSearch(e.target.value)} + /> +
+ + []} + data={items} + isLoading={isLoading} + isError={isError} + errorMessage={t('guardrails.errorLoad')} + emptyMessage={ + + {t('guardrails.empty')}{' '} + + {t('guardrails.emptyAction')} + + + } + rowSelection={rowSelection} + onRowSelectionChange={setRowSelection} + getRowId={(row) => row.id} + globalFilter={search} + /> +
+
+ ); +} + +function describeGuardrailConfig( + guardrail: Guardrail, + t: ReturnType['t'], +) { + switch (guardrail.type) { + case 'regex': + return t('guardrails.regexSummary', { + pattern: guardrail.config.pattern, + }); + case 'bedrock': + return t('guardrails.bedrockSummary', { + identifier: guardrail.config.identifier, + version: guardrail.config.version, + region: guardrail.config.region, + }); + } +} From e2ee2f5432cc84cfbe51647c02377a7e447c1f6f Mon Sep 17 00:00:00 2001 From: bzp2010 Date: Tue, 19 May 2026 10:43:57 +0800 Subject: [PATCH 2/2] fix comments --- crates/admin-ui/ui/src/i18n/locales/en.json | 2 + .../admin-ui/ui/src/i18n/locales/zh-CN.json | 2 + .../admin-ui/ui/src/lib/queries/guardrails.ts | 21 ++++++- .../ui/src/routes/_layout/guardrails/$id.tsx | 21 ++++++- .../src/routes/_layout/guardrails/index.tsx | 55 ++++++++++++++++++- 5 files changed, 93 insertions(+), 8 deletions(-) diff --git a/crates/admin-ui/ui/src/i18n/locales/en.json b/crates/admin-ui/ui/src/i18n/locales/en.json index b462a64..4ef4095 100644 --- a/crates/admin-ui/ui/src/i18n/locales/en.json +++ b/crates/admin-ui/ui/src/i18n/locales/en.json @@ -197,6 +197,8 @@ "emptyAction": "Add one", "errorLoad": "Failed to load guardrails.", "errorLoadSingle": "Failed to load guardrail.", + "deleteFailed": "Failed to delete guardrail.", + "bulkDeleteFailed": "Deleted {{successCount}} guardrails; {{failureCount}} deletions failed: {{details}}", "deleteConfirm": "Delete guardrail \"{{id}}\"?", "regexSummary": "Pattern: {{pattern}}", "bedrockSummary": "{{identifier}} · v{{version}} · {{region}}", diff --git a/crates/admin-ui/ui/src/i18n/locales/zh-CN.json b/crates/admin-ui/ui/src/i18n/locales/zh-CN.json index 41a3d4b..78bb4cb 100644 --- a/crates/admin-ui/ui/src/i18n/locales/zh-CN.json +++ b/crates/admin-ui/ui/src/i18n/locales/zh-CN.json @@ -197,6 +197,8 @@ "emptyAction": "添加一个", "errorLoad": "加载 Guardrail 失败。", "errorLoadSingle": "加载 Guardrail 失败。", + "deleteFailed": "删除 Guardrail 失败。", + "bulkDeleteFailed": "已删除 {{successCount}} 个 Guardrail,{{failureCount}} 个删除失败:{{details}}", "deleteConfirm": "确认删除 Guardrail \"{{id}}\"?", "regexSummary": "模式:{{pattern}}", "bedrockSummary": "{{identifier}} · v{{version}} · {{region}}", diff --git a/crates/admin-ui/ui/src/lib/queries/guardrails.ts b/crates/admin-ui/ui/src/lib/queries/guardrails.ts index 5ac1ecd..f3157a9 100644 --- a/crates/admin-ui/ui/src/lib/queries/guardrails.ts +++ b/crates/admin-ui/ui/src/lib/queries/guardrails.ts @@ -44,30 +44,45 @@ export function useGuardrail(id: string) { export function useCreateGuardrail() { const qc = useQueryClient(); - const { key } = useAdminKey(); + const { key, openModal } = useAdminKey(); return useMutation({ mutationFn: (data: Guardrail) => guardrailsApi.create(key!, data), onSuccess: () => qc.invalidateQueries({ queryKey: guardrailKeys.list() }), + onError: (err) => { + if (err instanceof ApiClientError && err.status === 401) { + openModal(); + } + }, }); } export function useUpdateGuardrail(id: string) { const qc = useQueryClient(); - const { key } = useAdminKey(); + const { key, openModal } = useAdminKey(); return useMutation({ mutationFn: (data: Guardrail) => guardrailsApi.update(key!, id, data), onSuccess: () => { qc.invalidateQueries({ queryKey: guardrailKeys.list() }); qc.invalidateQueries({ queryKey: guardrailKeys.detail(id) }); }, + onError: (err) => { + if (err instanceof ApiClientError && err.status === 401) { + openModal(); + } + }, }); } export function useDeleteGuardrail() { const qc = useQueryClient(); - const { key } = useAdminKey(); + const { key, openModal } = useAdminKey(); return useMutation({ mutationFn: (id: string) => guardrailsApi.delete(key!, id), onSuccess: () => qc.invalidateQueries({ queryKey: guardrailKeys.list() }), + onError: (err) => { + if (err instanceof ApiClientError && err.status === 401) { + openModal(); + } + }, }); } diff --git a/crates/admin-ui/ui/src/routes/_layout/guardrails/$id.tsx b/crates/admin-ui/ui/src/routes/_layout/guardrails/$id.tsx index 0d5e559..f7ab120 100644 --- a/crates/admin-ui/ui/src/routes/_layout/guardrails/$id.tsx +++ b/crates/admin-ui/ui/src/routes/_layout/guardrails/$id.tsx @@ -1,5 +1,6 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { Trash2, X } from 'lucide-react'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { GuardrailForm } from '@/components/guardrails/guardrail-form'; @@ -19,6 +20,7 @@ function GuardrailEditPage() { const { t } = useTranslation(); const { id } = Route.useParams(); const navigate = useNavigate(); + const [deleteError, setDeleteError] = useState(null); const { data, isLoading, isError } = useGuardrail(id); const updateGuardrail = useUpdateGuardrail(id); @@ -26,8 +28,16 @@ function GuardrailEditPage() { async function handleDelete() { if (!confirm(t('guardrails.deleteConfirm', { id }))) return; - await deleteGuardrail.mutateAsync(id); - navigate({ to: '/guardrails' }); + + setDeleteError(null); + try { + await deleteGuardrail.mutateAsync(id); + navigate({ to: '/guardrails' }); + } catch (error) { + setDeleteError( + error instanceof Error ? error.message : t('guardrails.deleteFailed'), + ); + } } if (isLoading) { @@ -71,9 +81,16 @@ function GuardrailEditPage() {

{id}

+ {deleteError && ( +

+ {deleteError} +

+ )} + { + setDeleteError(null); await updateGuardrail.mutateAsync(payload); navigate({ to: '/guardrails' }); }} diff --git a/crates/admin-ui/ui/src/routes/_layout/guardrails/index.tsx b/crates/admin-ui/ui/src/routes/_layout/guardrails/index.tsx index fcbeb07..9a46838 100644 --- a/crates/admin-ui/ui/src/routes/_layout/guardrails/index.tsx +++ b/crates/admin-ui/ui/src/routes/_layout/guardrails/index.tsx @@ -32,6 +32,7 @@ function GuardrailsPage() { const [search, setSearch] = useState(''); const [rowSelection, setRowSelection] = useState({}); + const [actionError, setActionError] = useState(null); const items = (data?.list ?? []).map( ({ key, value, ...rest }) => @@ -45,6 +46,19 @@ function GuardrailsPage() { ); const selectedKeys = Object.keys(rowSelection); + function formatDeleteError(error: unknown) { + return error instanceof Error ? error.message : t('guardrails.deleteFailed'); + } + + async function handleDeleteOne(id: string) { + setActionError(null); + try { + await deleteGuardrail.mutateAsync(id); + } catch (error) { + setActionError(formatDeleteError(error)); + } + } + const columns = [ col.display({ id: 'select', @@ -119,7 +133,9 @@ function GuardrailsPage() { @@ -129,9 +145,36 @@ function GuardrailsPage() { ]; async function handleDeleteSelected() { - for (const id of selectedKeys) { - await deleteGuardrail.mutateAsync(id); + setActionError(null); + + const results = await Promise.allSettled( + selectedKeys.map(async (id) => { + await deleteGuardrail.mutateAsync(id); + return id; + }), + ); + + const failed = results.flatMap((result, index) => + result.status === 'rejected' + ? [{ id: selectedKeys[index], message: formatDeleteError(result.reason) }] + : [], + ); + + if (failed.length > 0) { + const successCount = selectedKeys.length - failed.length; + setActionError( + t('guardrails.bulkDeleteFailed', { + successCount, + failureCount: failed.length, + details: failed.map(({ id, message }) => `${id}: ${message}`).join('; '), + }), + ); + setRowSelection( + Object.fromEntries(failed.map(({ id }) => [id, true])) satisfies RowSelectionState, + ); + return; } + setRowSelection({}); } @@ -161,6 +204,12 @@ function GuardrailsPage() {
+ {actionError && ( +

+ {actionError} +

+ )} +

{isLoading