diff --git a/react/src/components/AliasedImageDoubleTags.tsx b/react/src/components/AliasedImageDoubleTags.tsx index f1190a4624..841d31a399 100644 --- a/react/src/components/AliasedImageDoubleTags.tsx +++ b/react/src/components/AliasedImageDoubleTags.tsx @@ -1,8 +1,10 @@ +import { preserveDotStartCase } from '../helper'; import { useBackendAIImageMetaData } from '../hooks'; import DoubleTag, { DoubleTagObjectValue } from './DoubleTag'; import Flex from './Flex'; import TextHighlighter from './TextHighlighter'; import { AliasedImageDoubleTagsFragment$key } from './__generated__/AliasedImageDoubleTagsFragment.graphql'; +import { Tag } from 'antd'; import graphql from 'babel-plugin-relay/macro'; import _ from 'lodash'; import React from 'react'; @@ -45,7 +47,11 @@ const AliasedImageDoubleTags: React.FC = ({ key: 'ai.backend.customized-image.name', })?.value : tag.value; - return ( + const aliasedTag = tagAlias(tag.key + tagValue); + return _.isEqual( + aliasedTag, + preserveDotStartCase(tag.key + tagValue), + ) ? ( = ({ ]} {...doubleTagProps} /> + ) : ( + + {aliasedTag} + ); })} diff --git a/react/src/components/CustomizedImageList.tsx b/react/src/components/CustomizedImageList.tsx index 350545b295..fe6e3360af 100644 --- a/react/src/components/CustomizedImageList.tsx +++ b/react/src/components/CustomizedImageList.tsx @@ -1,9 +1,4 @@ import Flex from '../components/Flex'; -import { - BaseImageTags, - ConstraintTags, - LangTags, -} from '../components/ImageTags'; import TableColumnsSettingModal from '../components/TableColumnsSettingModal'; import { filterEmptyItem, @@ -17,6 +12,7 @@ import { useUpdatableState, } from '../hooks'; import AliasedImageDoubleTags from './AliasedImageDoubleTags'; +import { ImageTags } from './ImageTags'; import TextHighlighter from './TextHighlighter'; import { CustomizedImageListForgetAndUntagMutation } from './__generated__/CustomizedImageListForgetAndUntagMutation.graphql'; import { @@ -63,19 +59,8 @@ const CustomizedImageList: React.FC = ({ children }) => { const [inFlightImageId, setInFlightImageId] = useState(); const [imageSearch, setImageSearch] = useState(''); const [isPendingSearchTransition, startSearchTransition] = useTransition(); - const [ - , - { - getNamespace, - getImageLang, - getBaseVersion, - getBaseImage, - getConstraints, - tagAlias, - getLang, - getBaseImages, - }, - ] = useBackendAIImageMetaData(); + const [, { getBaseVersion, getBaseImages, getBaseImage, tagAlias, getTags }] = + useBackendAIImageMetaData(); const { customized_images } = useLazyLoadQuery( graphql` @@ -142,17 +127,14 @@ const CustomizedImageList: React.FC = ({ children }) => { fullName: getImageFullName(image) || '', digest: image?.digest || '', // ------------ need only before 24.12.0 ------------ - lang: image?.name ? getLang(image.name) : '', baseversion: getBaseVersion(getImageFullName(image) || ''), baseimage: image?.tag && image?.name ? getBaseImages(image.tag, image.name) : [], - constraints: - image?.tag && image?.labels - ? getConstraints( - image.tag, - image.labels as { key: string; value: string }[], - ) - : [], + tag: + getTags( + image?.tag || '', + image?.labels as Array<{ key: string; value: string }>, + ) || [], isCustomized: image?.tag ? image.tag.indexOf('customized') !== -1 : false, @@ -180,14 +162,13 @@ const CustomizedImageList: React.FC = ({ children }) => { const baseImagesMatch = _.some(curFilterValues.baseimage, (value) => regExp.test(value), ); - const constraintsMatch = _.some( - curFilterValues.constraints, - (constraint) => regExp.test(constraint), + const tagMatch = _.some( + curFilterValues.tag, + (tag) => regExp.test(tag.key) || regExp.test(tag.value), ); const customizedMatch = curFilterValues.isCustomized ? regExp.test('customized') : false; - const langMatch = regExp.test(curFilterValues.lang); const namespaceMatch = regExp.test(curFilterValues.namespace || ''); const fullNameMatch = regExp.test(curFilterValues.fullName); const tagsMatch = _.some( @@ -200,8 +181,7 @@ const CustomizedImageList: React.FC = ({ children }) => { return ( baseVersionMatch || baseImagesMatch || - constraintsMatch || - langMatch || + tagMatch || namespaceMatch || customizedMatch || fullNameMatch || @@ -281,7 +261,7 @@ const CustomizedImageList: React.FC = ({ children }) => { title: t('environment.Tags'), key: 'tags', dataIndex: 'tags', - render: (text, row) => ( + render: (text: Array<{ key: string; value: string }>, row) => ( = ({ children }) => { title: t('environment.Namespace'), key: 'name', dataIndex: 'name', - sorter: (a, b) => { - const namespaceA = getNamespace(getImageFullName(a) || ''); - const namespaceB = getNamespace(getImageFullName(b) || ''); - return localeCompare(namespaceA, namespaceB); - }, - render: (text, row) => ( - {getNamespace(getImageFullName(row) || '')} - ), - }, - !supportExtendedImageInfo && { - title: t('environment.Language'), - key: 'lang', - sorter: (a, b) => - localeCompare( - getImageLang(getImageFullName(a) || ''), - getImageLang(getImageFullName(b) || ''), - ), - render: (text, row) => ( - + sorter: (a, b) => localeCompare(getImageFullName(a), getImageFullName(b)), + render: (text) => ( + {text} ), }, !supportExtendedImageInfo && { @@ -340,38 +304,23 @@ const CustomizedImageList: React.FC = ({ children }) => { getBaseImage(getImageFullName(b) || ''), ), render: (text, row) => ( - + + {tagAlias(getBaseImage(getImageFullName(row) || ''))} + ), }, !supportExtendedImageInfo && { - title: t('environment.Constraint'), - key: 'constraint', - dataIndex: 'constraint', - sorter: (a, b) => { - const requirementA = - a?.tag && b?.labels - ? getConstraints( - a?.tag, - a?.labels as { key: string; value: string }[], - )[0] || '' - : ''; - const requirementB = - b?.tag && b?.labels - ? getConstraints( - b?.tag, - b?.labels as { key: string; value: string }[], - )[0] || '' - : ''; - return localeCompare(requirementA, requirementB); - }, - render: (text, row) => - row?.tag ? ( - } - highlightKeyword={imageSearch} - /> - ) : null, + title: t('environment.Tags'), + key: 'tag', + dataIndex: 'tag', + sorter: (a, b) => localeCompare(a?.tag, b?.tag), + render: (text, row) => ( + } + highlightKeyword={imageSearch} + /> + ), }, { title: t('environment.Digest'), diff --git a/react/src/components/ImageEnvironmentSelectFormItems.tsx b/react/src/components/ImageEnvironmentSelectFormItems.tsx index 839076872c..91e5b320a8 100644 --- a/react/src/components/ImageEnvironmentSelectFormItems.tsx +++ b/react/src/components/ImageEnvironmentSelectFormItems.tsx @@ -1,4 +1,5 @@ import { getImageFullName, localeCompare } from '../helper'; +import { preserveDotStartCase } from '../helper'; import { useBackendAIImageMetaData, useSuspendedBackendaiClient, @@ -745,7 +746,13 @@ const ImageEnvironmentSelectFormItems: React.FC< key: 'ai.backend.customized-image.name', })?.value : tag.value; - return ( + const aliasedTag = tagAlias( + tag.key + tagValue, + ); + return _.isEqual( + aliasedTag, + preserveDotStartCase(tag.key + tagValue), + ) ? ( + ) : ( + + {aliasedTag} + ); }, )} diff --git a/react/src/components/ImageList.tsx b/react/src/components/ImageList.tsx index aaf528dd3f..3fe4665836 100644 --- a/react/src/components/ImageList.tsx +++ b/react/src/components/ImageList.tsx @@ -4,6 +4,7 @@ import { filterNonNullItems, getImageFullName, localeCompare, + preserveDotStartCase, } from '../helper'; import { useBackendAIImageMetaData, @@ -12,7 +13,7 @@ import { } from '../hooks'; import DoubleTag from './DoubleTag'; import ImageInstallModal from './ImageInstallModal'; -import { BaseImageTags, ConstraintTags, LangTags } from './ImageTags'; +import { ImageTags } from './ImageTags'; import ManageAppsModal from './ManageAppsModal'; import ManageImageResourceLimitModal from './ManageImageResourceLimitModal'; import ResourceNumber from './ResourceNumber'; @@ -45,18 +46,8 @@ export type EnvironmentImage = NonNullable< const ImageList: React.FC<{ style?: React.CSSProperties }> = ({ style }) => { const { t } = useTranslation(); const [selectedRows, setSelectedRows] = useState([]); - const [ - , - { - getNamespace, - getBaseVersion, - getLang, - getBaseImages, - getConstraints, - getBaseImage, - tagAlias, - }, - ] = useBackendAIImageMetaData(); + const [, { getBaseVersion, getBaseImages, getBaseImage, tagAlias, getTags }] = + useBackendAIImageMetaData(); const { token } = theme.useToken(); const [managingApp, setManagingApp] = useState(null); const [managingResourceLimit, setManagingResourceLimit] = @@ -194,7 +185,7 @@ const ImageList: React.FC<{ style?: React.CSSProperties }> = ({ style }) => { key: 'base_image_name', dataIndex: 'base_image_name', sorter: (a, b) => localeCompare(a?.base_image_name, b?.base_image_name), - render: (text, row) => ( + render: (text) => ( {tagAlias(text)} @@ -213,7 +204,10 @@ const ImageList: React.FC<{ style?: React.CSSProperties }> = ({ style }) => { title: t('environment.Tags'), key: 'tags', dataIndex: 'tags', - render: (text: Array<{ key: string; value: string }>, row) => { + render: ( + text: Array<{ key: string; value: string }>, + row: EnvironmentImage, + ) => { return ( {/* TODO: replace this with AliasedImageDoubleTags after image list query with ImageNode is implemented. */} @@ -224,7 +218,11 @@ const ImageList: React.FC<{ style?: React.CSSProperties }> = ({ style }) => { key: 'ai.backend.customized-image.name', })?.value : tag.value; - return ( + const aliasedTag = tagAlias(tag.key + tagValue); + return _.isEqual( + aliasedTag, + preserveDotStartCase(tag.key + tagValue), + ) ? ( = ({ style }) => { }, ]} /> + ) : ( + + {aliasedTag} + ); })} @@ -257,20 +259,23 @@ const ImageList: React.FC<{ style?: React.CSSProperties }> = ({ style }) => { key: 'name', dataIndex: 'name', sorter: (a, b) => localeCompare(getImageFullName(a), getImageFullName(b)), - render: (text, row) => ( - - {getNamespace(getImageFullName(row) || '')} - + render: (text) => ( + {text} ), }, !supportExtendedImageInfo && { - title: t('environment.Language'), - key: 'lang', - dataIndex: 'lang', + title: t('environment.Base'), + key: 'baseimage', + dataIndex: 'baseimage', sorter: (a, b) => - localeCompare(getLang(a.name ?? ''), getLang(b.name ?? '')), + localeCompare( + getBaseImage(getImageFullName(a) || ''), + getBaseImage(getImageFullName(b) || ''), + ), render: (text, row) => ( - + + {tagAlias(getBaseImage(getImageFullName(row) || ''))} + ), }, !supportExtendedImageInfo && { @@ -289,48 +294,18 @@ const ImageList: React.FC<{ style?: React.CSSProperties }> = ({ style }) => { ), }, !supportExtendedImageInfo && { - title: t('environment.Base'), - key: 'baseimage', - dataIndex: 'baseimage', - sorter: (a, b) => - localeCompare( - getBaseImage(getImageFullName(a) || ''), - getBaseImage(getImageFullName(b) || ''), - ), + title: t('environment.Tags'), + key: 'tag', + dataIndex: 'tag', + sorter: (a, b) => localeCompare(a?.tag, b?.tag), render: (text, row) => ( - + } + highlightKeyword={imageSearch} + /> ), }, - !supportExtendedImageInfo && { - title: t('environment.Constraint'), - key: 'constraint', - dataIndex: 'constraint', - sorter: (a, b) => { - const requirementA = - a?.tag && b?.labels - ? getConstraints( - a?.tag, - a?.labels as { key: string; value: string }[], - )[0] || '' - : ''; - const requirementB = - b?.tag && b?.labels - ? getConstraints( - b?.tag, - b?.labels as { key: string; value: string }[], - )[0] || '' - : ''; - return localeCompare(requirementA, requirementB); - }, - render: (text, row) => - row?.tag ? ( - } - highlightKeyword={imageSearch} - /> - ) : null, - }, { title: t('environment.Digest'), dataIndex: 'digest', @@ -417,17 +392,14 @@ const ImageList: React.FC<{ style?: React.CSSProperties }> = ({ style }) => { fullName: getImageFullName(image) || '', digest: image?.digest || '', // ------------ need only before 24.12.0 ------------ - lang: image?.name ? getLang(image.name) : '', baseversion: getBaseVersion(getImageFullName(image) || ''), baseimage: image?.tag && image?.name ? getBaseImages(image.tag, image.name) : [], - constraints: - image?.tag && image?.labels - ? getConstraints( - image.tag, - image.labels as { key: string; value: string }[], - ) - : [], + tag: + getTags( + image?.tag || '', + image?.labels as Array<{ key: string; value: string }>, + ) || [], isCustomized: image?.tag ? image.tag.indexOf('customized') !== -1 : false, @@ -455,14 +427,13 @@ const ImageList: React.FC<{ style?: React.CSSProperties }> = ({ style }) => { const baseImagesMatch = _.some(curFilterValues.baseimage, (value) => regExp.test(value), ); - const constraintsMatch = _.some( - curFilterValues.constraints, - (constraint) => regExp.test(constraint), + const tagMatch = _.some( + curFilterValues.tag, + (tag) => regExp.test(tag.key) || regExp.test(tag.value), ); const customizedMatch = curFilterValues.isCustomized ? regExp.test('customized') : false; - const langMatch = regExp.test(curFilterValues.lang); const namespaceMatch = regExp.test(curFilterValues.namespace || ''); const fullNameMatch = regExp.test(curFilterValues.fullName); const tagsMatch = _.some( @@ -475,8 +446,7 @@ const ImageList: React.FC<{ style?: React.CSSProperties }> = ({ style }) => { return ( baseVersionMatch || baseImagesMatch || - constraintsMatch || - langMatch || + tagMatch || namespaceMatch || customizedMatch || fullNameMatch || diff --git a/react/src/components/ImageTags.tsx b/react/src/components/ImageTags.tsx index 91d0d47e74..7482f13956 100644 --- a/react/src/components/ImageTags.tsx +++ b/react/src/components/ImageTags.tsx @@ -1,3 +1,4 @@ +import { preserveDotStartCase } from '../helper'; import { useBackendAIImageMetaData } from '../hooks'; import DoubleTag, { DoubleTagObjectValue } from './DoubleTag'; import Flex from './Flex'; @@ -76,7 +77,7 @@ export const ArchitectureTags: React.FC = ({ const [, { getArchitecture, tagAlias }] = useBackendAIImageMetaData(); return _.isEmpty(tagAlias(getArchitecture(image))) ? null : ( - {tagAlias(getArchitecture(image))} + {getArchitecture(image)} ); }; @@ -161,3 +162,57 @@ const SessionKernelTags: React.FC<{ }; export default React.memo(SessionKernelTags); + +interface ImageTagsProps extends TagProps { + tag: string; + labels: Array<{ key: string; value: string }>; + highlightKeyword?: string; +} +export const ImageTags: React.FC = ({ + tag, + labels, + highlightKeyword, + ...props +}) => { + labels = labels || []; + const [, { getTags, tagAlias }] = useBackendAIImageMetaData(); + const tags = getTags(tag, labels); + return ( + + {_.map(tags, (tag: { key: string; value: string }, index) => { + const isCustomized = tag.key === 'Customized'; + const aliasedTag = tagAlias(tag.key + tag.value); + return _.isEqual( + aliasedTag, + preserveDotStartCase(tag.key + tag.value), + ) ? ( + + {tagAlias(tag.key)} + + ), + color: isCustomized ? 'cyan' : 'blue', + }, + { + label: ( + + {tag.value} + + ), + color: isCustomized ? 'cyan' : 'blue', + }, + ]} + /> + ) : ( + + {aliasedTag} + + ); + })} + + ); +}; diff --git a/react/src/helper/index.tsx b/react/src/helper/index.tsx index bc2c14998f..df44c0dddf 100644 --- a/react/src/helper/index.tsx +++ b/react/src/helper/index.tsx @@ -396,3 +396,43 @@ export function formatToUUID(str: string) { export const toGlobalId = (type: string, id: string): string => { return btoa(`${type}:${id}`); }; + +/** + * Custom startCase function that preserves specified separators. + * @param {string} string - The input string to transform. + * @param {string|RegExp} [separator='.'] - The separator(s) to preserve. + * @returns {string} The transformed string. + */ +export const maintainSeparatorsAndStartCase = ( + string: string, + separator = '.', +) => { + // Escape special characters for use in RegExp + const escapedSeparator = _.isRegExp(separator) + ? separator.source + : _.escapeRegExp(separator); + + return _(string) + .split(new RegExp(`(${escapedSeparator})`, 'g')) // Split the string by the separator, including the separator in the result + .map((part: string) => { + // If the part is a separator, return it as is + if (new RegExp(`^${escapedSeparator}$`).test(part)) { + return part; + } + // Otherwise, apply startCase + return _.startCase(_.toLower(part)); // Ensure consistent casing before applying startCase + }) + .join('') // Join the processed parts back together + .valueOf(); // Terminate the chain and retrieve the wrapped value +}; + +export function preserveDotStartCase(str: string) { + // Temporarily replace periods with a unique placeholder + const placeholder = '<<>>'; + const tempStr = str.replace(/\./g, placeholder); + + const startCased = _.startCase(tempStr); + + // Replace the placeholder back with periods + return startCased.replace(new RegExp(placeholder, 'g'), '.'); +} diff --git a/react/src/hooks/index.tsx b/react/src/hooks/index.tsx index a934fd2e7d..7e700d8346 100644 --- a/react/src/hooks/index.tsx +++ b/react/src/hooks/index.tsx @@ -1,3 +1,4 @@ +import { preserveDotStartCase } from '../helper'; import { useSuspenseTanQuery } from './reactQueryAlias'; import { useEventNotStable } from './useEventNotStable'; import _ from 'lodash'; @@ -242,8 +243,8 @@ export const useBackendAIImageMetaData = () => { } return metadata?.tagAlias[lang] || lang; }, - getImageTags: (imageName: string) => { - // const { key, tags } = getImageMeta(imageName); + getImageTagStr: (imageName: string) => { + return _.last(_.split(_.split(imageName, '@')[0], ':')); }, getFilteredRequirementsTags: (imageName: string) => { const { tags } = getImageMeta(imageName); @@ -261,12 +262,16 @@ export const useBackendAIImageMetaData = () => { return customizedNameLabel; }, getBaseVersion: (imageName: string) => { - const { tags } = getImageMeta(imageName); - return tags[0]; + return ( + _.first(_.split(_.last(_.split(imageName, ':')), /[^a-zA-Z\d.]+/)) || + '' + ); }, getBaseImage: (imageName: string) => { - const { tags } = getImageMeta(imageName); - return tags[1]; + const splitByColon = _.split(imageName, ':'); + const beforeLastColon = _.join(_.initial(splitByColon), ':'); + const lastItemAfterSplitBySlash = _.last(_.split(beforeLastColon, '/')); + return lastItemAfterSplitBySlash || ''; }, getBaseImages: (tag: string, name: string) => { const tags = tag.split('-'); @@ -292,6 +297,41 @@ export const useBackendAIImageMetaData = () => { return baseImageArr; }, getImageMeta, + getTags: (tag: string, labels: Array<{ key: string; value: string }>) => { + // Remove the 'customized_' prefix and its following string from the tag + const cleanedTag = _.replace(tag, /customized_[a-zA-Z\d.]+/, ''); + // Split the remaining tag into segments based on alphanumeric and '.' characters, ignoring the first segment + const tags = _.tail(_.split(cleanedTag, /[^a-zA-Z\d.]+/)); + const result: Array<{ key: string; value: string }> = []; + + // Process not 'customized_' tags + _.forEach(tags, (currentTag) => { + // Separate the alphabetic prefix from the numeric and '.' suffix for each tag + const match = /^([a-zA-Z]+)(.*)$/.exec(currentTag); + if (match) { + const [, key, value] = match; + // Ensure the value is an empty string if it's undefined + result.push({ key, value: value || '' }); + } + }); + + // Handle the 'customized_' tag separately by finding the custom image name in labels + const customizedNameLabel = _.get( + _.find(labels, { key: 'ai.backend.customized-image.name' }), + 'value', + '', + ); + // If a custom image name exists, add it to the result with the key 'Customized' + if (customizedNameLabel) { + result.push({ key: 'Customized', value: customizedNameLabel }); + } + + // Remove duplicates and entries with an empty 'key' + return _.uniqWith( + _.filter(result, ({ key }) => !_.isEmpty(key)), + _.isEqual, + ); + }, getConstraints: ( tag: string, labels: { key: string; value: string }[], @@ -328,7 +368,7 @@ export const useBackendAIImageMetaData = () => { } }) .value() ?? - _.startCase(tag) + preserveDotStartCase(tag) ); }, },