From 2c40903fce313ba56d9107505388f6577ea15862 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Wed, 1 Apr 2026 12:49:03 -0700 Subject: [PATCH 1/6] feat(ui): add control template editing and creation support - Add template-backed control detection and "Template" badge in controls list - Add TemplateEditContent with parameter form, read-only summary, and Parameters/Full JSON mode toggle for editing template-backed controls - Add TemplateParamForm component that auto-generates form inputs from template parameter definitions (string, string_list, enum, boolean, regex_re2) - Add TemplatePreview component with debounced render endpoint calls that strips template metadata from the preview output - Wire enable/disable toggle to use PATCH for template-backed controls (avoids 409 from PUT /data on template-backed controls) - Template-backed controls can be created via "From JSON" by pasting a TemplateControlInput payload - Add template types and render API method to client (manual until OpenAPI types are regenerated) - Add 13 Playwright tests covering template badge, parameter form editing, JSON toggle, preview stripping, and raw control regression --- ui/src/core/api/client.ts | 19 + ui/src/core/api/types.ts | 69 +++ .../core/components/template-param-form.tsx | 175 ++++++++ ui/src/core/components/template-preview.tsx | 136 ++++++ .../hooks/query-hooks/use-render-template.ts | 28 ++ .../agent-detail/agent-detail.tsx | 3 + .../agent-detail/controls/table-columns.tsx | 92 ++-- .../modals/add-new-control/index.tsx | 2 + .../modals/create-from-template/index.tsx | 352 +++++++++++++++ .../edit-control/edit-control-content.tsx | 24 +- .../edit-control/template-edit-content.tsx | 417 ++++++++++++++++++ ui/tests/control-templates.spec.ts | 282 ++++++++++++ ui/tests/fixtures.ts | 138 ++++++ 13 files changed, 1701 insertions(+), 36 deletions(-) create mode 100644 ui/src/core/components/template-param-form.tsx create mode 100644 ui/src/core/components/template-preview.tsx create mode 100644 ui/src/core/hooks/query-hooks/use-render-template.ts create mode 100644 ui/src/core/page-components/agent-detail/modals/create-from-template/index.tsx create mode 100644 ui/src/core/page-components/agent-detail/modals/edit-control/template-edit-content.tsx create mode 100644 ui/tests/control-templates.spec.ts diff --git a/ui/src/core/api/client.ts b/ui/src/core/api/client.ts index 6fec427a..f8d114c6 100644 --- a/ui/src/core/api/client.ts +++ b/ui/src/core/api/client.ts @@ -8,6 +8,8 @@ import type { InitAgentRequestBody, ListAgentsQueryParams, PatchControlRequest, + RenderControlTemplateRequest, + RenderControlTemplateResponse, SetControlDataRequest, ValidateControlDataRequest, ValidateControlDataResponse, @@ -209,6 +211,23 @@ export const api = { }, }), }, + controlTemplates: { + render: async (data: RenderControlTemplateRequest) => { + const res = await fetch(`${API_URL}/api/v1/control-templates/render`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + const body = await res.json(); + if (!res.ok) return { data: undefined, error: body, response: res }; + return { + data: body as RenderControlTemplateResponse, + error: undefined, + response: res, + }; + }, + }, policies: { create: (name: string) => apiClient.PUT('/api/v1/policies', { body: { name } }), diff --git a/ui/src/core/api/types.ts b/ui/src/core/api/types.ts index 136d54eb..dfdfc3fa 100644 --- a/ui/src/core/api/types.ts +++ b/ui/src/core/api/types.ts @@ -111,6 +111,75 @@ export type ListControlsResponse = export type AgentRef = components['schemas']['AgentRef']; +// ============================================================================= +// Template Types (manual until API types are regenerated) +// ============================================================================= + +export type TemplateValue = string | boolean | string[]; + +export type TemplateParameterBase = { + label: string; + description?: string | null; + required?: boolean; + ui_hint?: string | null; +}; + +export type StringTemplateParameter = TemplateParameterBase & { + type: 'string'; + default?: string | null; + placeholder?: string | null; +}; + +export type StringListTemplateParameter = TemplateParameterBase & { + type: 'string_list'; + default?: string[] | null; + placeholder?: string[] | null; +}; + +export type EnumTemplateParameter = TemplateParameterBase & { + type: 'enum'; + allowed_values: string[]; + default?: string | null; +}; + +export type BooleanTemplateParameter = TemplateParameterBase & { + type: 'boolean'; + default?: boolean | null; +}; + +export type RegexTemplateParameter = TemplateParameterBase & { + type: 'regex_re2'; + default?: string | null; + placeholder?: string | null; +}; + +export type TemplateParameterDefinition = + | StringTemplateParameter + | StringListTemplateParameter + | EnumTemplateParameter + | BooleanTemplateParameter + | RegexTemplateParameter; + +export type TemplateDefinition = { + description?: string | null; + parameters: Record; + definition_template: unknown; +}; + +export type TemplateControlInput = { + template: TemplateDefinition; + template_values: Record; +}; + +export type RenderControlTemplateRequest = { + template: TemplateDefinition; + template_values: Record; +}; + +export type RenderControlTemplateResponse = { + control: ControlDefinition; +}; + // Helper type to extract query parameters from operations type ExtractQueryParams = T extends { parameters: { query?: infer Q } } ? Q diff --git a/ui/src/core/components/template-param-form.tsx b/ui/src/core/components/template-param-form.tsx new file mode 100644 index 00000000..2591fde4 --- /dev/null +++ b/ui/src/core/components/template-param-form.tsx @@ -0,0 +1,175 @@ +import { + Select, + Stack, + Switch, + TagsInput, + Text, + TextInput, +} from '@mantine/core'; +import { useMemo } from 'react'; + +import type { + TemplateDefinition, + TemplateParameterDefinition, + TemplateValue, +} from '@/core/api/types'; + +import { + LabelWithTooltip, + labelPropsInline, +} from './label-with-tooltip'; + +type TemplateParamFormProps = { + template: TemplateDefinition; + values: Record; + onChange: (values: Record) => void; + errors?: Record; +}; + +function paramLabel( + name: string, + param: TemplateParameterDefinition +): React.ReactNode { + if (param.description) { + return ; + } + return param.label; +} + +function ParameterInput({ + name, + param, + value, + error, + onChangeValue, +}: { + name: string; + param: TemplateParameterDefinition; + value: TemplateValue | undefined; + error?: string; + onChangeValue: (name: string, value: TemplateValue) => void; +}) { + const label = paramLabel(name, param); + const isRequired = param.required !== false; + + switch (param.type) { + case 'string': + return ( + onChangeValue(name, e.currentTarget.value)} + error={error} + size="sm" + /> + ); + + case 'string_list': + return ( + onChangeValue(name, val)} + error={error} + size="sm" + /> + ); + + case 'enum': + return ( +