feat: k8s volume mounts

This commit is contained in:
jialin
2026-04-10 15:17:58 +08:00
committed by jialin
parent aeabdab781
commit f2a4ab2654
11 changed files with 668 additions and 49 deletions
+1 -1
View File
@@ -259,7 +259,7 @@ const VersionsForm: React.FC<AddModalProps> = ({
<div
key={name}
style={{
borderRadius: 'var(--ant-border-radius)',
borderRadius: 'var(--ant-border-radius-lg)',
border: '1px solid var(--ant-color-split)'
}}
>
@@ -14,6 +14,7 @@ import {
} from '../config/types';
import AdvanceConfig from '../step-forms/advance-config';
import CloudProvider from './cloud-provider-form';
import K8SVolumeMount from './k8s-volume-mount';
type AddModalProps = {
action: PageActionType;
@@ -60,7 +61,17 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
useEffect(() => {
if (currentData) {
form.setFieldsValue(currentData);
const volumeMounts = currentData?.k8s_volume_mounts || [];
const realVolumeList = (volumeMounts || []).map(
(item: any, index: number) => ({
...item,
sourceType: Object.keys(item.volumeSource || {})[0] || 'hostPath'
})
);
form.setFieldsValue({
...currentData,
k8s_volume_mounts: realVolumeList
});
}
}, [currentData]);
@@ -151,6 +162,7 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
credentialList={credentialList}
></CloudProvider>
)}
<Form.Item<FormData>
name="description"
rules={[{ required: false }]}
@@ -161,6 +173,12 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
label={intl.formatMessage({ id: 'common.table.description' })}
></SealTextArea>
</Form.Item>
{provider === ProviderValueMap.Kubernetes && (
<K8SVolumeMount
action={action}
currentData={currentData}
></K8SVolumeMount>
)}
<CollapsePanel
accordion={false}
activeKey={activeKey}
@@ -0,0 +1,432 @@
import CollapsibleContainer from '@/components/collapse-container';
import SealCheckbox from '@/components/seal-form/seal-checkbox';
import SealInput from '@/components/seal-form/seal-input';
import SealSelect from '@/components/seal-form/seal-select';
import SealSwitch from '@/components/seal-form/seal-switch';
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import useAppUtils from '@/hooks/use-app-utils';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import { Button, Flex, Form } from 'antd';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import { hostTypeOptions, sourceTypeOptions } from '../config';
const Label = styled.span`
display: flex;
align-items: center;
gap: 4px;
color: var(--ant-color-text-secondary);
`;
const Title = styled.div`
position: sticky;
top: -16px;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--ant-color-bg-container);
font-weight: 600;
padding-top: 8px;
padding-bottom: 8px;
`;
const VolumeMountsForm: React.FC<{ action: PageActionType }> = ({ action }) => {
const form = Form.useFormInstance();
const intl = useIntl();
const { getRuleMessage } = useAppUtils();
const [collapseKey, setCollapseKey] = useState<Set<number | string>>(
new Set([0])
);
const volumeList = [
{
name: 'volume-1',
mountPath: '',
readOnly: false,
sourceType: 'hostPath',
volumeSource: {
hostPath: {
path: '',
type: 'DirectoryOrCreate'
}
}
}
];
useEffect(() => {
if (action === PageAction.CREATE) {
form.setFieldValue('k8s_volume_mounts', volumeList);
}
}, [action]);
const onToggle = (open: boolean, key: number) => {
setCollapseKey(open ? new Set([key]) : new Set());
};
const handleAdd = async () => {
try {
await form.validateFields(['k8s_volume_mounts'], {
recursive: true
});
const list = form.getFieldValue('k8s_volume_mounts') || [];
form.setFieldValue('k8s_volume_mounts', [
...list,
{
name: `volume-${list.length + 1}`,
mountPath: '',
readOnly: false,
sourceType: 'hostPath',
volumeSource: {
hostPath: {
path: '',
type: 'DirectoryOrCreate'
}
}
}
]);
setTimeout(() => {
setCollapseKey(new Set([list.length]));
}, 100);
} catch (e: any) {
const errorIndex = e?.errorFields?.[0]?.name?.[1];
if (typeof errorIndex === 'number') {
setCollapseKey(new Set([errorIndex]));
}
}
};
const handleSourceChange = (value: string, index: number) => {
const list = form.getFieldValue('k8s_volume_mounts') || [];
const updated = list.map((item: any, i: number) => {
if (i !== index) return item;
let volumeSource: any = {};
if (value === 'hostPath') {
volumeSource = {
hostPath: { path: '', type: 'DirectoryOrCreate' }
};
} else if (value === 'pvc') {
volumeSource = {
persistentVolumeClaim: { claimName: '', readOnly: false }
};
} else if (value === 'configMap') {
volumeSource = {
configMap: { name: '', optional: false }
};
}
return {
...item,
sourceType: value,
volumeSource
};
});
form.setFieldValue('k8s_volume_mounts', updated);
};
return (
<>
<Title>
<div className="flex-center gap-8">
<span>{intl.formatMessage({ id: 'clusters.volume.title' })}</span>
<Button type="link" onClick={handleAdd}>
<PlusOutlined /> {intl.formatMessage({ id: 'clusters.volume.add' })}
</Button>
</div>
</Title>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '16px',
marginBottom: '24px'
}}
>
<Form.List name="k8s_volume_mounts">
{(fields, { remove }) => {
const list = form.getFieldValue('k8s_volume_mounts') || [];
return fields.map(({ name }) => {
const item = list[name] || {};
return (
<div
key={name}
style={{
border: '1px solid var(--ant-color-split)',
borderRadius: 'var(--ant-border-radius-lg)'
}}
>
<CollapsibleContainer
collapsible={true}
showExpandIcon={true}
open={collapseKey.has(name)}
onToggle={(open) => onToggle(open, name)}
styles={{
body: collapseKey.has(name) ? { padding: 16 } : {},
content: { paddingTop: 0 },
header: {
backgroundColor: 'unset'
}
}}
title={
<Label>
<span>
{intl.formatMessage({ id: 'clusters.volume.name' })}:
</span>
<span>{list[name]?.name}</span>
</Label>
}
right={
<Button
size="small"
shape="circle"
onClick={() => remove(name)}
>
<MinusOutlined />
</Button>
}
>
<Form.Item
name={[name, 'name']}
rules={[
{
required: true,
message: getRuleMessage(
'input',
'clusters.volume.name'
)
}
]}
>
<SealInput.Input
label={intl.formatMessage({
id: 'clusters.volume.name'
})}
required
></SealInput.Input>
</Form.Item>
<Flex style={{ gap: 16, width: '100%' }}>
{/* Mount Path */}
<div style={{ flex: 1 }}>
<Form.Item
name={[name, 'mountPath']}
rules={[
{
required: true,
message: getRuleMessage(
'input',
'clusters.volume.mountPath'
)
}
]}
>
<SealInput.Input
required
placeholder={intl.formatMessage({
id: 'clusters.volume.mountPath.format'
})}
label={intl.formatMessage({
id: 'clusters.volume.mountPath'
})}
></SealInput.Input>
</Form.Item>
</div>
{/* ReadOnly */}
<Form.Item
style={{ width: 240 }}
name={[name, 'readOnly']}
valuePropName="checked"
>
<SealSwitch
label={intl.formatMessage({
id: 'clusters.volume.readOnly'
})}
/>
</Form.Item>
</Flex>
{/* Source Type */}
<Form.Item
name={[name, 'sourceType']}
rules={[
{
required: true,
message: getRuleMessage(
'select',
'clusters.volume.sourceType'
)
}
]}
>
<SealSelect
required
label={intl.formatMessage({
id: 'clusters.volume.sourceType'
})}
onChange={(value) => handleSourceChange(value, name)}
options={sourceTypeOptions}
value={item.sourceType}
></SealSelect>
</Form.Item>
{/* Dynamic Source Form */}
{item.sourceType === 'hostPath' && (
<Flex style={{ gap: 16 }}>
<div style={{ flex: 1 }}>
<Form.Item
name={[name, 'volumeSource', 'hostPath', 'path']}
rules={[
{
required: true,
message: getRuleMessage(
'input',
'clusters.volume.hostPath.path'
)
}
]}
>
<SealInput.Input
required
placeholder={intl.formatMessage({
id: 'clusters.volume.mountPath.format'
})}
label={intl.formatMessage({
id: 'clusters.volume.sourceType.hostPath'
})}
></SealInput.Input>
</Form.Item>
</div>
<Form.Item
style={{ width: 240 }}
name={[name, 'volumeSource', 'hostPath', 'type']}
rules={[
{
required: true,
message: getRuleMessage(
'select',
'clusters.volume.hostPath.type'
)
}
]}
>
<SealSelect
required
options={hostTypeOptions}
label={intl.formatMessage({
id: 'clusters.volume.hostPath.type'
})}
></SealSelect>
</Form.Item>
</Flex>
)}
{item.sourceType === 'pvc' && (
<Flex style={{ gap: 16 }}>
<div style={{ flex: 1 }}>
<Form.Item
name={[
name,
'volumeSource',
'persistentVolumeClaim',
'claimName'
]}
rules={[
{
required: true,
message: getRuleMessage(
'input',
'clusters.volume.pvc.claimName'
)
}
]}
>
<SealInput.Input
label={intl.formatMessage({
id: 'clusters.volume.pvc.claimName'
})}
required
/>
</Form.Item>
</div>
<Form.Item
style={{ width: 240 }}
name={[
name,
'volumeSource',
'persistentVolumeClaim',
'readOnly'
]}
valuePropName="checked"
>
<SealSwitch
label={intl.formatMessage({
id: 'clusters.volume.pvc.readOnly'
})}
/>
</Form.Item>
</Flex>
)}
{item.sourceType === 'configMap' && (
<Flex style={{ gap: 16 }}>
<div style={{ flex: 1 }}>
<Form.Item
name={[name, 'volumeSource', 'configMap', 'name']}
rules={[
{
required: true,
message: getRuleMessage(
'input',
'clusters.volume.configMap.name'
)
}
]}
>
<SealInput.Input
label={intl.formatMessage({
id: 'clusters.volume.configMap.name'
})}
required
/>
</Form.Item>
</div>
<Form.Item
style={{ width: 240 }}
name={[name, 'volumeSource', 'configMap', 'optional']}
valuePropName="checked"
>
<SealCheckbox
label={intl.formatMessage({
id: 'clusters.volume.configMap.optional'
})}
/>
</Form.Item>
</Flex>
)}
</CollapsibleContainer>
</div>
);
});
}}
</Form.List>
</div>
</>
);
};
export default VolumeMountsForm;
@@ -97,3 +97,55 @@ export const CloudOptionItems = [
key: 'volumes'
}
];
export const hostTypeOptions = [
{
label: 'clusters.volume.hostPath.type.directory',
locale: true,
value: 'Directory'
},
{
label: 'clusters.volume.hostPath.type.directoryOrCreate',
locale: true,
value: 'DirectoryOrCreate'
},
{
label: 'clusters.volume.hostPath.type.file',
locale: true,
value: 'File'
},
{
label: 'clusters.volume.hostPath.type.fileOrCreate',
locale: true,
value: 'FileOrCreate'
},
{
label: 'clusters.volume.hostPath.type.socket',
locale: true,
value: 'Socket'
},
{
label: 'clusters.volume.hostPath.type.charDevice',
locale: true,
value: 'CharDevice'
},
{
label: 'clusters.volume.hostPath.type.blockDevice',
locale: true,
value: 'BlockDevice'
}
];
export const sourceTypeOptions = [
{
label: 'clusters.volume.sourceType.hostPath',
locale: true,
value: 'hostPath'
},
{ label: 'clusters.volume.sourceType.pvc', locale: true, value: 'pvc' },
{
label: 'clusters.volume.sourceType.configMap',
locale: true,
value: 'configMap'
}
];
@@ -48,6 +48,25 @@ export interface NodePoolListItem extends NodePoolFormData {
updated_at: string;
cluster_id: number;
}
export interface VolumeMount {
name: string;
mountPath: string;
readOnly: boolean;
volumeSource: {
hostPath: {
path: string;
type: string;
};
persistentVolumeClaim: {
claimName: string;
readOnly: boolean;
};
configMap: {
name: string;
optional: boolean;
};
};
}
export interface ClusterListItem {
name: string;
@@ -68,6 +87,7 @@ export interface ClusterListItem {
state: ClusterStatusType;
state_message: string;
worker_pools: NodePoolListItem[];
k8s_volume_mounts?: VolumeMount[];
}
export interface ClusterFormData {
@@ -81,6 +101,7 @@ export interface ClusterFormData {
server_url?: string;
worker_config?: Record<string, any>;
worker_pools?: NodePoolFormData[];
k8s_volume_mounts?: VolumeMount[];
}
export interface SystemConfig {
@@ -1,20 +1,8 @@
import { PageActionType } from '@/config/types';
import { useIntl } from '@umijs/max';
import { forwardRef, useImperativeHandle, useRef } from 'react';
import styled from 'styled-components';
import ClusterForm from '../components/cluster-form';
import { ProviderType } from '../config';
const Title = styled.span`
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
font-size: 14px;
.text {
font-size: 20px;
}
`;
interface BasicFormProps {
provider: ProviderType;
action: PageActionType;
@@ -23,7 +11,6 @@ interface BasicFormProps {
}
const BasicForm = forwardRef((props: BasicFormProps, ref) => {
const intl = useIntl();
const { provider, credentialList, action, currentData } = props;
const formRef = useRef<any>(null);
@@ -38,25 +25,14 @@ const BasicForm = forwardRef((props: BasicFormProps, ref) => {
}));
return (
<div>
{/* <PageTools
marginBottom={16}
left={
<Title>
{intl.formatMessage({ id: 'clusters.create.configBasic' })}
</Title>
}
marginTop={0}
></PageTools> */}
<ClusterForm
provider={provider}
action={action}
ref={formRef}
credentialList={credentialList}
onFinish={handleOnFinish}
currentData={currentData}
></ClusterForm>
</div>
<ClusterForm
provider={provider}
action={action}
ref={formRef}
credentialList={credentialList}
onFinish={handleOnFinish}
currentData={currentData}
></ClusterForm>
);
});