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}