diff --git a/src/pages/cluster-management/components/k8s-pod-spec.tsx b/src/pages/cluster-management/components/k8s-pod-spec.tsx index 50d9aa5b..17a9725c 100644 --- a/src/pages/cluster-management/components/k8s-pod-spec.tsx +++ b/src/pages/cluster-management/components/k8s-pod-spec.tsx @@ -96,6 +96,7 @@ const ClusterTypeGrid = styled.div` `; const ClusterTypeCard = styled.div<{ $active: boolean }>` + position: relative; display: flex; align-items: flex-start; gap: 10px; @@ -135,6 +136,17 @@ const ClusterTypeCard = styled.div<{ $active: boolean }>` } `; +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; @@ -187,6 +199,7 @@ export const ClusterTypeSelector: React.FC = () => { key: 'model' | 'gpu'; title: string; description: string; + experimental?: boolean; }[] = [ { key: 'model', @@ -196,7 +209,8 @@ export const ClusterTypeSelector: React.FC = () => { { key: 'gpu', title: intl.formatMessage({ id: 'clusters.gpuInstances.title' }), - description: intl.formatMessage({ id: 'clusters.gpuInstances.tip' }) + description: intl.formatMessage({ id: 'clusters.gpuInstances.tip' }), + experimental: true } ]; @@ -231,6 +245,11 @@ export const ClusterTypeSelector: React.FC = () => { }} > + {opt.experimental && ( + + {intl.formatMessage({ id: 'common.tag.experimental' })} + + )}
{opt.title}
{opt.description}
diff --git a/src/pages/llmodels/user-models.tsx b/src/pages/llmodels/user-models.tsx index fbad3f08..cad53aa0 100644 --- a/src/pages/llmodels/user-models.tsx +++ b/src/pages/llmodels/user-models.tsx @@ -2,21 +2,21 @@ import useTableFetch from '@/hooks/use-table-fetch'; import { SyncOutlined } from '@ant-design/icons'; import { BaseSelect, - IconFont, InfiniteScrollerProvider, - NoResult, PageTools, TemplateCardList } from '@gpustack/core-ui'; -import { useIntl } from '@umijs/max'; +import { useAccess, useIntl, useNavigate } from '@umijs/max'; import useMemoizedFn from 'ahooks/lib/useMemoizedFn'; import { Button, Input, Space } from 'antd'; -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; import PageBox from '../_components/page-box'; import { MY_MODELS_API, queryMyModels } from './apis'; import APIAccessInfoModal from './components/api-access-info'; import ModelItem from './components/model-item'; import { categoryOptions, MyModelsStatusValueMap } from './config'; +import useFormInitialValues from './hooks/use-form-initial-values'; +import useNoResourceResult from './hooks/use-no-resource-result'; import useViewApIInfo from './hooks/use-view-api-info'; const Dot = ({ color }: { color: string }) => { return ( @@ -60,8 +60,25 @@ const UserModels: React.FC = () => { } }); const intl = useIntl(); + const access = useAccess(); + const navigate = useNavigate(); const { apiAccessInfo, openViewAPIInfo, closeViewAPIInfo } = useViewApIInfo(); + // Only managers (platform admin or org owner) can see / manage + // clusters and workers, so only they hit those endpoints. A plain + // user falls straight through to the default "no models" empty state + // without the infra-guidance queries firing. + const canManageResources = access?.canSeeAdmin || access?.canSeeOrgAdmin; + const { getClusterList, getWorkerList, clusterList, workerList } = + useFormInitialValues(); + + useEffect(() => { + if (canManageResources) { + getClusterList(); + getWorkerList(); + } + }, [canManageResources]); + const statusOptions = useMemo(() => { return [ { @@ -155,6 +172,36 @@ const UserModels: React.FC = () => { }); }; + const { noResourceResult } = useNoResourceResult({ + loading: dataSource.loading, + loadend: dataSource.loadend, + dataSource: dataList, + // Preserve the original filters heuristic: only treat the current + // query as an active filter when there is data across pages, so a + // truly empty account still shows the full empty state (CTA). + queryParams: dataSource.totalPage > 0 ? queryParams : {}, + iconType: 'icon-models', + title: intl.formatMessage({ id: 'noresult.mymodels.title' }), + noClusters: !!canManageResources && !clusterList.length, + noWorkers: + !!canManageResources && workerList.length === 0 && clusterList.length > 0, + defaultContent: { + // Infra is in place but no models yet: guide managers to deploy + // one, reusing the deployments-page copy so the two empty states + // read consistently. Plain users can't deploy (and skip the infra + // queries), so they keep the consumer-facing "ask an admin" copy + // and a button-less empty state. + subTitle: canManageResources + ? intl.formatMessage({ id: 'noresult.deployments.subTitle' }) + : intl.formatMessage({ id: 'noresult.mymodels.subTitle' }), + noFoundText: intl.formatMessage({ id: 'noresult.mymodels.nofound' }), + buttonText: canManageResources + ? intl.formatMessage({ id: 'models.table.button.deploy' }) + : '', + onClick: canManageResources ? () => navigate('/models/catalog') : () => {} + } + }); + return ( <> @@ -226,18 +273,7 @@ const UserModels: React.FC = () => { isFirst={!dataSource.loadend} renderItem={renderCard} > - } - filters={{ ...queryParams }} - noFoundText={intl.formatMessage({ - id: 'noresult.mymodels.nofound' - })} - title={intl.formatMessage({ id: 'noresult.mymodels.title' })} - subTitle={intl.formatMessage({ id: 'noresult.mymodels.subTitle' })} - > + {noResourceResult}