import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import { Input as CInput, LabelSelector } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks';
import { Form } from 'antd';
import _ from 'lodash';
import React, { useEffect, useId, useMemo } from 'react';
import styled from 'styled-components';
import { useFormContext } from '../config/form-context';
import { useStepsContext } from '../config/steps-context';
import { ClusterListItem as ListItem } from '../config/types';
import ImageCredential from './image-credential';
import K8SVolumeMount from './k8s-volume-mount';
const SectionWrap = styled.div`
margin-bottom: 16px;
`;
const NodeSelectorForm: React.FC = () => {
const intl = useIntl();
return (
);
};
const NamespaceForm: React.FC = () => {
const intl = useIntl();
return (
value || null}
>
);
};
export const OperatorImageForm: React.FC = () => {
const intl = useIntl();
return (
value || null}
>
);
};
// Visual parity with @gpustack/core-ui's SwitchCard so the selector blends
// in with surrounding form fields: same border, radius, padding, and
// typography. The only differences are the two-column grid layout and an
// active state (blue border + tinted background) to mark the selection.
const ClusterTypeWrap = styled.div`
margin-bottom: 24px;
`;
const ClusterTypeLabel = styled.div`
color: var(--ant-color-text);
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
.required {
color: var(--ant-color-error);
margin-left: 4px;
}
`;
const ClusterTypeGrid = styled.div`
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
`;
const ClusterTypeCard = styled.div<{ $active: boolean }>`
position: relative;
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
border-radius: var(--ant-border-radius-lg);
border: 1px solid
${(p) =>
p.$active ? 'var(--ant-color-primary)' : 'var(--ant-color-border)'};
background: ${(p) =>
p.$active ? 'var(--ant-color-primary-bg)' : 'transparent'};
cursor: pointer;
transition:
border-color 0.2s,
background-color 0.2s;
&:hover,
&:focus-visible {
border-color: var(--ant-color-primary);
}
&:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--ant-control-outline);
}
.body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.title {
color: var(--ant-color-text);
font-size: 14px;
font-weight: 500;
}
.description {
color: var(--ant-color-text-secondary);
}
`;
const ExperimentalTag = styled.span`
position: absolute;
right: 2px;
top: 2px;
padding: 2px;
border-radius: 2px;
font-size: 10px;
font-weight: 400;
background-color: var(--ant-blue-1);
`;
const RadioDot = styled.span<{ $active: boolean }>`
position: relative;
flex-shrink: 0;
width: 16px;
height: 16px;
margin-top: 3px;
border-radius: 50%;
border: 1.5px solid
${(p) =>
p.$active ? 'var(--ant-color-primary)' : 'var(--ant-color-border)'};
background: ${(p) =>
p.$active ? 'var(--ant-color-primary)' : 'transparent'};
transition: all 0.2s;
&::after {
content: '';
position: absolute;
inset: 0;
margin: auto;
width: 6px;
height: 6px;
border-radius: 50%;
background: #fff;
opacity: ${(p) => (p.$active ? 1 : 0)};
transition: opacity 0.2s;
}
`;
// Card-based selector for cluster type. The two options are mutually exclusive
// and the choice maps directly to the presence/absence of `gpuInstanceOptions`
// on the submitted payload. No standalone form field is registered; the click
// only updates the shared `clusterType` state (see FormContext) — the payload's
// `gpuInstanceOptions` shape is derived from it at submit (see cluster-form's
// normalizeOutgoing), and the static-address field mounts/unmounts off it.
export const ClusterTypeSelector: React.FC = () => {
const intl = useIntl();
const { presetClusterType } = useStepsContext();
const labelId = useId();
// Cluster type is shared, explicit state (see FormContext): the click is the
// source of truth. This replaced a Form.useWatch on an unregistered path that
// did not re-render reliably when cleared to undefined.
const { clusterType, setClusterType } = useFormContext();
const value: 'model' | 'gpu' = clusterType ?? 'model';
const handleSelect = useMemoizedFn((next: 'model' | 'gpu') => {
if (next === value) return;
setClusterType?.(next);
});
const options: {
key: 'model' | 'gpu';
title: string;
description: string;
experimental?: boolean;
}[] = [
{
key: 'model',
title: intl.formatMessage({ id: 'clusters.modelService.title' }),
description: intl.formatMessage({ id: 'clusters.modelService.tip' })
},
{
key: 'gpu',
title: intl.formatMessage({ id: 'clusters.gpuInstances.title' }),
description: intl.formatMessage({ id: 'clusters.gpuInstances.tip' }),
experimental: true
}
];
useEffect(() => {
if (presetClusterType) {
handleSelect(presetClusterType);
}
}, [presetClusterType]);
return (
{intl.formatMessage({ id: 'clusters.clusterType.title' })}
*
{options.map((opt) => {
const active = value === opt.key;
return (
handleSelect(opt.key)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSelect(opt.key);
}
}}
>
{opt.experimental && (
{intl.formatMessage({ id: 'common.tag.experimental' })}
)}
{opt.title}
{opt.description}
);
})}
);
};
// Static access address for GPU instances. Only shown when "GPU 服务" is
// the selected cluster type. Rendered in the advanced section, between the
// default container registry and the worker config (节点配置).
export const GpuInstancesStaticAddressForm: React.FC = () => {
const intl = useIntl();
// Visibility tracks the shared cluster-type state (see FormContext), so this
// field mounts/unmounts deterministically with the selector. Its Form.Item is
// the only thing keeping gpuInstanceOptions alive, so unmounting it here (with
// the form's preserve={false}) also clears that path from the store.
const { clusterType } = useFormContext();
if (clusterType !== 'gpu') {
return null;
}
return (
value || null}
>
);
};
// Strip UI-only noise so two k8s_options snapshots compare on real content.
// `sourceType` is derived from `volumeSource` purely for the volume-mount UI
// (see cluster-form init).
const cleanK8sOptions = (opts: any) => {
const cloned = _.cloneDeep(opts || {});
if (Array.isArray(cloned.volumeMounts)) {
cloned.volumeMounts = cloned.volumeMounts.map(
({ sourceType, ...rest }: any) => rest
);
}
return _.pickBy(cloned, (value: any) => !_.isNil(value));
};
// Custom comparator for isEqualWith: treats null, undefined, empty strings,
// empty arrays, and empty objects as equivalent. Form normalize converts empty
// inputs to null while the API may omit absent keys (read as undefined when
// accessed on the object). This lets "user clears field back to original"
// compare as unchanged without needing to recursively strip nullish values.
const nullishCustomizer = (val1: any, val2: any) => {
if (
(val1 == null && val2 === '') ||
(val1 === '' && val2 == null) ||
(_.isEmpty(val1) && val2 == null) ||
(val1 == null && _.isEmpty(val2)) ||
(_.isNil(val1) && _.isNil(val2))
) {
return true;
}
return undefined;
};
// Headless watcher: in EDIT mode it reports (via onChange) whether the user has
// changed any k8s_options field or the top-level system_default_container_registry
// from the cluster's saved values. It renders nothing — the notice itself is shown
// in the form footer, above Save/Cancel (see cluster-create.tsx), mirroring the
// model edit interaction. Must be mounted inside the cluster