feat: template port name

This commit is contained in:
jialin
2026-05-15 16:22:07 +08:00
committed by jialin
parent 5d75cfdfc5
commit c6e7075bf5
18 changed files with 292 additions and 37 deletions
@@ -176,16 +176,7 @@ const AddModal: React.FC<AddModalProps> = ({
// filter instance types
const filteredInstanceTypes = instanceTypeList.filter((item) =>
matchKeyword(
[
item.metadata?.name,
item.spec?.memory,
item.status?.cpu?.capacity,
item.status?.ram?.capacity,
item.status?.accelerator?.remaining
],
instanceKeyword
)
matchKeyword([item.metadata?.name], instanceKeyword)
);
// filter templates based on selection and keyword
@@ -256,17 +247,20 @@ const AddModal: React.FC<AddModalProps> = ({
<div className={styles.colWrapper}>
<ColumnWrapper styles={{ container: { paddingBlock: 0 } }}>
<div className={styles.panelBody}>
<ColTitle style={{ paddingBottom: 0 }}>
{intl.formatMessage({ id: 'gpuservice.instance.types' })}
</ColTitle>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 16,
position: 'sticky',
top: 0,
zIndex: 10,
backgroundColor: 'var(--ant-color-bg-elevated)'
}}
>
<ColTitle style={{ paddingBottom: 0 }}>
{intl.formatMessage({ id: 'gpuservice.instance.types' })}
</ColTitle>
<Input
allowClear
prefix={<SearchOutlined className="text-tertiary" />}
@@ -290,17 +284,20 @@ const AddModal: React.FC<AddModalProps> = ({
<div className={styles.colWrapper}>
<ColumnWrapper styles={{ container: { paddingBlock: 0 } }}>
<div className={styles.panelBody}>
<ColTitle style={{ paddingBottom: 0 }}>
{intl.formatMessage({ id: 'gpuservice.instance.templates' })}
</ColTitle>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 16,
position: 'sticky',
top: 0,
zIndex: 10,
backgroundColor: 'var(--ant-color-bg-elevated)'
}}
>
<ColTitle style={{ paddingBottom: 0 }}>
{intl.formatMessage({ id: 'gpuservice.instance.templates' })}
</ColTitle>
<Input
allowClear
prefix={<SearchOutlined className="text-tertiary" />}
@@ -15,6 +15,7 @@ export interface FormData {
ports: {
port: number;
protocol?: string;
name?: string;
}[];
env: {
name: string;
@@ -68,7 +68,7 @@ const TemplateSelector: React.FC<TemplateSelectorProps> = ({
<TemplateContent>
<div className="name">
<AutoTooltip ghost minWidth={20}>
{item.name}
{item.displayName || item.name || '-'}
</AutoTooltip>
</div>
<div className="info">
@@ -2,17 +2,27 @@ import ascendLogo from '@/assets/logo/ascend.png';
import CambriconPNG from '@/assets/logo/cambricon.png';
import hyponPNG from '@/assets/logo/hygon.png';
import iluvatarWEBP from '@/assets/logo/Iluvatar.png';
import jupyterLogo from '@/assets/logo/jupyter.png';
import metaxLogo from '@/assets/logo/metax.png';
import mooreLogo from '@/assets/logo/moore-logo.png';
import nvidiaLogo from '@/assets/logo/nvidia.png';
import pytorchBlackLogo from '@/assets/logo/pytorch_black.png';
import sgLangLogo from '@/assets/logo/sglang.png';
import theadLogoEN from '@/assets/logo/t-head-en.png';
import theadLogoZH from '@/assets/logo/t-head-zh.png';
import { manfacturerValueMap } from '@/pages/resources/config/gpu-driver';
import tensorflowkLogo from '@/assets/logo/tensorflow.svg';
import ubuntuLogo from '@/assets/logo/ubuntu.png';
import vllmLogo from '@/assets/logo/vllm.png';
import {
GPUsConfigs,
manfacturerValueMap
} from '@/pages/resources/config/gpu-driver';
import {
AutoTooltip,
DropdownActions,
IconFont,
TemplateCard
TemplateCard,
ThemeTag
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Button, Tag } from 'antd';
@@ -20,6 +30,56 @@ import styled from 'styled-components';
import { templateActions } from '../config';
import { ListItem } from '../config/types';
const imageLogoMap = {
vllm: vllmLogo,
sglang: sgLangLogo,
cann: ascendLogo,
jupyter: jupyterLogo,
pytorch: pytorchBlackLogo,
tensorflow: tensorflowkLogo,
ubuntu: ubuntuLogo
} as const;
export const manufactureColorMap: Record<string, string> = {
nvidia: 'green',
amd: 'volcano',
ascend: 'orange',
hygon: 'magenta',
moorthreads: 'cyan',
iluvatar: 'purple',
metax: 'geekblue',
cambricon: 'gold',
thead: 'red',
cpu: 'blue'
};
const manufacturerLabelMap: Record<string, string> = Object.values(
GPUsConfigs
).reduce(
(acc, item) => {
if (item.gpuVendor) acc[item.gpuVendor] = item.label;
return acc;
},
{ cpu: 'CPU' } as Record<string, string>
);
const matchImageLogo = (image: string | undefined): string | null => {
if (!image) return null;
const lower = image.toLowerCase();
let matched: keyof typeof imageLogoMap | null = null;
let earliest = Infinity;
(Object.keys(imageLogoMap) as Array<keyof typeof imageLogoMap>).forEach(
(key) => {
const idx = lower.indexOf(key);
if (idx !== -1 && idx < earliest) {
earliest = idx;
matched = key;
}
}
);
return matched ? imageLogoMap[matched] : null;
};
const StyledCard = styled(TemplateCard)`
&:hover {
.operations {
@@ -38,6 +98,8 @@ const Header = styled.div`
.title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
`;
@@ -73,7 +135,7 @@ const InfoItem = styled.div`
color: var(--ant-color-text-quaternary);
}
.value {
color: var(--ant-color-text-secondary);
color: var(--ant-color-text-tertiary);
}
`;
@@ -98,6 +160,10 @@ const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
};
const renderLogo = () => {
const imageLogo = matchImageLogo(data.spec?.image);
if (imageLogo) {
return <LogoImg src={imageLogo} height={22} />;
}
switch (data.manufacturer) {
case manfacturerValueMap.NVIDIA:
return <LogoImg src={nvidiaLogo} height={18} />;
@@ -141,6 +207,19 @@ const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
}
};
const renderManufacturerTag = () => {
if (!data.manufacturer) return null;
const label =
manufacturerLabelMap[data.manufacturer] ??
data.manufacturer.toUpperCase();
const color = manufactureColorMap[data.manufacturer] ?? 'purple';
return (
<ThemeTag color={color} style={{ fontWeight: 400 }}>
{label}
</ThemeTag>
);
};
const renderActions = () => {
return (
<span onClick={handleonClickAction} className="operations">
@@ -177,8 +256,9 @@ const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
<Content>
<CardName>
<AutoTooltip ghost minWidth={20}>
{data.name || '-'}
{data.displayName || data.name || '-'}
</AutoTooltip>
{renderManufacturerTag()}
</CardName>
<InfoItem>
<span>
@@ -1,6 +1,7 @@
export interface PortItem {
protocol: 'UDP' | 'TCP';
port: number;
name?: string;
}
export type ImagePullPolicy = 'Always' | 'IfNotPresent' | 'Never';
@@ -13,6 +14,7 @@ export interface EnvItem {
export interface FormData {
name: string;
description: string;
displayName?: string;
manufacturer: string;
spec: {
image: string;
@@ -76,6 +76,25 @@ const Basic: React.FC<BasicProps> = ({ page = 'template', onceMaxRequest }) => {
required
/>
</Form.Item>
<Form.Item<FormData>
name="displayName"
rules={[
{
max: 63,
message: intl.formatMessage({
id: 'gpuservice.template.displayName.max'
})
}
]}
>
<CInput.Input
label={intl.formatMessage({
id: 'gpuservice.template.displayName'
})}
showCount
maxLength={63}
/>
</Form.Item>
<Form.Item<FormData>
name="manufacturer"
rules={[
@@ -36,6 +36,7 @@ const GPUServiceTemplateForm: React.FC<TemplateFormProps> = forwardRef(
const handleFinish = async (values: FormData) => {
await onFinish({
...values,
displayName: values.displayName?.trim() || values.name,
spec: {
...values.spec,
command: values.spec?.command?.filter(Boolean) ?? []
@@ -65,7 +66,8 @@ const GPUServiceTemplateForm: React.FC<TemplateFormProps> = forwardRef(
ports: [
{
protocol: 'TCP',
port: 22
port: 22,
name: 'SSH'
}
],
volumeMount: '/workspace',
+134 -5
View File
@@ -1,14 +1,18 @@
import {
Input as CInput,
InputNumber as CInputNumber,
MetadataList,
Select as SealSelect
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Form } from 'antd';
import { useEffect, useRef, useState } from 'react';
import { FormData, PortItem as PortItemType } from '../config/types';
type PortProtocol = PortItemType['protocol'];
const PORT_NAME_MAX = 16;
const protocolOptions = [
{
label: 'UDP',
@@ -20,43 +24,154 @@ const protocolOptions = [
}
];
const wellKnownTcpPortNames: Record<number, string> = {
22: 'SSH',
80: 'HTTP',
443: 'HTTPS'
};
const getAutoFilledName = (
protocol: PortProtocol | undefined,
port: number | undefined,
previousProtocol: PortProtocol | undefined,
previousPort: number | undefined,
currentName: string | undefined
): string | undefined => {
if (protocol !== 'TCP' || !port) {
return currentName;
}
const suggested = wellKnownTcpPortNames[port];
if (!suggested) {
return currentName;
}
const previousSuggested =
previousProtocol === 'TCP' && previousPort
? wellKnownTcpPortNames[previousPort]
: undefined;
// Only overwrite when the name is empty or matches a previously suggested
// well-known name — never stomp on a user-entered value.
if (!currentName || currentName === previousSuggested) {
return suggested;
}
return currentName;
};
interface PortItemProps {
item: PortItemType;
index: number;
onChange: (item: PortItemType) => void;
}
interface PortNameInputProps {
value: string | undefined;
onChange: (value: string) => void;
placeholder: string;
}
const PortNameInput: React.FC<PortNameInputProps> = ({
value,
onChange,
placeholder
}) => {
const [localValue, setLocalValue] = useState<string>(value ?? '');
const isComposingRef = useRef(false);
useEffect(() => {
if (!isComposingRef.current) {
setLocalValue(value ?? '');
}
}, [value]);
return (
<CInput.Input
showCount
maxLength={PORT_NAME_MAX}
value={localValue}
placeholder={placeholder}
onCompositionStart={() => {
isComposingRef.current = true;
}}
onCompositionEnd={(e) => {
isComposingRef.current = false;
const next = (e.target as HTMLInputElement).value;
setLocalValue(next);
onChange(next);
}}
onChange={(e) => {
const next = e.target.value;
setLocalValue(next);
if (!isComposingRef.current) {
onChange(next);
}
}}
style={{ width: '100%' }}
/>
);
};
const PortItem: React.FC<PortItemProps> = ({ item, index, onChange }) => {
const intl = useIntl();
return (
<div style={{ display: 'flex', gap: 12, width: '100%' }}>
<div style={{ flex: 1 }}>
<div style={{ width: 120 }}>
<SealSelect
value={item.protocol}
options={protocolOptions}
onChange={(value) => {
const nextProtocol = value as PortProtocol;
onChange({
...item,
protocol: value as PortProtocol
protocol: nextProtocol,
name: getAutoFilledName(
nextProtocol,
item.port,
item.protocol,
item.port,
item.name
)
});
}}
style={{ width: '100%' }}
></SealSelect>
</div>
<div style={{ flex: 1 }}>
<div style={{ width: 120 }}>
<CInputNumber
min={1}
max={65535}
precision={0}
value={item.port}
onChange={(value) => {
const nextPort =
typeof value === 'number' ? value : (undefined as any);
onChange({
...item,
port: typeof value === 'number' ? value : (undefined as any)
port: nextPort,
name: getAutoFilledName(
item.protocol,
nextPort,
item.protocol,
item.port,
item.name
)
});
}}
style={{ width: '100%' }}
/>
</div>
<div style={{ flex: 1 }}>
<PortNameInput
value={item.name}
placeholder={intl.formatMessage({
id: 'gpuservice.template.ports.name'
})}
onChange={(next) => {
onChange({
...item,
name: next
});
}}
/>
</div>
</div>
);
};
@@ -75,7 +190,8 @@ const Ports: React.FC = () => {
...ports,
{
protocol: 'TCP',
port: undefined as any
port: undefined as any,
name: ''
}
]);
};
@@ -113,6 +229,19 @@ const Ports: React.FC = () => {
)
);
}
const hasInvalidName = value.some(
(item: PortItemType) =>
item.name && item.name.length > PORT_NAME_MAX
);
if (hasInvalidName) {
return Promise.reject(
new Error(
intl.formatMessage({
id: 'gpuservice.template.ports.name.max'
})
)
);
}
return Promise.resolve();
}
}