Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions ui/src/core/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type {
InitAgentRequestBody,
ListAgentsQueryParams,
PatchControlRequest,
RenderControlTemplateRequest,
RenderControlTemplateResponse,
SetControlDataRequest,
ValidateControlDataRequest,
ValidateControlDataResponse,
Expand Down Expand Up @@ -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 } }),
Expand Down
69 changes: 69 additions & 0 deletions ui/src/core/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, TemplateParameterDefinition>;
definition_template: unknown;
};

export type TemplateControlInput = {
template: TemplateDefinition;
template_values: Record<string, TemplateValue>;
};

export type RenderControlTemplateRequest = {
template: TemplateDefinition;
template_values: Record<string, TemplateValue>;
};

export type RenderControlTemplateResponse = {
control: ControlDefinition;
};

// Helper type to extract query parameters from operations
type ExtractQueryParams<T> = T extends { parameters: { query?: infer Q } }
? Q
Expand Down
172 changes: 172 additions & 0 deletions ui/src/core/components/template-param-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import {
Select,
Stack,
Switch,
TagsInput,
Text,
TextInput,
} from '@mantine/core';
import { useMemo } from 'react';

import type {
TemplateDefinition,
TemplateParameterDefinition,
TemplateValue,
} from '@/core/api/types';

import { labelPropsInline, LabelWithTooltip } from './label-with-tooltip';

type TemplateParamFormProps = {
template: TemplateDefinition;
values: Record<string, TemplateValue>;
onChange: (values: Record<string, TemplateValue>) => void;
errors?: Record<string, string>;
};

function paramLabel(
name: string,
param: TemplateParameterDefinition
): React.ReactNode {
if (param.description) {
return <LabelWithTooltip label={param.label} tooltip={param.description} />;
}
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 (
<TextInput
label={label}
labelProps={param.description ? labelPropsInline : undefined}
placeholder={param.placeholder ?? undefined}
required={isRequired}
value={(value as string) ?? ''}
onChange={(e) => onChangeValue(name, e.currentTarget.value)}
error={error}
size="sm"
/>
);

case 'string_list':
return (
<TagsInput
label={label}
labelProps={param.description ? labelPropsInline : undefined}
placeholder={
param.placeholder ? param.placeholder.join(', ') : 'Add items...'
}
required={isRequired}
value={Array.isArray(value) ? (value as string[]) : []}
onChange={(val) => onChangeValue(name, val)}
error={error}
size="sm"
/>
);

case 'enum':
return (
<Select
label={label}
labelProps={param.description ? labelPropsInline : undefined}
data={param.allowed_values}
required={isRequired}
value={(value as string) ?? null}
onChange={(val) => {
if (val !== null) onChangeValue(name, val);
}}
error={error}
size="sm"
/>
);

case 'boolean':
return (
<Switch
label={label}
checked={typeof value === 'boolean' ? value : false}
onChange={(e) => onChangeValue(name, e.currentTarget.checked)}
color="green.5"
size="md"
error={error}
/>
);

case 'regex_re2':
return (
<TextInput
label={label}
labelProps={param.description ? labelPropsInline : undefined}
placeholder={param.placeholder ?? 'RE2 regex pattern'}
required={isRequired}
value={(value as string) ?? ''}
onChange={(e) => onChangeValue(name, e.currentTarget.value)}
error={error}
size="sm"
styles={{ input: { fontFamily: 'monospace' } }}
/>
);

default:
return null;
}
}

/**
* Auto-generated parameter form driven by a TemplateDefinition's parameters.
* Renders one input per parameter, mapped by type.
*/
export function TemplateParamForm({
template,
values,
onChange,
errors,
}: TemplateParamFormProps) {
const paramEntries = useMemo(
() => Object.entries(template.parameters),
[template.parameters]
);

const handleChangeValue = (name: string, newValue: TemplateValue) => {
onChange({ ...values, [name]: newValue });
};

if (paramEntries.length === 0) {
return (
<Text size="sm" c="dimmed">
This template has no configurable parameters.
</Text>
);
}

return (
<Stack gap="md">
{paramEntries.map(([name, param]) => (
<ParameterInput
key={name}
name={name}
param={param}
value={values[name]}
error={errors?.[name]}
onChangeValue={handleChangeValue}
/>
))}
</Stack>
);
}
Loading
Loading