Skip to content

Commit 79c6504

Browse files
authored
[ Gateway 9/10 ] Add the Endpoint Edit Page (mlflow#19502)
Signed-off-by: Ben Wilson <[email protected]>
1 parent af85286 commit 79c6504

18 files changed

+1347
-219
lines changed
Lines changed: 391 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,391 @@
1+
import { useMemo, useCallback } from 'react';
2+
import { Link } from '../../../common/utils/RoutingUtils';
3+
import {
4+
Alert,
5+
Breadcrumb,
6+
Button,
7+
FormUI,
8+
Spinner,
9+
Tooltip,
10+
Typography,
11+
useDesignSystemTheme,
12+
} from '@databricks/design-system';
13+
import { GatewayInput } from '../common';
14+
import { FormattedMessage, useIntl } from 'react-intl';
15+
import { Controller, UseFormReturn } from 'react-hook-form';
16+
import { ProviderSelect } from '../create-endpoint/ProviderSelect';
17+
import { ModelSelect } from '../create-endpoint/ModelSelect';
18+
import { ApiKeyConfigurator } from '../model-configuration/components/ApiKeyConfigurator';
19+
import { useApiKeyConfiguration } from '../model-configuration/hooks/useApiKeyConfiguration';
20+
import type { ApiKeyConfiguration } from '../model-configuration/types';
21+
import GatewayRoutes from '../../routes';
22+
import { LongFormSection } from '../../../common/components/long-form/LongFormSection';
23+
import { LongFormSummary } from '../../../common/components/long-form/LongFormSummary';
24+
import { formatProviderName } from '../../utils/providerUtils';
25+
import type { EditEndpointFormData } from '../../hooks/useEditEndpointForm';
26+
27+
const LONG_FORM_TITLE_WIDTH = 200;
28+
29+
export interface EditEndpointFormRendererProps {
30+
form: UseFormReturn<EditEndpointFormData>;
31+
isLoadingEndpoint: boolean;
32+
isSubmitting: boolean;
33+
loadError: Error | null;
34+
mutationError: Error | null;
35+
errorMessage: string | null;
36+
resetErrors: () => void;
37+
endpointName: string | undefined;
38+
isFormComplete: boolean;
39+
hasChanges: boolean;
40+
onSubmit: (values: EditEndpointFormData) => Promise<void>;
41+
onCancel: () => void;
42+
onNameBlur: () => void;
43+
}
44+
45+
export const EditEndpointFormRenderer = ({
46+
form,
47+
isLoadingEndpoint,
48+
isSubmitting,
49+
loadError,
50+
mutationError,
51+
errorMessage,
52+
resetErrors,
53+
endpointName,
54+
isFormComplete,
55+
hasChanges,
56+
onSubmit,
57+
onCancel,
58+
onNameBlur,
59+
}: EditEndpointFormRendererProps) => {
60+
const { theme } = useDesignSystemTheme();
61+
const intl = useIntl();
62+
63+
const provider = form.watch('provider');
64+
const modelName = form.watch('modelName');
65+
const secretMode = form.watch('secretMode');
66+
const existingSecretId = form.watch('existingSecretId');
67+
const newSecret = form.watch('newSecret');
68+
69+
const { existingSecrets, isLoadingSecrets, authModes, defaultAuthMode, isLoadingProviderConfig } =
70+
useApiKeyConfiguration({ provider });
71+
72+
const selectedSecretName = existingSecrets.find((s) => s.secret_id === existingSecretId)?.secret_name;
73+
74+
const apiKeyConfig: ApiKeyConfiguration = useMemo(
75+
() => ({
76+
mode: secretMode,
77+
existingSecretId: existingSecretId,
78+
newSecret: newSecret,
79+
}),
80+
[secretMode, existingSecretId, newSecret],
81+
);
82+
83+
const handleApiKeyChange = useCallback(
84+
(config: ApiKeyConfiguration) => {
85+
if (config.mode !== secretMode) {
86+
form.setValue('secretMode', config.mode);
87+
}
88+
if (config.existingSecretId !== existingSecretId) {
89+
form.setValue('existingSecretId', config.existingSecretId);
90+
}
91+
if (config.newSecret !== newSecret) {
92+
form.setValue('newSecret', config.newSecret);
93+
}
94+
},
95+
[form, secretMode, existingSecretId, newSecret],
96+
);
97+
98+
if (isLoadingEndpoint) {
99+
return (
100+
<div css={{ overflow: 'hidden', display: 'flex', flexDirection: 'column', flex: 1 }}>
101+
<div css={{ display: 'flex', alignItems: 'center', gap: theme.spacing.sm, padding: theme.spacing.md }}>
102+
<Spinner size="small" />
103+
<FormattedMessage defaultMessage="Loading endpoint..." description="Loading message for endpoint" />
104+
</div>
105+
</div>
106+
);
107+
}
108+
109+
if (loadError) {
110+
return (
111+
<div css={{ overflow: 'hidden', display: 'flex', flexDirection: 'column', flex: 1 }}>
112+
<div css={{ padding: theme.spacing.md }}>
113+
<Alert
114+
componentId="mlflow.gateway.edit-endpoint.error"
115+
type="error"
116+
message={loadError.message ?? 'Endpoint not found'}
117+
/>
118+
</div>
119+
</div>
120+
);
121+
}
122+
123+
return (
124+
<div css={{ overflow: 'hidden', display: 'flex', flexDirection: 'column', flex: 1 }}>
125+
<div css={{ padding: theme.spacing.md }}>
126+
<Breadcrumb includeTrailingCaret>
127+
<Breadcrumb.Item>
128+
<Link to={GatewayRoutes.gatewayPageRoute}>
129+
<FormattedMessage defaultMessage="AI Gateway" description="Breadcrumb link to gateway page" />
130+
</Link>
131+
</Breadcrumb.Item>
132+
<Breadcrumb.Item>
133+
<Link to={GatewayRoutes.gatewayPageRoute}>
134+
<FormattedMessage defaultMessage="Endpoints" description="Breadcrumb link to endpoints list" />
135+
</Link>
136+
</Breadcrumb.Item>
137+
</Breadcrumb>
138+
<Typography.Title level={2} css={{ marginTop: theme.spacing.sm }}>
139+
<FormattedMessage defaultMessage="Edit endpoint" description="Page title for edit endpoint" />
140+
</Typography.Title>
141+
<div
142+
css={{
143+
marginTop: theme.spacing.md,
144+
borderBottom: `1px solid ${theme.colors.border}`,
145+
}}
146+
/>
147+
</div>
148+
149+
{mutationError && (
150+
<div css={{ padding: `0 ${theme.spacing.md}px` }}>
151+
<Alert
152+
componentId="mlflow.gateway.edit-endpoint.mutation-error"
153+
closable={false}
154+
message={errorMessage}
155+
type="error"
156+
css={{ marginBottom: theme.spacing.md }}
157+
/>
158+
</div>
159+
)}
160+
161+
<div
162+
css={{
163+
flex: 1,
164+
display: 'flex',
165+
gap: theme.spacing.md,
166+
padding: `0 ${theme.spacing.md}px`,
167+
overflow: 'auto',
168+
}}
169+
>
170+
<div css={{ flex: 1, maxWidth: 700 }}>
171+
<LongFormSection
172+
titleWidth={LONG_FORM_TITLE_WIDTH}
173+
title={intl.formatMessage({
174+
defaultMessage: 'General',
175+
description: 'Section title for general settings',
176+
})}
177+
>
178+
<Controller
179+
control={form.control}
180+
name="name"
181+
render={({ field, fieldState }) => (
182+
<div>
183+
<FormUI.Label htmlFor="mlflow.gateway.edit-endpoint.name">
184+
<FormattedMessage defaultMessage="Name" description="Label for endpoint name input" />
185+
</FormUI.Label>
186+
<GatewayInput
187+
id="mlflow.gateway.edit-endpoint.name"
188+
componentId="mlflow.gateway.edit-endpoint.name"
189+
{...field}
190+
onChange={(e) => {
191+
field.onChange(e);
192+
form.clearErrors('name');
193+
resetErrors();
194+
}}
195+
onBlur={() => {
196+
field.onBlur();
197+
onNameBlur();
198+
}}
199+
placeholder={intl.formatMessage({
200+
defaultMessage: 'my-endpoint',
201+
description: 'Placeholder for endpoint name input',
202+
})}
203+
validationState={fieldState.error ? 'error' : undefined}
204+
/>
205+
{fieldState.error && <FormUI.Message type="error" message={fieldState.error.message} />}
206+
</div>
207+
)}
208+
/>
209+
</LongFormSection>
210+
211+
<LongFormSection
212+
titleWidth={LONG_FORM_TITLE_WIDTH}
213+
title={intl.formatMessage({
214+
defaultMessage: 'Model configuration',
215+
description: 'Section title for model configuration',
216+
})}
217+
>
218+
<div css={{ display: 'flex', flexDirection: 'column', gap: theme.spacing.md }}>
219+
<Controller
220+
control={form.control}
221+
name="provider"
222+
rules={{ required: 'Provider is required' }}
223+
render={({ field, fieldState }) => (
224+
<ProviderSelect
225+
value={field.value}
226+
onChange={(value) => {
227+
field.onChange(value);
228+
form.setValue('modelName', '');
229+
form.setValue('existingSecretId', '');
230+
form.setValue('secretMode', 'new');
231+
form.setValue('newSecret', {
232+
name: '',
233+
authMode: '',
234+
secretFields: {},
235+
configFields: {},
236+
});
237+
}}
238+
error={fieldState.error?.message}
239+
componentIdPrefix="mlflow.gateway.edit-endpoint.provider"
240+
/>
241+
)}
242+
/>
243+
244+
<Controller
245+
control={form.control}
246+
name="modelName"
247+
rules={{ required: 'Model is required' }}
248+
render={({ field, fieldState }) => (
249+
<ModelSelect
250+
provider={provider}
251+
value={field.value}
252+
onChange={field.onChange}
253+
error={fieldState.error?.message}
254+
componentIdPrefix="mlflow.gateway.edit-endpoint.model"
255+
/>
256+
)}
257+
/>
258+
</div>
259+
</LongFormSection>
260+
261+
<LongFormSection
262+
titleWidth={LONG_FORM_TITLE_WIDTH}
263+
title={intl.formatMessage({
264+
defaultMessage: 'Connections',
265+
description: 'Section title for authentication',
266+
})}
267+
hideDivider
268+
>
269+
<ApiKeyConfigurator
270+
value={apiKeyConfig}
271+
onChange={handleApiKeyChange}
272+
provider={provider}
273+
existingSecrets={existingSecrets}
274+
isLoadingSecrets={isLoadingSecrets}
275+
authModes={authModes}
276+
defaultAuthMode={defaultAuthMode}
277+
isLoadingProviderConfig={isLoadingProviderConfig}
278+
componentIdPrefix="mlflow.gateway.edit-endpoint.api-key"
279+
/>
280+
</LongFormSection>
281+
</div>
282+
283+
<div
284+
css={{
285+
width: 280,
286+
flexShrink: 0,
287+
position: 'sticky',
288+
top: 0,
289+
alignSelf: 'flex-start',
290+
}}
291+
>
292+
<LongFormSummary
293+
title={intl.formatMessage({
294+
defaultMessage: 'Summary',
295+
description: 'Summary sidebar title',
296+
})}
297+
>
298+
<div css={{ display: 'flex', flexDirection: 'column', gap: theme.spacing.md }}>
299+
<div css={{ display: 'flex', flexDirection: 'column', gap: theme.spacing.xs }}>
300+
<Typography.Text bold color="secondary">
301+
<FormattedMessage defaultMessage="Provider" description="Summary provider label" />
302+
</Typography.Text>
303+
{provider ? (
304+
<Typography.Text>{formatProviderName(provider)}</Typography.Text>
305+
) : (
306+
<Typography.Text color="secondary">
307+
<FormattedMessage defaultMessage="Not configured" description="Summary not configured" />
308+
</Typography.Text>
309+
)}
310+
</div>
311+
312+
<div css={{ display: 'flex', flexDirection: 'column', gap: theme.spacing.xs }}>
313+
<Typography.Text bold color="secondary">
314+
<FormattedMessage defaultMessage="Model" description="Summary model label" />
315+
</Typography.Text>
316+
{modelName ? (
317+
<Typography.Text>{modelName}</Typography.Text>
318+
) : (
319+
<Typography.Text color="secondary">
320+
<FormattedMessage defaultMessage="Not configured" description="Summary not configured" />
321+
</Typography.Text>
322+
)}
323+
</div>
324+
325+
<div css={{ display: 'flex', flexDirection: 'column', gap: theme.spacing.xs }}>
326+
<Typography.Text bold color="secondary">
327+
<FormattedMessage defaultMessage="Connections" description="Summary connections label" />
328+
</Typography.Text>
329+
{secretMode === 'existing' && selectedSecretName ? (
330+
<Typography.Text>{selectedSecretName}</Typography.Text>
331+
) : secretMode === 'new' && newSecret?.name ? (
332+
<Typography.Text>
333+
{newSecret.name}{' '}
334+
<Typography.Text color="secondary" css={{ fontSize: theme.typography.fontSizeSm }}>
335+
<FormattedMessage defaultMessage="(new)" description="Summary new secret indicator" />
336+
</Typography.Text>
337+
</Typography.Text>
338+
) : (
339+
<Typography.Text color="secondary">
340+
<FormattedMessage defaultMessage="Not configured" description="Summary not configured" />
341+
</Typography.Text>
342+
)}
343+
</div>
344+
</div>
345+
</LongFormSummary>
346+
</div>
347+
</div>
348+
349+
<div
350+
css={{
351+
display: 'flex',
352+
justifyContent: 'flex-end',
353+
gap: theme.spacing.sm,
354+
padding: theme.spacing.md,
355+
borderTop: `1px solid ${theme.colors.border}`,
356+
flexShrink: 0,
357+
}}
358+
>
359+
<Button componentId="mlflow.gateway.edit-endpoint.cancel" onClick={onCancel}>
360+
<FormattedMessage defaultMessage="Cancel" description="Cancel button" />
361+
</Button>
362+
<Tooltip
363+
componentId="mlflow.gateway.edit-endpoint.save-tooltip"
364+
content={
365+
!isFormComplete
366+
? intl.formatMessage({
367+
defaultMessage: 'Please select a provider, model, and configure authentication',
368+
description: 'Tooltip shown when save button is disabled due to incomplete form',
369+
})
370+
: !hasChanges
371+
? intl.formatMessage({
372+
defaultMessage: 'No changes to save',
373+
description: 'Tooltip shown when save button is disabled due to no changes',
374+
})
375+
: undefined
376+
}
377+
>
378+
<Button
379+
componentId="mlflow.gateway.edit-endpoint.save"
380+
type="primary"
381+
onClick={form.handleSubmit(onSubmit)}
382+
loading={isSubmitting}
383+
disabled={!isFormComplete || !hasChanges}
384+
>
385+
<FormattedMessage defaultMessage="Save changes" description="Save changes button" />
386+
</Button>
387+
</Tooltip>
388+
</div>
389+
</div>
390+
);
391+
};

0 commit comments

Comments
 (0)