fix(style): k8s form

This commit is contained in:
jialin
2026-06-01 21:06:56 +08:00
committed by jialin
parent e314f25a77
commit 95ffe0350e
12 changed files with 328 additions and 249 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ export default {
'menu.usage': '使用量',
'menu.404': '404',
'menu.resources.workers': '节点',
'menu.resources.gpus': 'GPUs',
'menu.resources.gpus': 'GPU',
'menu.models.modelfiles': '模型文件',
'menu.accessControl': '访问控制',
'menu.accessControl.apikeys': 'API 密钥',
+3 -1
View File
@@ -276,7 +276,9 @@ const VersionsForm: React.FC<AddModalProps> = ({
key={name}
defaultOpen
styles={{
body: collapseKey.has(name) ? { padding: 16 } : {},
body: collapseKey.has(name)
? { paddingInline: 16, paddingBlock: '16px 0' }
: {},
content: { paddingTop: 0 },
header: {
backgroundColor: 'unset'
@@ -11,8 +11,14 @@ import {
import { useIntl } from '@umijs/max';
import { Form } from 'antd';
import { useAtomValue } from 'jotai';
import React, { forwardRef, useEffect, useImperativeHandle } from 'react';
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useState
} from 'react';
import { ProviderType, ProviderValueMap } from '../config';
import { FormContext } from '../config/form-context';
import {
ClusterFormData as FormData,
ClusterListItem as ListItem
@@ -37,6 +43,7 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
const [k8sActiveKey, setK8sActiveKey] = React.useState<string[]>([
'k8sOptions'
]);
const [submitAttempted, setSubmitAttempted] = useState(false);
const advanceConfigRef = React.useRef<any>(null);
const systemConfig = useAtomValue(systemConfigAtom);
@@ -171,7 +178,12 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
};
},
validateFields: async () => {
await form.validateFields();
try {
await form.validateFields();
} catch (e) {
setSubmitAttempted(true);
throw e;
}
const values = form.getFieldsValue(true);
const workerConfig = yaml2Json(
@@ -187,103 +199,112 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
}
}));
const handleOnFinishFailed = () => {
setSubmitAttempted(true);
};
return (
<Form
name="clusterForm"
form={form}
onFinish={handleOnFinish}
preserve={false}
scrollToFirstError={true}
initialValues={currentData}
>
<Form.Item<FormData>
name="name"
rules={[
{
required: true,
message: intl.formatMessage(
{ id: 'common.form.rule.input' },
<FormContext.Provider value={{ submitAttempted }}>
<Form
name="clusterForm"
form={form}
onFinish={handleOnFinish}
onFinishFailed={handleOnFinishFailed}
preserve={false}
scrollToFirstError={true}
initialValues={currentData}
>
<Form.Item<FormData>
name="name"
rules={[
{
required: true,
message: intl.formatMessage(
{ id: 'common.form.rule.input' },
{
name: intl.formatMessage({ id: 'common.table.name' })
}
)
}
]}
>
<CInput.Input
label={intl.formatMessage({ id: 'common.table.name' })}
required
trim={false}
></CInput.Input>
</Form.Item>
<PluginExtraFields name="CreateOrgScopeField" context={{ action }} />
{provider === ProviderValueMap.DigitalOcean && (
<CloudProvider
provider={provider}
action={action}
credentialID={currentData?.credential_id}
credentialList={credentialList}
></CloudProvider>
)}
<Form.Item<FormData>
name="description"
rules={[{ required: false }]}
style={{ marginBottom: 8 }}
>
<SealTextArea
scaleSize
label={intl.formatMessage({ id: 'common.table.description' })}
></SealTextArea>
</Form.Item>
{provider === ProviderValueMap.Kubernetes && (
<CollapsePanel
accordion={false}
activeKey={k8sActiveKey}
onChange={(keys) =>
setK8sActiveKey(Array.isArray(keys) ? keys : [keys])
}
items={[
{
name: intl.formatMessage({ id: 'common.table.name' })
key: 'k8sOptions',
label: intl.formatMessage({
id: 'clusters.k8sOptions.title'
}),
forceRender: true,
children: (
<K8sPodSpec
key={currentData?.id ?? 'new'}
action={action}
initialGpuInstanceOptions={
currentData?.k8s_options?.gpuInstanceOptions
}
></K8sPodSpec>
)
}
)
}
]}
>
<CInput.Input
label={intl.formatMessage({ id: 'common.table.name' })}
required
trim={false}
></CInput.Input>
</Form.Item>
<PluginExtraFields name="CreateOrgScopeField" context={{ action }} />
{provider === ProviderValueMap.DigitalOcean && (
<CloudProvider
provider={provider}
action={action}
credentialID={currentData?.credential_id}
credentialList={credentialList}
></CloudProvider>
)}
]}
></CollapsePanel>
)}
<Form.Item<FormData>
name="description"
rules={[{ required: false }]}
style={{ marginBottom: 8 }}
>
<SealTextArea
scaleSize
label={intl.formatMessage({ id: 'common.table.description' })}
></SealTextArea>
</Form.Item>
{provider === ProviderValueMap.Kubernetes && (
<CollapsePanel
accordion={false}
activeKey={k8sActiveKey}
onChange={(keys) =>
setK8sActiveKey(Array.isArray(keys) ? keys : [keys])
}
activeKey={activeKey}
onChange={handleOnCollapseChange}
items={[
{
key: 'k8sOptions',
label: intl.formatMessage({ id: 'clusters.k8sOptions.title' }),
key: 'advanceConfig',
label: intl.formatMessage({ id: 'resources.form.advanced' }),
forceRender: true,
children: (
<K8sPodSpec
key={currentData?.id ?? 'new'}
<AdvanceConfig
action={action}
initialGpuInstanceOptions={
currentData?.k8s_options?.gpuInstanceOptions
}
></K8sPodSpec>
provider={provider}
currentData={currentData}
ref={advanceConfigRef}
></AdvanceConfig>
)
}
]}
></CollapsePanel>
)}
<CollapsePanel
accordion={false}
activeKey={activeKey}
onChange={handleOnCollapseChange}
items={[
{
key: 'advanceConfig',
label: intl.formatMessage({ id: 'resources.form.advanced' }),
forceRender: true,
children: (
<AdvanceConfig
action={action}
provider={provider}
currentData={currentData}
ref={advanceConfigRef}
></AdvanceConfig>
)
}
]}
></CollapsePanel>
</Form>
</Form>
</FormContext.Provider>
);
}
);
@@ -0,0 +1,81 @@
import { Input as CInput } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Divider } from 'antd';
import React from 'react';
import styled from 'styled-components';
import { ImageCredential } from '../config/types';
const Wrapper = styled.div`
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
.row {
display: flex;
gap: 12px;
> * {
flex: 1;
min-width: 0;
}
}
`;
interface CredentialItemProps {
item: ImageCredential;
validated: boolean;
index: number;
onChange: (partial: Partial<ImageCredential>) => void;
}
const CredentialItem: React.FC<CredentialItemProps> = ({
item,
index,
validated,
onChange
}) => {
const intl = useIntl();
const registryEmpty = !item.registry?.trim();
const registryStatus =
validated && registryEmpty ? ('error' as const) : ('success' as const);
return (
<Wrapper>
{index !== 0 && (
<Divider style={{ marginBlock: 8 }} variant="dashed"></Divider>
)}
<CInput.Input
required
status={registryStatus}
value={item.registry}
onChange={(e) => onChange({ registry: e.target.value })}
label={intl.formatMessage({
id: 'clusters.imageCredentials.registry'
})}
/>
<div className="row">
<CInput.Input
value={item.username}
status="success"
onChange={(e) => onChange({ username: e.target.value })}
label={intl.formatMessage({
id: 'clusters.imageCredentials.username'
})}
/>
<CInput.Password
status="success"
value={item.password}
onChange={(e) => onChange({ password: e.target.value })}
label={intl.formatMessage({
id: 'clusters.imageCredentials.password'
})}
/>
</div>
</Wrapper>
);
};
export default CredentialItem;
@@ -0,0 +1,98 @@
import { MetadataList, useAppUtils } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Form } from 'antd';
import React, { useEffect } from 'react';
import { useFormContext } from '../config/form-context';
import { ImageCredential as ImageCredentialType } from '../config/types';
import imgCredentialStyle from '../styles/img-credential.less';
import CredentialItem from './credential-item';
const FIELD_PATH = ['k8s_options', 'imageCredentials'];
const ImageCredential: React.FC = () => {
const intl = useIntl();
const { getRuleMessage } = useAppUtils();
const { submitAttempted } = useFormContext();
const validated = !!submitAttempted;
const form = Form.useFormInstance();
const credentials: ImageCredentialType[] =
Form.useWatch(FIELD_PATH, form) || [];
const updateList = (list: ImageCredentialType[]) => {
form.setFieldValue(FIELD_PATH, list);
};
useEffect(() => {
if (validated) {
form.validateFields([FIELD_PATH]).catch(() => {});
}
}, [credentials, validated]);
const handleAdd = () => {
updateList([...credentials, { registry: '', username: '', password: '' }]);
};
const handleDelete = (index: number) => {
const next = [...credentials];
next.splice(index, 1);
updateList(next);
};
const handleChange = (
index: number,
partial: Partial<ImageCredentialType>
) => {
const next = [...credentials];
next[index] = { ...next[index], ...partial };
updateList(next);
};
return (
<div className={imgCredentialStyle.container}>
<Form.Item
name={FIELD_PATH}
style={{ marginTop: 24 }}
rules={[
{
validator: async (_, value: ImageCredentialType[]) => {
if (!value?.length) return;
const hasMissingRegistry = value.some(
(item) => !item?.registry?.trim()
);
if (hasMissingRegistry) {
throw new Error(
getRuleMessage('input', 'clusters.imageCredentials.registry')
);
}
}
}
]}
>
<MetadataList
label={intl.formatMessage({ id: 'clusters.imageCredentials.title' })}
btnText={intl.formatMessage({ id: 'clusters.imageCredentials.add' })}
dataList={credentials}
onAdd={handleAdd}
onDelete={handleDelete}
styles={{
delBtn: {
marginTop: 44
}
}}
>
{(item: ImageCredentialType, index: number) => (
<CredentialItem
item={item}
index={index}
key={index}
validated={validated}
onChange={(partial) => handleChange(index, partial)}
/>
)}
</MetadataList>
</Form.Item>
</div>
);
};
export default ImageCredential;
@@ -1,20 +1,11 @@
import { PageActionType } from '@/config/types';
import {
MinusOutlined,
PlusOutlined,
QuestionCircleOutlined
} from '@ant-design/icons';
import {
Input as CInput,
LabelSelector,
SwitchCard,
useAppUtils
} from '@gpustack/core-ui';
import { Input as CInput, LabelSelector, SwitchCard } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Button, Form, Tooltip } from 'antd';
import { Form } from 'antd';
import React, { useState } from 'react';
import styled from 'styled-components';
import { GpuInstanceOptions } from '../config/types';
import ImageCredential from './image-credential';
import K8SVolumeMount from './k8s-volume-mount';
const Title = styled.div`
@@ -32,151 +23,21 @@ const SectionWrap = styled.div`
margin-bottom: 16px;
`;
const ImageCredentialsForm: React.FC = () => {
const intl = useIntl();
const { getRuleMessage } = useAppUtils();
return (
<SectionWrap>
<Form.List name={['k8s_options', 'imageCredentials']}>
{(fields, { add, remove }) => (
<>
<Title>
<div className="flex-center gap-8">
<span>
{intl.formatMessage({
id: 'clusters.imageCredentials.title'
})}
</span>
<Button
type="link"
onClick={() =>
add({ registry: '', username: '', password: '' })
}
>
<PlusOutlined />{' '}
{intl.formatMessage({
id: 'clusters.imageCredentials.add'
})}
</Button>
</div>
</Title>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{fields.map(({ key, name }) => (
<div
key={key}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 8,
padding: 12,
border: '1px solid var(--ant-color-split)',
borderRadius: 'var(--ant-border-radius-lg)'
}}
>
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: 12
}}
>
<Form.Item
name={[name, 'registry']}
rules={[
{
required: true,
message: getRuleMessage(
'input',
'clusters.imageCredentials.registry'
)
}
]}
style={{ marginBottom: 0 }}
>
<CInput.Input
required
label={intl.formatMessage({
id: 'clusters.imageCredentials.registry'
})}
></CInput.Input>
</Form.Item>
<div style={{ display: 'flex', gap: 12 }}>
<div style={{ flex: 1 }}>
<Form.Item
name={[name, 'username']}
style={{ marginBottom: 0 }}
>
<CInput.Input
label={intl.formatMessage({
id: 'clusters.imageCredentials.username'
})}
></CInput.Input>
</Form.Item>
</div>
<div style={{ flex: 1 }}>
<Form.Item
name={[name, 'password']}
style={{ marginBottom: 0 }}
>
<CInput.Password
label={intl.formatMessage({
id: 'clusters.imageCredentials.password'
})}
></CInput.Password>
</Form.Item>
</div>
</div>
</div>
<Button
size="small"
shape="circle"
style={{ marginTop: 8 }}
onClick={() => remove(name)}
>
<MinusOutlined />
</Button>
</div>
))}
</div>
</>
)}
</Form.List>
</SectionWrap>
);
};
const NodeSelectorForm: React.FC = () => {
const intl = useIntl();
return (
<SectionWrap>
<Title>
<span className="flex-center gap-4">
<span>
{intl.formatMessage({ id: 'clusters.nodeSelector.title' })}
</span>
<Tooltip
title={intl.formatMessage({ id: 'clusters.nodeSelector.tip' })}
>
<QuestionCircleOutlined
style={{ color: 'var(--ant-color-text-secondary)' }}
/>
</Tooltip>
</span>
</Title>
<Form.Item name={['k8s_options', 'nodeSelector']}>
<LabelSelector
label={intl.formatMessage({ id: 'clusters.nodeSelector.title' })}
description={intl.formatMessage({ id: 'clusters.nodeSelector.tip' })}
></LabelSelector>
</Form.Item>
</SectionWrap>
);
};
// Render namespace. Kept as the first field of the section so the most
// fundamental K8s deployment knob is set before the rest.
const NamespaceForm: React.FC = () => {
const intl = useIntl();
@@ -197,8 +58,6 @@ const NamespaceForm: React.FC = () => {
);
};
// Operator-image override. A plain string knob that used to ride along inside
// worker_config; it now lives directly on k8s_options.
const OperatorImageForm: React.FC = () => {
const intl = useIntl();
@@ -273,7 +132,7 @@ const K8sPodSpec: React.FC<{
<GpuInstanceOptionsForm initialValue={initialGpuInstanceOptions} />
<NamespaceForm />
<K8SVolumeMount action={action}></K8SVolumeMount>
<ImageCredentialsForm />
<ImageCredential />
<NodeSelectorForm />
<OperatorImageForm />
</>
@@ -169,7 +169,9 @@ const VolumeMountsForm: React.FC<{ action: PageActionType }> = ({ action }) => {
open={collapseKey.has(name)}
onToggle={(open: boolean) => onToggle(open, name)}
styles={{
body: collapseKey.has(name) ? { padding: 16 } : {},
body: collapseKey.has(name)
? { paddingBlock: '16px 0', paddingInline: 16 }
: {},
content: { paddingTop: 0 },
header: {
backgroundColor: 'unset'
@@ -0,0 +1,12 @@
import { createContext, useContext } from 'react';
import { ClusterListItem } from './types';
// for cluster form
interface FormContextProps {
currentData?: ClusterListItem;
submitAttempted?: boolean;
}
export const FormContext = createContext<FormContextProps>({});
export const useFormContext = () => useContext(FormContext);
@@ -0,0 +1,11 @@
.container {
:global(.item-container) {
align-items: flex-start;
&:nth-child(2) {
:global(.btn) {
margin-top: 16px !important;
}
}
}
}
@@ -33,6 +33,7 @@ const Basic = ({ action }: { action: string }) => {
</Form.Item>
<Form.Item<FormData> name="displayName">
<CInput.Input
trim={false}
label={intl.formatMessage({ id: 'common.table.displayName' })}
/>
</Form.Item>
@@ -1,6 +1,5 @@
import { ModelFileFormData as FormData } from '@/pages/resources/config/types';
import {
CheckboxField,
Input as CInput,
Cascader as SealCascader,
Select as SealSelect,
@@ -204,6 +203,13 @@ const TargetForm: React.FC<TargetFormProps> = forwardRef((props, ref) => {
root: 'cascader-popup-wrapper gpu-selector'
}
}}
styles={{
popup: {
listItem: {
maxWidth: '100%'
}
}
}}
maxTagCount={1}
label={intl.formatMessage({ id: 'resources.worker' })}
options={workerOptions}
@@ -241,20 +247,6 @@ const TargetForm: React.FC<TargetFormProps> = forwardRef((props, ref) => {
></CInput.Input>
</Form.Item>
)}
{source === modelSourceMap.local_path_value && (
<Form.Item<FormData>
name="is_lora"
key="is_lora"
valuePropName="checked"
noStyle
>
<CheckboxField
label={intl.formatMessage({
id: 'resources.modelfiles.form.isLora'
})}
></CheckboxField>
</Form.Item>
)}
</Form>
</div>
);