feat(models): resource-aware empty state on My Models; tag GPU cluster type experimental
My Models empty state now guides managers to create a cluster/worker or deploy a model (queries gated to canSeeAdmin/canSeeOrgAdmin); plain users keep the consumer copy. Add an Experimental badge to the GPU-instance cluster type card.
This commit is contained in:
@@ -96,6 +96,7 @@ const ClusterTypeGrid = styled.div`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const ClusterTypeCard = styled.div<{ $active: boolean }>`
|
const ClusterTypeCard = styled.div<{ $active: boolean }>`
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 10px;
|
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 }>`
|
const RadioDot = styled.span<{ $active: boolean }>`
|
||||||
position: relative;
|
position: relative;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -187,6 +199,7 @@ export const ClusterTypeSelector: React.FC = () => {
|
|||||||
key: 'model' | 'gpu';
|
key: 'model' | 'gpu';
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
experimental?: boolean;
|
||||||
}[] = [
|
}[] = [
|
||||||
{
|
{
|
||||||
key: 'model',
|
key: 'model',
|
||||||
@@ -196,7 +209,8 @@ export const ClusterTypeSelector: React.FC = () => {
|
|||||||
{
|
{
|
||||||
key: 'gpu',
|
key: 'gpu',
|
||||||
title: intl.formatMessage({ id: 'clusters.gpuInstances.title' }),
|
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 = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RadioDot $active={active} />
|
<RadioDot $active={active} />
|
||||||
|
{opt.experimental && (
|
||||||
|
<ExperimentalTag>
|
||||||
|
{intl.formatMessage({ id: 'common.tag.experimental' })}
|
||||||
|
</ExperimentalTag>
|
||||||
|
)}
|
||||||
<div className="body">
|
<div className="body">
|
||||||
<div className="title">{opt.title}</div>
|
<div className="title">{opt.title}</div>
|
||||||
<div className="description">{opt.description}</div>
|
<div className="description">{opt.description}</div>
|
||||||
|
|||||||
@@ -2,21 +2,21 @@ import useTableFetch from '@/hooks/use-table-fetch';
|
|||||||
import { SyncOutlined } from '@ant-design/icons';
|
import { SyncOutlined } from '@ant-design/icons';
|
||||||
import {
|
import {
|
||||||
BaseSelect,
|
BaseSelect,
|
||||||
IconFont,
|
|
||||||
InfiniteScrollerProvider,
|
InfiniteScrollerProvider,
|
||||||
NoResult,
|
|
||||||
PageTools,
|
PageTools,
|
||||||
TemplateCardList
|
TemplateCardList
|
||||||
} from '@gpustack/core-ui';
|
} from '@gpustack/core-ui';
|
||||||
import { useIntl } from '@umijs/max';
|
import { useAccess, useIntl, useNavigate } from '@umijs/max';
|
||||||
import useMemoizedFn from 'ahooks/lib/useMemoizedFn';
|
import useMemoizedFn from 'ahooks/lib/useMemoizedFn';
|
||||||
import { Button, Input, Space } from 'antd';
|
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 PageBox from '../_components/page-box';
|
||||||
import { MY_MODELS_API, queryMyModels } from './apis';
|
import { MY_MODELS_API, queryMyModels } from './apis';
|
||||||
import APIAccessInfoModal from './components/api-access-info';
|
import APIAccessInfoModal from './components/api-access-info';
|
||||||
import ModelItem from './components/model-item';
|
import ModelItem from './components/model-item';
|
||||||
import { categoryOptions, MyModelsStatusValueMap } from './config';
|
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';
|
import useViewApIInfo from './hooks/use-view-api-info';
|
||||||
const Dot = ({ color }: { color: string }) => {
|
const Dot = ({ color }: { color: string }) => {
|
||||||
return (
|
return (
|
||||||
@@ -60,8 +60,25 @@ const UserModels: React.FC = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
const access = useAccess();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { apiAccessInfo, openViewAPIInfo, closeViewAPIInfo } = useViewApIInfo();
|
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(() => {
|
const statusOptions = useMemo(() => {
|
||||||
return [
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageBox>
|
<PageBox>
|
||||||
@@ -226,18 +273,7 @@ const UserModels: React.FC = () => {
|
|||||||
isFirst={!dataSource.loadend}
|
isFirst={!dataSource.loadend}
|
||||||
renderItem={renderCard}
|
renderItem={renderCard}
|
||||||
></TemplateCardList>
|
></TemplateCardList>
|
||||||
<NoResult
|
{noResourceResult}
|
||||||
loading={dataSource.loading}
|
|
||||||
loadend={dataSource.loadend}
|
|
||||||
dataSource={dataList}
|
|
||||||
image={<IconFont type="icon-models" />}
|
|
||||||
filters={{ ...queryParams }}
|
|
||||||
noFoundText={intl.formatMessage({
|
|
||||||
id: 'noresult.mymodels.nofound'
|
|
||||||
})}
|
|
||||||
title={intl.formatMessage({ id: 'noresult.mymodels.title' })}
|
|
||||||
subTitle={intl.formatMessage({ id: 'noresult.mymodels.subTitle' })}
|
|
||||||
></NoResult>
|
|
||||||
</InfiniteScrollerProvider>
|
</InfiniteScrollerProvider>
|
||||||
</PageBox>
|
</PageBox>
|
||||||
<APIAccessInfoModal
|
<APIAccessInfoModal
|
||||||
|
|||||||
Reference in New Issue
Block a user