Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
207cef350f | ||
|
|
fa3fed8671 |
+21
-10
@@ -100,15 +100,6 @@ const baseRoutes = [
|
||||
path: '/models',
|
||||
redirect: '/models/deployments'
|
||||
},
|
||||
{
|
||||
name: 'userModels',
|
||||
path: '/models/user-models',
|
||||
key: 'userModels',
|
||||
icon: 'icon-models',
|
||||
selectedIcon: 'icon-models-filled',
|
||||
defaultIcon: 'icon-models',
|
||||
component: './llmodels/user-models'
|
||||
},
|
||||
{
|
||||
name: 'modelCatalog',
|
||||
path: '/models/catalog',
|
||||
@@ -119,6 +110,16 @@ const baseRoutes = [
|
||||
access: 'canSeeOrgAdmin',
|
||||
component: './llmodels/catalog'
|
||||
},
|
||||
{
|
||||
name: 'userModels',
|
||||
path: '/models/user-models',
|
||||
key: 'userModels',
|
||||
icon: 'icon-models',
|
||||
selectedIcon: 'icon-models-filled',
|
||||
defaultIcon: 'icon-models',
|
||||
access: 'canSeeUser',
|
||||
component: './llmodels/user-models'
|
||||
},
|
||||
{
|
||||
name: 'deployment',
|
||||
path: '/models/deployments',
|
||||
@@ -273,7 +274,7 @@ const baseRoutes = [
|
||||
selectedIcon: 'icon-cluster2-filled',
|
||||
defaultIcon: 'icon-cluster2-outline',
|
||||
component: './cluster-management/clusters',
|
||||
subMenu: ['/resources/clusters/create']
|
||||
subMenu: ['/resources/clusters/detail', '/resources/clusters/create']
|
||||
},
|
||||
{
|
||||
name: 'workers',
|
||||
@@ -301,6 +302,16 @@ const baseRoutes = [
|
||||
selectedIcon: 'icon-credential-filled',
|
||||
defaultIcon: 'icon-credential-outline',
|
||||
component: './cluster-management/credentials'
|
||||
},
|
||||
{
|
||||
name: 'clusterDetail',
|
||||
path: '/resources/clusters/detail',
|
||||
key: 'clusterDetail',
|
||||
icon: 'icon-cluster2-outline',
|
||||
selectedIcon: 'icon-cluster2-filled',
|
||||
defaultIcon: 'icon-cluster2-outline',
|
||||
hideInMenu: true,
|
||||
component: './cluster-management/cluster-detail'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
"@ant-design/pro-components": "3.1.0-0",
|
||||
"@antv/g6": "^5.0.51",
|
||||
"@braintree/sanitize-url": "^7.1.1",
|
||||
"@gpustack/core-ui": "^1.0.37",
|
||||
"@gpustack/core-ui": "^1.0.35",
|
||||
"@huggingface/gguf": "^0.1.7",
|
||||
"@huggingface/hub": "^0.15.1",
|
||||
"@huggingface/tasks": "^0.11.6",
|
||||
|
||||
Generated
+5
-5
@@ -24,8 +24,8 @@ importers:
|
||||
specifier: ^7.1.1
|
||||
version: 7.1.2
|
||||
'@gpustack/core-ui':
|
||||
specifier: ^1.0.37
|
||||
version: 1.0.37(czdvzceysqw7iv6pct2ucnb23e)
|
||||
specifier: ^1.0.35
|
||||
version: 1.0.35(czdvzceysqw7iv6pct2ucnb23e)
|
||||
'@huggingface/gguf':
|
||||
specifier: ^0.1.7
|
||||
version: 0.1.18
|
||||
@@ -1481,8 +1481,8 @@ packages:
|
||||
resolution: {integrity: sha512-KWk80UPIzPmUg+P0rKh6TqspRw0G6eux1PuJr+zz47ftMaZ9QDwbGzHZbtzWkl5hgayM/qrKRutllRC7D/vVXQ==, tarball: https://registry.npmjs.org/@formatjs/intl-utils/-/intl-utils-2.3.0.tgz}
|
||||
deprecated: the package is rather renamed to @formatjs/ecma-abstract with some changes in functionality (primarily selectUnit is removed and we don't plan to make any further changes to this package
|
||||
|
||||
'@gpustack/core-ui@1.0.37':
|
||||
resolution: {integrity: sha512-eP9uD4Eg04i2S+AQMwVh/siX8EjfAsZTiuaybe73hNahuJXV6/gqd+67zzBq27lmZRpa4Ao5CSX8JSjz06AONA==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.37.tgz}
|
||||
'@gpustack/core-ui@1.0.35':
|
||||
resolution: {integrity: sha512-MaEmCM3FikeKZdUgDSyUtIfXtNMbNOLeFiTynl6DcKEraUOpk6tgQXPDginBY3uXuisG4hzoIQbpHHkzaYiQew==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.35.tgz}
|
||||
peerDependencies:
|
||||
'@ant-design/icons': ^6.1.0
|
||||
'@ant-design/pro-components': 3.1.0-0
|
||||
@@ -10802,7 +10802,7 @@ snapshots:
|
||||
|
||||
'@formatjs/intl-utils@2.3.0': {}
|
||||
|
||||
'@gpustack/core-ui@1.0.37(czdvzceysqw7iv6pct2ucnb23e)':
|
||||
'@gpustack/core-ui@1.0.35(czdvzceysqw7iv6pct2ucnb23e)':
|
||||
dependencies:
|
||||
'@ant-design/icons': 6.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@ant-design/pro-components': 3.1.0-0(antd@6.3.7(date-fns@2.30.0)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { GPUStackVersionAtom } from '@/atoms/user';
|
||||
import { getAtomStorage } from '@/atoms/utils';
|
||||
import VersionInfo, { modalConfig } from '@/components/version-info';
|
||||
import externalLinks from '@/constants/external-links';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Button, Divider, Modal, Typography } from 'antd';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const CompanyWrapper = styled.div`
|
||||
@@ -35,7 +35,6 @@ const Footer: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const [modal, contextHolder] = Modal.useModal();
|
||||
const { styles } = useStyles();
|
||||
const version = useAtomValue(GPUStackVersionAtom);
|
||||
|
||||
const showVersion = () => {
|
||||
modal.info({
|
||||
@@ -74,7 +73,7 @@ const Footer: React.FC = () => {
|
||||
</Button>
|
||||
<Divider orientation="vertical" />
|
||||
<Button type="link" size="small" onClick={showVersion}>
|
||||
{version?.version}
|
||||
{getAtomStorage(GPUStackVersionAtom)?.version}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Logo from '@/assets/images/gpustack-logo.png';
|
||||
import { GPUStackVersionAtom, UpdateCheckAtom, userAtom } from '@/atoms/user';
|
||||
import externalLinks from '@/constants/external-links';
|
||||
import { useLogo } from '@/hooks/use-logo';
|
||||
import { Button } from 'antd';
|
||||
import { useAtom } from 'jotai';
|
||||
import React from 'react';
|
||||
@@ -19,7 +18,7 @@ const VersionInfo: React.FC<{ intl: any }> = ({ intl }) => {
|
||||
isProd,
|
||||
isDev
|
||||
} = gpuStackVersionAtom;
|
||||
const { sidebarLogo } = useLogo();
|
||||
|
||||
// user info
|
||||
const { is_admin } = userDataAtom || {};
|
||||
|
||||
@@ -31,7 +30,7 @@ const VersionInfo: React.FC<{ intl: any }> = ({ intl }) => {
|
||||
return (
|
||||
<div className="version-box">
|
||||
<div className="img">
|
||||
<img src={sidebarLogo || Logo} alt="logo" />
|
||||
<img src={Logo} alt="logo" />
|
||||
</div>
|
||||
|
||||
<div className="ver">
|
||||
|
||||
@@ -40,11 +40,6 @@ export default function useWatchList<T = Record<string, any>>(API: string) {
|
||||
}
|
||||
});
|
||||
|
||||
const cancelWatch = useMemoizedFn(() => {
|
||||
chunkRequestRef.current?.current?.cancel?.();
|
||||
listRequestTokenRef.current?.cancel?.();
|
||||
});
|
||||
|
||||
const queryAllDataList = async (
|
||||
params: Global.SearchParams,
|
||||
options?: any
|
||||
@@ -83,15 +78,14 @@ export default function useWatchList<T = Record<string, any>>(API: string) {
|
||||
useEffect(() => {
|
||||
createWatchChunkRequest();
|
||||
return () => {
|
||||
cancelWatch();
|
||||
chunkRequestRef.current?.cancel?.();
|
||||
listRequestTokenRef.current?.cancel?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
watchDataList,
|
||||
setWatchDataList,
|
||||
startWatch: createWatchChunkRequest,
|
||||
cancelWatch,
|
||||
deleteItemFromCache: handleDeleteItemFromCache
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import VersionInfo, { modalConfig } from '@/components/version-info';
|
||||
import externalLinks from '@/constants/external-links';
|
||||
import useBodyScroll from '@/hooks/use-body-scroll';
|
||||
import { logout } from '@/pages/login/apis';
|
||||
import { getGPUStackPlugin } from '@/plugins';
|
||||
import { useModel } from '@@/plugin-model';
|
||||
import {
|
||||
DiscordOutlined,
|
||||
@@ -99,7 +98,6 @@ const CustomItem = styled.div`
|
||||
|
||||
export const ExtraContent = (props: { isDarkTheme?: boolean }) => {
|
||||
const { isDarkTheme } = props;
|
||||
const plugin = getGPUStackPlugin();
|
||||
const { saveScrollHeight, restoreScrollHeight } = useBodyScroll();
|
||||
const [modal, contextHolder] = Modal.useModal();
|
||||
const [version] = useAtom(GPUStackVersionAtom);
|
||||
@@ -288,17 +286,15 @@ export const ExtraContent = (props: { isDarkTheme?: boolean }) => {
|
||||
</NewLabel>
|
||||
)}
|
||||
</div>
|
||||
{!plugin && (
|
||||
<DropdownActions menu={{ ...helpMenu }} popupRender={helpPopupRender}>
|
||||
<IconWrapper>
|
||||
<IconFont
|
||||
type="icon-help"
|
||||
className="font-size-20"
|
||||
style={{ color: 'var(--ant-color-text-tertiary)' }}
|
||||
/>
|
||||
</IconWrapper>
|
||||
</DropdownActions>
|
||||
)}
|
||||
<DropdownActions menu={{ ...helpMenu }} popupRender={helpPopupRender}>
|
||||
<IconWrapper>
|
||||
<IconFont
|
||||
type="icon-help"
|
||||
className="font-size-20"
|
||||
style={{ color: 'var(--ant-color-text-tertiary)' }}
|
||||
/>
|
||||
</IconWrapper>
|
||||
</DropdownActions>
|
||||
<PluginExtraField name="GlobalSettings" />
|
||||
<DropdownActions menu={{ ...userMenu }} popupRender={userPopupRender}>
|
||||
<IconWrapper>
|
||||
|
||||
@@ -44,8 +44,6 @@ export default {
|
||||
'On the Worker that needs to be added, run the following command to join it to the cluster.',
|
||||
'clusters.create.addCommand.k8s.tips':
|
||||
'On the Kubernetes cluster that needs to be registered, run the following command to create the Kubernetes resources and register the cluster.',
|
||||
'clusters.create.addCommand.k8s.version.warning':
|
||||
'The minimum supported Kubernetes version is 1.23. To use the GPU Service feature, the minimum supported Kubernetes version is 1.27.',
|
||||
'clusters.create.register.tips':
|
||||
'On the Kubernetes cluster that needs to be added, run the following command to join its nodes to the cluster.',
|
||||
'cluster.create.checkEnv.tips':
|
||||
|
||||
@@ -2,7 +2,6 @@ export default {
|
||||
'gpuservice.template': 'GPU Instance Template',
|
||||
'gpuservice.template.add': 'Add Instance Template',
|
||||
'gpuservice.template.edit': 'Edit Instance Template',
|
||||
'gpuservice.template.clone': 'Clone Instance Template',
|
||||
'gpuservice.template.filter.name': 'Filter by name',
|
||||
'gpuservice.template.filter.vendor': 'Filter by vendor',
|
||||
'gpuservice.template.image': 'Image',
|
||||
|
||||
@@ -13,7 +13,7 @@ export default {
|
||||
'menu.models.modelCatalog': 'Catalog',
|
||||
'menu.models.catalog': 'Model Catalog',
|
||||
'menu.models.deployment': 'Deployments',
|
||||
'menu.models.userModels': 'Models',
|
||||
'menu.models.userModels': 'My Models',
|
||||
'menu.models.benchmark': 'Benchmarks',
|
||||
'menu.models.benchmarkDetail': 'Benchmark Details',
|
||||
'menu.models.providers': 'Providers',
|
||||
|
||||
@@ -44,8 +44,6 @@ export default {
|
||||
'On the Worker that needs to be added, run the following command to join it to the cluster.',
|
||||
'clusters.create.addCommand.k8s.tips':
|
||||
'登録する Kubernetes クラスターで以下のコマンドを実行し、Kubernetes リソースを作成してクラスターを登録します。',
|
||||
'clusters.create.addCommand.k8s.version.warning':
|
||||
'サポートされる Kubernetes の最小バージョンは 1.23 です。GPU Service 機能を使用する場合、サポートされる Kubernetes の最小バージョンは 1.27 です。',
|
||||
'cluster.create.checkEnv.tips':
|
||||
'Use the following command to check if the environment is ready.',
|
||||
'clusters.create.register.tips':
|
||||
|
||||
@@ -2,7 +2,6 @@ export default {
|
||||
'gpuservice.template': 'GPU インスタンステンプレート',
|
||||
'gpuservice.template.add': 'インスタンステンプレートを追加',
|
||||
'gpuservice.template.edit': 'インスタンステンプレートを編集',
|
||||
'gpuservice.template.clone': 'インスタンステンプレートを複製',
|
||||
'gpuservice.template.filter.name': '名前でフィルター',
|
||||
'gpuservice.template.filter.vendor': 'ベンダーでフィルター',
|
||||
'gpuservice.template.image': 'コンテナイメージ',
|
||||
|
||||
@@ -41,7 +41,7 @@ export default {
|
||||
'menu.accessControl.organizations': 'Organizations',
|
||||
'menu.resources.clusters': 'Clusters',
|
||||
'menu.resources.credentials': 'Cloud Credentials',
|
||||
'menu.models.userModels': 'Models',
|
||||
'menu.models.userModels': 'My Models',
|
||||
'menu.resources.clusterDetail': 'Cluster Detail',
|
||||
'menu.resources.clusterCreate': 'Create Cluster',
|
||||
'menu.models.backendsList': 'Inference Backends',
|
||||
@@ -62,11 +62,11 @@ export default {
|
||||
// 6. 'menu.accessControl.apikeys': 'API Keys',
|
||||
// 7. 'menu.accessControl.users': 'Users',
|
||||
// 8. 'menu.clusterManagement': 'Cluster Management',
|
||||
// 9. 'menu.resources.clusters': 'Clusters',
|
||||
// 10. 'menu.resources.credentials': 'Cloud Credentials',
|
||||
// 11. 'menu.models.userModels': 'Models'
|
||||
// 12. 'menu.resources.clusterDetail': 'Cluster Detail',
|
||||
// 13. 'menu.resources.clusterCreate': 'Create Cluster',
|
||||
// 9. 'menu.clusterManagement.clusters': 'Clusters',
|
||||
// 10. 'menu.clusterManagement.credentials': 'Cloud Credentials',
|
||||
// 11. 'menu.models.userModels': 'My Models'
|
||||
// 12. 'menu.clusterManagement.clusterDetail': 'Cluster Detail',
|
||||
// 13. 'menu.clusterManagement.clusterCreate': 'Create Cluster',
|
||||
// 14. 'menu.models.backendsList': 'Inference Backends',
|
||||
// 15. 'menu.models.benchmark': 'Benchmarks',
|
||||
// 15. 'menu.models.provider': 'Provider',
|
||||
|
||||
@@ -44,8 +44,6 @@ export default {
|
||||
'На воркере, который необходимо добавить, выполните следующую команду, чтобы присоединить его к кластеру.',
|
||||
'clusters.create.addCommand.k8s.tips':
|
||||
'На Kubernetes-кластере, который необходимо зарегистрировать, выполните следующую команду, чтобы создать ресурсы Kubernetes и зарегистрировать этот кластер.',
|
||||
'clusters.create.addCommand.k8s.version.warning':
|
||||
'Минимальная поддерживаемая версия Kubernetes — 1.23. Для использования функции GPU Service минимальная поддерживаемая версия Kubernetes — 1.27.',
|
||||
'cluster.create.checkEnv.tips':
|
||||
'Используйте следующую команду для проверки готовности окружения',
|
||||
'clusters.create.register.tips':
|
||||
|
||||
@@ -2,7 +2,6 @@ export default {
|
||||
'gpuservice.template': 'Шаблон экземпляра GPU',
|
||||
'gpuservice.template.add': 'Добавить шаблон экземпляра',
|
||||
'gpuservice.template.edit': 'Редактировать шаблон экземпляра',
|
||||
'gpuservice.template.clone': 'Клонировать шаблон экземпляра',
|
||||
'gpuservice.template.filter.name': 'Фильтр по имени',
|
||||
'gpuservice.template.filter.vendor': 'Фильтр по производителю',
|
||||
'gpuservice.template.image': 'Образ',
|
||||
|
||||
@@ -40,7 +40,7 @@ export default {
|
||||
'menu.accessControl.organizations': 'Организации',
|
||||
'menu.resources.clusters': 'Кластеры',
|
||||
'menu.resources.credentials': 'Облачные аккаунты',
|
||||
'menu.models.userModels': 'Модели',
|
||||
'menu.models.userModels': 'Мои модели',
|
||||
'menu.resources.clusterDetail': 'Детали кластера',
|
||||
'menu.resources.clusterCreate': 'Создать кластер',
|
||||
'menu.models.backendsList': 'Бэкенды запуска',
|
||||
|
||||
@@ -44,8 +44,6 @@ export default {
|
||||
'Eklenmesi gereken İşçi Düğümde, kümeye katılması için aşağıdaki komutu çalıştırın.',
|
||||
'clusters.create.addCommand.k8s.tips':
|
||||
'Kaydedilmesi gereken Kubernetes kümesinde, Kubernetes kaynaklarını oluşturmak ve kümeyi kaydetmek için aşağıdaki komutu çalıştırın.',
|
||||
'clusters.create.addCommand.k8s.version.warning':
|
||||
'Desteklenen minimum Kubernetes sürümü 1.23’tür. GPU Service özelliğini kullanmak için desteklenen minimum Kubernetes sürümü 1.27’dir.',
|
||||
'clusters.create.register.tips':
|
||||
'Eklenmesi gereken Kubernetes kümesinde, düğümlerini kümeye katılması için aşağıdaki komutu çalıştırın.',
|
||||
'cluster.create.checkEnv.tips':
|
||||
|
||||
@@ -2,7 +2,6 @@ export default {
|
||||
'gpuservice.template': 'GPU Örnek Şablonu',
|
||||
'gpuservice.template.add': 'Örnek Şablonu Ekle',
|
||||
'gpuservice.template.edit': 'Örnek Şablonunu Düzenle',
|
||||
'gpuservice.template.clone': 'Örnek Şablonunu Klonla',
|
||||
'gpuservice.template.filter.name': 'Ada göre filtrele',
|
||||
'gpuservice.template.filter.vendor': 'Tedarikçiye göre filtrele',
|
||||
'gpuservice.template.image': 'İmaj',
|
||||
|
||||
@@ -13,12 +13,12 @@ export default {
|
||||
'menu.models.modelCatalog': 'Katalog',
|
||||
'menu.models.catalog': 'Model Kataloğu',
|
||||
'menu.models.deployment': 'Dağıtımlar',
|
||||
'menu.models.userModels': 'Modeller',
|
||||
'menu.models.userModels': 'Modellerim',
|
||||
'menu.models.benchmark': 'Kıyaslamalar',
|
||||
'menu.models.benchmarkDetail': 'Kıyaslama Detayları',
|
||||
'menu.models.providers': 'Sağlayıcılar',
|
||||
'menu.models.routes': 'Yönlendirmeler',
|
||||
'menu.models.usage': 'Kullanım',
|
||||
'menu.models.usage': 'Usage',
|
||||
'menu.modelCatalog': 'Katalog',
|
||||
'menu.resources': 'Kaynaklar',
|
||||
'menu.apikeys': 'API Anahtarları',
|
||||
|
||||
@@ -43,8 +43,6 @@ export default {
|
||||
'在需要添加的节点上运行以下命令,将其加入到集群中。',
|
||||
'clusters.create.addCommand.k8s.tips':
|
||||
'在需要注册的 Kubernetes 集群中运行以下命令,创建 Kubernetes 资源,注册该集群。',
|
||||
'clusters.create.addCommand.k8s.version.warning':
|
||||
'Kubernetes 版本最低支持 1.23,如果要使用 GPU Service 功能,Kubernetes 版本最低支持 1.27。',
|
||||
'clusters.create.register.tips':
|
||||
'在需要添加的 Kubernetes 集群上运行以下命令,将其中的节点加入到集群中。',
|
||||
'cluster.create.checkEnv.tips': '使用以下命令检查环境是否准备妥当。',
|
||||
|
||||
@@ -2,7 +2,6 @@ export default {
|
||||
'gpuservice.template': 'GPU 实例模板',
|
||||
'gpuservice.template.add': '添加实例模板',
|
||||
'gpuservice.template.edit': '编辑实例模板',
|
||||
'gpuservice.template.clone': '克隆实例模板',
|
||||
'gpuservice.template.filter.name': '按名称过滤',
|
||||
'gpuservice.template.filter.vendor': '按厂商过滤',
|
||||
'gpuservice.template.image': '镜像',
|
||||
|
||||
@@ -12,7 +12,7 @@ export default {
|
||||
'menu.models.modelList': '部署与管理',
|
||||
'menu.models.modelCatalog': '模型库',
|
||||
'menu.models.deployment': '部署',
|
||||
'menu.models.userModels': '模型广场',
|
||||
'menu.models.userModels': '我的模型',
|
||||
'menu.models.benchmark': '基准测试',
|
||||
'menu.models.benchmarkDetail': '基准测试详情',
|
||||
'menu.models.providers': '提供商',
|
||||
|
||||
@@ -183,15 +183,11 @@ const APIKeys: React.FC = () => {
|
||||
if (type !== 'Table') return;
|
||||
return (
|
||||
<NoResult
|
||||
minHeight="calc(100vh - 300px)"
|
||||
loading={dataSource.loading}
|
||||
loadend={dataSource.loadend}
|
||||
dataSource={dataSource.dataList}
|
||||
image={<IconFont type="icon-key" />}
|
||||
filters={{
|
||||
..._.omit(queryParams, ['sort_by']),
|
||||
user_id: queryParams.user_id === '*' ? undefined : queryParams.user_id
|
||||
}}
|
||||
filters={_.omit(queryParams, ['sort_by'])}
|
||||
noFoundText={intl.formatMessage({
|
||||
id: 'noresult.keys.nofound'
|
||||
})}
|
||||
|
||||
@@ -164,7 +164,6 @@ const CommunityBackends: React.FC<{
|
||||
renderItem={renderItem}
|
||||
></BackendCardList>
|
||||
<NoResult
|
||||
minHeight="calc(100vh - 300px)"
|
||||
loading={dataSource.loading}
|
||||
loadend={dataSource.loadend}
|
||||
dataSource={dataSource.dataList}
|
||||
|
||||
@@ -282,7 +282,6 @@ const BackendList = () => {
|
||||
onSelect={handleOnSelect}
|
||||
></BackendCardList>
|
||||
<NoResult
|
||||
minHeight="calc(100vh - 300px)"
|
||||
loading={dataSource.loading}
|
||||
loadend={dataSource.loadend}
|
||||
dataSource={dataSource.dataList}
|
||||
|
||||
@@ -177,7 +177,6 @@ const Benchmark: React.FC = () => {
|
||||
if (type !== 'Table') return;
|
||||
return (
|
||||
<NoResult
|
||||
minHeight="calc(100vh - 300px)"
|
||||
loading={dataSource.loading}
|
||||
loadend={dataSource.loadend}
|
||||
dataSource={[]}
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { PageActionType } from '@/config/types';
|
||||
import useExpandedRowKeys from '@/hooks/use-expanded-row-keys';
|
||||
import useTableFetch from '@/hooks/use-table-fetch';
|
||||
import useWatchList from '@/hooks/use-watch-list';
|
||||
import { getGPUStackPlugin } from '@/plugins';
|
||||
import {
|
||||
DeleteModal,
|
||||
FilterBar,
|
||||
@@ -15,7 +14,7 @@ import {
|
||||
TableOrder,
|
||||
TableProvider
|
||||
} from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { useIntl, useNavigate } from '@umijs/max';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { message } from 'antd';
|
||||
import { useAtom } from 'jotai';
|
||||
@@ -74,7 +73,7 @@ const Clusters: React.FC = () => {
|
||||
deleteAPI: deleteCluster,
|
||||
watch: true,
|
||||
API: CLUSTERS_API,
|
||||
contentForDelete: 'menu.resources.clusters',
|
||||
contentForDelete: 'menu.clusterManagement.clusters',
|
||||
defaultQueryParams: {
|
||||
// Management view: drop cross-Org cluster_access grants. Org
|
||||
// Owner only sees the clusters they own here. Pickers that
|
||||
@@ -83,13 +82,10 @@ const Clusters: React.FC = () => {
|
||||
mine: true
|
||||
}
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
const { goToGrafana, ActionButton } = useGranfanaLink({
|
||||
type: 'cluster'
|
||||
});
|
||||
// Cluster Access lives in the enterprise plugin: it contributes the
|
||||
// row action and this self-controlled drawer, owning its own
|
||||
// open/close state. OSS just mounts it (nothing without a plugin).
|
||||
const AccessDrawer = getGPUStackPlugin()?.clusterDetail?.AccessDrawer;
|
||||
const { watchDataList: allWorkerPoolList } = useWatchList(WORKER_POOLS_API);
|
||||
const [expandAtom] = useAtom(expandKeysAtom);
|
||||
const [clusterSession, setClusterSession] = useAtom(clusterSessionAtom);
|
||||
@@ -276,6 +272,14 @@ const Clusters: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const handleOnCell = useMemoizedFn((record: ClusterListItem, dataIndex) => {
|
||||
if (dataIndex === 'name') {
|
||||
navigate(
|
||||
`/resources/clusters/detail?id=${record.id}&name=${record.name}&page=clusters`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCredentialList = async () => {
|
||||
const data = await queryCredentialList({ page: -1 });
|
||||
@@ -362,7 +366,7 @@ const Clusters: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const columns = useClusterColumns(handleSelect);
|
||||
const columns = useClusterColumns(handleSelect, handleOnCell);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -416,7 +420,7 @@ const Clusters: React.FC = () => {
|
||||
loadend={dataSource.loadend}
|
||||
dataSource={dataSource.dataList}
|
||||
image={<IconFont type="icon-cluster-outline" />}
|
||||
filters={_.omit(queryParams, ['sort_by', 'mine'])}
|
||||
filters={_.omit(queryParams, ['sort_by'])}
|
||||
noFoundText={intl.formatMessage({
|
||||
id: 'noresult.cluster.nofound'
|
||||
})}
|
||||
@@ -478,7 +482,6 @@ const Clusters: React.FC = () => {
|
||||
onClose={handleClusterModalClose}
|
||||
></ClusterModal>
|
||||
{AddWorkerModal}
|
||||
{AccessDrawer && <AccessDrawer />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { ExclamationCircleFilled } from '@ant-design/icons';
|
||||
import { AlertBlockInfo } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Typography } from 'antd';
|
||||
import RegisterClusterInner from '../register-cluster-inner';
|
||||
@@ -36,14 +34,6 @@ const K8sRunCommand: React.FC<AddWorkerStepProps> = ({ disabled }) => {
|
||||
id: 'clusters.create.addCommand.k8s.tips'
|
||||
})}
|
||||
</Typography.Paragraph>
|
||||
<AlertBlockInfo
|
||||
type="warning"
|
||||
style={{ marginBottom: 8 }}
|
||||
icon={<ExclamationCircleFilled />}
|
||||
message={intl.formatMessage({
|
||||
id: 'clusters.create.addCommand.k8s.version.warning'
|
||||
})}
|
||||
></AlertBlockInfo>
|
||||
<RegisterClusterInner
|
||||
registrationInfo={registrationInfo}
|
||||
currentGPU={currentGPU}
|
||||
|
||||
@@ -59,12 +59,6 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
|
||||
const intl = useIntl();
|
||||
const [activeKey, setActiveKey] = React.useState<string[]>([]);
|
||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||
// Single source of truth for the K8s cluster type, seeded from the cluster
|
||||
// being edited. Shared via FormContext so the type selector and the
|
||||
// GPU-only fields stay in sync deterministically (no cross-component watch).
|
||||
const [clusterType, setClusterType] = useState<'model' | 'gpu'>(() =>
|
||||
currentData?.k8s_options?.gpuInstanceOptions ? 'gpu' : 'model'
|
||||
);
|
||||
const advanceConfigRef = React.useRef<any>(null);
|
||||
const systemConfig = useAtomValue(systemConfigAtom);
|
||||
|
||||
@@ -93,20 +87,6 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
|
||||
|
||||
const next: any = { ...opts };
|
||||
|
||||
// "model" clusters must not carry GPU-instance config. The field's UI is
|
||||
// unmounted when model is selected, but strip it here too so the payload
|
||||
// never keeps a stale gpuInstanceOptions shape from a prior "gpu" choice.
|
||||
if (clusterType === 'model') {
|
||||
next.gpuInstanceOptions = null;
|
||||
} else if (clusterType === 'gpu' && !next.gpuInstanceOptions) {
|
||||
// gpuInstanceOptions has no always-mounted Form.Item (its only child,
|
||||
// the optional static address, may be unmounted or empty), so with
|
||||
// preserve={false} onFinish's `values` can omit it. Read it straight
|
||||
// from the store so a "gpu" cluster always carries the field.
|
||||
next.gpuInstanceOptions =
|
||||
form.getFieldValue(['k8s_options', 'gpuInstanceOptions']) ?? {};
|
||||
}
|
||||
|
||||
const creds = opts.imageCredentials;
|
||||
if (Array.isArray(creds)) {
|
||||
next.imageCredentials = creds.map((c: any) => ({
|
||||
@@ -240,9 +220,7 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
|
||||
};
|
||||
|
||||
return (
|
||||
<FormContext.Provider
|
||||
value={{ submitAttempted, clusterType, setClusterType }}
|
||||
>
|
||||
<FormContext.Provider value={{ submitAttempted }}>
|
||||
<Form
|
||||
name="clusterForm"
|
||||
form={form}
|
||||
|
||||
@@ -2,12 +2,10 @@ import { PageAction } from '@/config';
|
||||
import { PageActionType } from '@/config/types';
|
||||
import { Input as CInput, LabelSelector } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { Form } from 'antd';
|
||||
import _ from 'lodash';
|
||||
import React, { useEffect, useId, useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { useFormContext } from '../config/form-context';
|
||||
import { useStepsContext } from '../config/steps-context';
|
||||
import { ClusterListItem as ListItem } from '../config/types';
|
||||
import ImageCredential from './image-credential';
|
||||
@@ -70,6 +68,13 @@ export const OperatorImageForm: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// The presence of `gpuInstanceOptions` on `k8s_options` is the source of truth
|
||||
// for whether GPU instances are enabled. Both the cluster-type selector
|
||||
// (rendered up top) and the static-address field (rendered in the advanced
|
||||
// section) watch this same path so they stay in sync without sharing local
|
||||
// state.
|
||||
const GPU_INSTANCE_OPTIONS_PATH = ['k8s_options', 'gpuInstanceOptions'];
|
||||
|
||||
// Visual parity with @gpustack/core-ui's SwitchCard so the selector blends
|
||||
// in with surrounding form fields: same border, radius, padding, and
|
||||
// typography. The only differences are the two-column grid layout and an
|
||||
@@ -96,7 +101,6 @@ const ClusterTypeGrid = styled.div`
|
||||
`;
|
||||
|
||||
const ClusterTypeCard = styled.div<{ $active: boolean }>`
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
@@ -136,17 +140,6 @@ 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;
|
||||
@@ -176,30 +169,37 @@ const RadioDot = styled.span<{ $active: boolean }>`
|
||||
|
||||
// Card-based selector for cluster type. The two options are mutually exclusive
|
||||
// and the choice maps directly to the presence/absence of `gpuInstanceOptions`
|
||||
// on the submitted payload. No standalone form field is registered; the click
|
||||
// only updates the shared `clusterType` state (see FormContext) — the payload's
|
||||
// `gpuInstanceOptions` shape is derived from it at submit (see cluster-form's
|
||||
// normalizeOutgoing), and the static-address field mounts/unmounts off it.
|
||||
// on the form — "model" clears it, "gpu" seeds it to {} (preserving any
|
||||
// already-entered static address). No standalone form field is registered;
|
||||
// state is read via useWatch with `preserve: true` so it tracks updates made
|
||||
// through setFieldValue.
|
||||
export const ClusterTypeSelector: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const form = Form.useFormInstance();
|
||||
const { presetClusterType } = useStepsContext();
|
||||
const labelId = useId();
|
||||
// Cluster type is shared, explicit state (see FormContext): the click is the
|
||||
// source of truth. This replaced a Form.useWatch on an unregistered path that
|
||||
// did not re-render reliably when cleared to undefined.
|
||||
const { clusterType, setClusterType } = useFormContext();
|
||||
const value: 'model' | 'gpu' = clusterType ?? 'model';
|
||||
|
||||
const handleSelect = useMemoizedFn((next: 'model' | 'gpu') => {
|
||||
if (next === value) return;
|
||||
setClusterType?.(next);
|
||||
const gpuInstanceOptions = Form.useWatch(GPU_INSTANCE_OPTIONS_PATH, {
|
||||
form,
|
||||
preserve: true
|
||||
});
|
||||
const value: 'model' | 'gpu' = gpuInstanceOptions ? 'gpu' : 'model';
|
||||
|
||||
const handleSelect = (next: 'model' | 'gpu') => {
|
||||
if (!form || next === value) return;
|
||||
if (next === 'gpu') {
|
||||
form.setFieldValue(
|
||||
GPU_INSTANCE_OPTIONS_PATH,
|
||||
form.getFieldValue(GPU_INSTANCE_OPTIONS_PATH) ?? {}
|
||||
);
|
||||
} else {
|
||||
form.setFieldValue(GPU_INSTANCE_OPTIONS_PATH, undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const options: {
|
||||
key: 'model' | 'gpu';
|
||||
title: string;
|
||||
description: string;
|
||||
experimental?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
key: 'model',
|
||||
@@ -209,8 +209,7 @@ export const ClusterTypeSelector: React.FC = () => {
|
||||
{
|
||||
key: 'gpu',
|
||||
title: intl.formatMessage({ id: 'clusters.gpuInstances.title' }),
|
||||
description: intl.formatMessage({ id: 'clusters.gpuInstances.tip' }),
|
||||
experimental: true
|
||||
description: intl.formatMessage({ id: 'clusters.gpuInstances.tip' })
|
||||
}
|
||||
];
|
||||
|
||||
@@ -245,11 +244,6 @@ export const ClusterTypeSelector: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<RadioDot $active={active} />
|
||||
{opt.experimental && (
|
||||
<ExperimentalTag>
|
||||
{intl.formatMessage({ id: 'common.tag.experimental' })}
|
||||
</ExperimentalTag>
|
||||
)}
|
||||
<div className="body">
|
||||
<div className="title">{opt.title}</div>
|
||||
<div className="description">{opt.description}</div>
|
||||
@@ -267,13 +261,14 @@ export const ClusterTypeSelector: React.FC = () => {
|
||||
// default container registry and the worker config (节点配置).
|
||||
export const GpuInstancesStaticAddressForm: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
// Visibility tracks the shared cluster-type state (see FormContext), so this
|
||||
// field mounts/unmounts deterministically with the selector. Its Form.Item is
|
||||
// the only thing keeping gpuInstanceOptions alive, so unmounting it here (with
|
||||
// the form's preserve={false}) also clears that path from the store.
|
||||
const { clusterType } = useFormContext();
|
||||
// See note in ClusterTypeSelector: watch the full store so this field's
|
||||
// visibility tracks the selector even before it has mounted its own
|
||||
// Form.Item.
|
||||
const enabled = !!Form.useWatch(GPU_INSTANCE_OPTIONS_PATH, {
|
||||
preserve: true
|
||||
});
|
||||
|
||||
if (clusterType !== 'gpu') {
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,6 @@ import { ClusterListItem } from './types';
|
||||
interface FormContextProps {
|
||||
currentData?: ClusterListItem;
|
||||
submitAttempted?: boolean;
|
||||
// K8s cluster type. `gpuInstanceOptions` on the form is derived from this —
|
||||
// the selector, the static-address field, and submit all read this single
|
||||
// source of truth instead of independently watching the (unregistered) form
|
||||
// path, which did not re-render reliably.
|
||||
clusterType?: 'model' | 'gpu';
|
||||
setClusterType?: (type: 'model' | 'gpu') => void;
|
||||
}
|
||||
|
||||
export const FormContext = createContext<FormContextProps>({});
|
||||
|
||||
@@ -58,7 +58,7 @@ const Credentials: React.FC = () => {
|
||||
key: PaginationKey.Credentials,
|
||||
fetchAPI: queryCredentialList,
|
||||
deleteAPI: deleteCredential,
|
||||
contentForDelete: 'menu.resources.credentials'
|
||||
contentForDelete: 'menu.clusterManagement.credentials'
|
||||
});
|
||||
const [, setClusterSession] = useAtom(clusterSessionAtom);
|
||||
const [isFromCluster, setIsFromCluster] = useAtom(fromClusterCreationAtom);
|
||||
@@ -157,7 +157,6 @@ const Credentials: React.FC = () => {
|
||||
if (type !== 'Table') return;
|
||||
return (
|
||||
<NoResult
|
||||
minHeight="calc(100vh - 300px)"
|
||||
loading={dataSource.loading}
|
||||
loadend={dataSource.loadend}
|
||||
dataSource={[]}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type TableColumnProps as SealColumnProps
|
||||
} from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Tooltip } from 'antd';
|
||||
import { Tooltip, Typography } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useMemo } from 'react';
|
||||
@@ -85,15 +85,21 @@ const clusterActionList = [
|
||||
];
|
||||
|
||||
const useClusterColumns = (
|
||||
handleSelect: (val: string, record: ClusterListItem, item?: any) => void
|
||||
handleSelect: (val: string, record: ClusterListItem, item?: any) => void,
|
||||
onCellClick?: (record: ClusterListItem, dataIndex: string) => void
|
||||
): SealColumnProps[] => {
|
||||
const intl = useIntl();
|
||||
const systemConfig = useAtomValue(systemConfigAtom);
|
||||
const pluginCols = usePluginListColumns('clusters');
|
||||
// The cluster name is plain text: there is no cluster-detail page
|
||||
// to route into. A plugin may still contribute extra row actions
|
||||
// (topology, Cluster Access) via `clusterDetail.useGenerateActions`.
|
||||
const { useGenerateActions } = getGPUStackPlugin()?.clusterDetail || {};
|
||||
// The cluster-detail page is shipped in OSS source, but OSS keeps
|
||||
// it unreachable from the cluster list — the link is only
|
||||
// surfaced when a plugin opts in via
|
||||
// `clusterDetail.linkableName`. Without a plugin we render the
|
||||
// name as plain text (matches the pre-restore behaviour); with one
|
||||
// we use Typography.Link wired to the parent's `onCellClick`.
|
||||
|
||||
const { linkableName: nameLinkable, useGenerateActions } =
|
||||
getGPUStackPlugin()?.clusterDetail || {};
|
||||
|
||||
const actionList =
|
||||
useGenerateActions?.({ actions: clusterActionList }) || clusterActionList;
|
||||
@@ -154,7 +160,13 @@ const useClusterColumns = (
|
||||
render: (text: string, record: ClusterListItem) => (
|
||||
<>
|
||||
<AutoTooltip ghost title={text}>
|
||||
<span className="text-primary">{record.name}</span>
|
||||
{nameLinkable ? (
|
||||
<Typography.Link onClick={() => onCellClick?.(record, 'name')}>
|
||||
{record.name}
|
||||
</Typography.Link>
|
||||
) : (
|
||||
<span className="text-primary">{record.name}</span>
|
||||
)}
|
||||
</AutoTooltip>
|
||||
{record.is_default && (
|
||||
<Tooltip
|
||||
@@ -246,7 +258,7 @@ const useClusterColumns = (
|
||||
)
|
||||
}
|
||||
];
|
||||
}, [handleSelect, intl, pluginCols]);
|
||||
}, [handleSelect, onCellClick, intl, pluginCols]);
|
||||
};
|
||||
|
||||
export default useClusterColumns;
|
||||
|
||||
@@ -22,12 +22,7 @@ export const useQueryClusterList = (options?: { useStateData?: boolean }) => {
|
||||
loading,
|
||||
cancel
|
||||
} = useRequest(
|
||||
async (params: {
|
||||
page: number;
|
||||
perPage?: number;
|
||||
mine?: boolean;
|
||||
gpu_instance_enabled?: boolean;
|
||||
}) => {
|
||||
async (params: { page: number; perPage?: number }) => {
|
||||
axiosTokenRef.current?.cancel();
|
||||
axiosTokenRef.current = createAxiosToken();
|
||||
const res = await queryClusterList(params, {
|
||||
|
||||
@@ -18,17 +18,10 @@ const BasicForm = forwardRef((props: BasicFormProps, ref) => {
|
||||
console.log(values);
|
||||
};
|
||||
|
||||
// Forward through to the live ClusterForm ref on each call instead of
|
||||
// snapshotting its methods. ClusterForm rebuilds these closures whenever its
|
||||
// internal state (e.g. clusterType) changes, but BasicForm does not re-render
|
||||
// with it — a frozen snapshot would keep calling stale closures (reading the
|
||||
// initial clusterType) and be null on the first render before the ref attaches.
|
||||
useImperativeHandle(ref, () => ({
|
||||
validateFields: (...args: any[]) =>
|
||||
formRef.current?.validateFields(...args),
|
||||
getFieldsValue: (...args: any[]) =>
|
||||
formRef.current?.getFieldsValue(...args),
|
||||
submit: (...args: any[]) => formRef.current?.submit(...args)
|
||||
validateFields: formRef.current?.validateFields,
|
||||
getFieldsValue: formRef.current?.getFieldsValue,
|
||||
submit: formRef.current?.submit
|
||||
}));
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,20 +2,6 @@ import {
|
||||
BreakdownItem,
|
||||
UsageBreakdownResponse
|
||||
} from '@/pages/usage/config/types';
|
||||
import { withDeletedMark } from '@/pages/usage/utils/deleted-label';
|
||||
import { getIntl } from '@umijs/max';
|
||||
|
||||
// group dimension → the id field inside ``identity.current`` (the backend nulls
|
||||
// it for deleted entities, so the marker degrades to just "[Deleted]").
|
||||
const GROUP_ID_KEY: Record<
|
||||
UsageGroupBy,
|
||||
'model_id' | 'route_id' | 'user_id' | 'api_key_id'
|
||||
> = {
|
||||
model: 'model_id',
|
||||
route: 'route_id',
|
||||
user: 'user_id',
|
||||
api_key: 'api_key_id'
|
||||
};
|
||||
|
||||
export const overviewConfigs = [
|
||||
{
|
||||
@@ -102,7 +88,6 @@ export const buildUsageLabel = (item: BreakdownItem, groupBy: UsageGroupBy) => {
|
||||
return groupItem;
|
||||
}
|
||||
|
||||
let baseLabel: string;
|
||||
if (groupBy === 'model') {
|
||||
const providerName =
|
||||
identityValue?.provider_name || groupValue?.provider_name;
|
||||
@@ -112,38 +97,36 @@ export const buildUsageLabel = (item: BreakdownItem, groupBy: UsageGroupBy) => {
|
||||
rawItem.model_name ||
|
||||
rawItem.model;
|
||||
|
||||
baseLabel =
|
||||
providerName && modelName
|
||||
? `${providerName}/${modelName}`
|
||||
: groupItem?.label || modelName || '-';
|
||||
} else if (groupBy === 'route') {
|
||||
baseLabel =
|
||||
if (providerName && modelName) {
|
||||
return `${providerName}/${modelName}`;
|
||||
}
|
||||
|
||||
return groupItem?.label || modelName || '-';
|
||||
}
|
||||
|
||||
if (groupBy === 'route') {
|
||||
return (
|
||||
groupItem?.label ||
|
||||
identityValue?.route_name ||
|
||||
groupValue?.route_name ||
|
||||
'-';
|
||||
} else {
|
||||
baseLabel =
|
||||
groupItem?.label ||
|
||||
identityValue?.user_name ||
|
||||
identityValue?.api_key_name ||
|
||||
identityValue?.access_key ||
|
||||
groupValue?.user_name ||
|
||||
groupValue?.api_key_name ||
|
||||
groupValue?.access_key ||
|
||||
rawItem.user_name ||
|
||||
rawItem.api_key_name ||
|
||||
rawItem.access_key ||
|
||||
rawItem[groupBy] ||
|
||||
'-';
|
||||
'-'
|
||||
);
|
||||
}
|
||||
|
||||
// Mark deleted entities in the chart legend / tooltip as text (a legend can't
|
||||
// render a tag), matching the usage tabs. The id degrades to just "[Deleted]"
|
||||
// when the backend nulls ``identity.current`` for a deleted entity.
|
||||
const deletedWord = getIntl().formatMessage({ id: 'usage.table.deleted' });
|
||||
const id = groupItem?.identity?.current?.[GROUP_ID_KEY[groupBy]];
|
||||
return withDeletedMark(baseLabel, groupItem?.deleted, deletedWord, id);
|
||||
return (
|
||||
groupItem?.label ||
|
||||
identityValue?.user_name ||
|
||||
identityValue?.api_key_name ||
|
||||
identityValue?.access_key ||
|
||||
groupValue?.user_name ||
|
||||
groupValue?.api_key_name ||
|
||||
groupValue?.access_key ||
|
||||
rawItem.user_name ||
|
||||
rawItem.api_key_name ||
|
||||
rawItem.access_key ||
|
||||
rawItem[groupBy] ||
|
||||
'-'
|
||||
);
|
||||
};
|
||||
|
||||
export const getUsageResponseItems = (
|
||||
|
||||
@@ -6,8 +6,8 @@ import useUserDirectory from '../hooks/use-user-directory';
|
||||
/**
|
||||
* Owner tag for template cards, disambiguating same-name templates in
|
||||
* the admin's cross-tenant view. Renders nothing for non-admin callers
|
||||
* (gated on `canSeeAdmin`) and when a plugin provides its own
|
||||
* `OwnerScopeTag` slot.
|
||||
* (the management page is `mine`-scoped for them) and when a plugin
|
||||
* provides its own `OwnerScopeTag` slot.
|
||||
*/
|
||||
const OwnerTag: React.FC<{ ownerId?: number | null }> = ({ ownerId }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
@@ -10,18 +10,12 @@ import useOverlayLayout from '../hooks/use-overlay-layout';
|
||||
|
||||
interface StorageOverlayProps {
|
||||
open: boolean;
|
||||
// Org the surrounding instance create form targets (platform admin "All"
|
||||
// view). The storage inherits this scope, so the type list is pinned to
|
||||
// it — the picker only offers types that org can reference. Undefined
|
||||
// when there's no create-scope picker (the ambient org context applies).
|
||||
scopeOrgId?: number | null;
|
||||
onCancel: () => void;
|
||||
onSubmit: (values: StorageFormData) => Promise<void> | void;
|
||||
}
|
||||
|
||||
const StorageOverlay: React.FC<StorageOverlayProps> = ({
|
||||
open,
|
||||
scopeOrgId,
|
||||
onCancel,
|
||||
onSubmit
|
||||
}) => {
|
||||
@@ -34,14 +28,9 @@ const StorageOverlay: React.FC<StorageOverlayProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchStorageClass(
|
||||
{ page: -1 },
|
||||
scopeOrgId != null
|
||||
? { headers: { 'X-Organization-Id': String(scopeOrgId) } }
|
||||
: undefined
|
||||
);
|
||||
fetchStorageClass({ page: -1 });
|
||||
}
|
||||
}, [open, scopeOrgId]);
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
formRef.current?.submit();
|
||||
@@ -86,7 +75,6 @@ const StorageOverlay: React.FC<StorageOverlayProps> = ({
|
||||
ref={formRef}
|
||||
action={PageAction.CREATE}
|
||||
open={open}
|
||||
showOrgScope={false}
|
||||
onFinish={handleFinish}
|
||||
/>
|
||||
</FormContext.Provider>
|
||||
|
||||
@@ -30,10 +30,6 @@ const StorageVolume = ({
|
||||
const { getRuleMessage } = useAppUtils();
|
||||
const form = Form.useFormInstance<FormData>();
|
||||
const storageMode = Form.useWatch('storageMode', form);
|
||||
// Owned by the instance create-scope picker (platform admin "All" view).
|
||||
// A storage added inline belongs to the same org as the instance, so pass
|
||||
// it to the overlay to scope the storage-type list to that org.
|
||||
const scopeOrgId = Form.useWatch('organization_id', form);
|
||||
const { fetchData: createStorage } = useCreateStorage();
|
||||
const { detailData: storageData, fetchData: fetchStorage } =
|
||||
useQueryStorage();
|
||||
@@ -236,7 +232,6 @@ const StorageVolume = ({
|
||||
|
||||
<StorageOverlay
|
||||
open={overlayOpen}
|
||||
scopeOrgId={scopeOrgId}
|
||||
onCancel={() => setOverlayOpen(false)}
|
||||
onSubmit={handleCreateStorage}
|
||||
/>
|
||||
|
||||
@@ -271,7 +271,6 @@ const GPUService: React.FC = () => {
|
||||
if (!clusterLoading && !hasK8sCluster) {
|
||||
return (
|
||||
<NoResult
|
||||
minHeight="calc(100vh - 300px)"
|
||||
loading={dataSource.loading || clusterLoading}
|
||||
loadend={dataSource.loadend}
|
||||
dataSource={[]}
|
||||
@@ -295,7 +294,6 @@ const GPUService: React.FC = () => {
|
||||
}
|
||||
return (
|
||||
<NoResult
|
||||
minHeight="calc(100vh - 300px)"
|
||||
loading={dataSource.loading}
|
||||
loadend={dataSource.loadend}
|
||||
dataSource={dataSource.dataList}
|
||||
|
||||
@@ -110,7 +110,6 @@ const GPUServicePublicKeys: React.FC = () => {
|
||||
if (type !== 'Table') return;
|
||||
return (
|
||||
<NoResult
|
||||
minHeight="calc(100vh - 300px)"
|
||||
loading={dataSource.loading}
|
||||
loadend={dataSource.loadend}
|
||||
dataSource={dataSource.dataList}
|
||||
|
||||
@@ -1,23 +1,6 @@
|
||||
import { StatusMaps } from '@/config';
|
||||
import { StatusType } from '@/config/types';
|
||||
import { icons } from '@gpustack/core-ui';
|
||||
import { StorageTypeKind } from './types';
|
||||
|
||||
export const StorageTypePhaseValueMap = {
|
||||
Ready: 'Ready',
|
||||
Deleting: 'Deleting'
|
||||
};
|
||||
|
||||
export const StorageTypePhaseLabelMap: Record<string, string> = {
|
||||
[StorageTypePhaseValueMap.Ready]: 'Ready',
|
||||
[StorageTypePhaseValueMap.Deleting]: 'Deleting'
|
||||
};
|
||||
|
||||
export const status: Record<string, StatusType> = {
|
||||
[StorageTypePhaseValueMap.Ready]: StatusMaps.success,
|
||||
[StorageTypePhaseValueMap.Deleting]: StatusMaps.warning
|
||||
};
|
||||
|
||||
export const StorageTypeKindValueMap: Record<string, StorageTypeKind> = {
|
||||
NFS: 'nfs',
|
||||
S3: 's3'
|
||||
|
||||
@@ -44,8 +44,4 @@ export interface ListItem {
|
||||
nfs?: StorageTypeNFS | null;
|
||||
s3?: Omit<StorageTypeS3, 'secretKey'> | null;
|
||||
};
|
||||
status?: {
|
||||
phase?: string | null;
|
||||
phaseMessage?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -5,19 +5,13 @@ import {
|
||||
AutoTooltip,
|
||||
DropdownButtons,
|
||||
IconFont,
|
||||
StatusTag,
|
||||
ThemeTag
|
||||
} from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import type { ColumnsType } from 'antd/lib/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
rowActionList,
|
||||
status,
|
||||
StorageTypeKindLabelMap,
|
||||
StorageTypePhaseLabelMap
|
||||
} from '../config';
|
||||
import { rowActionList, StorageTypeKindLabelMap } from '../config';
|
||||
import { ListItem } from '../config/types';
|
||||
|
||||
interface ColumnsHookProps {
|
||||
@@ -87,24 +81,6 @@ const useStorageTypeColumns = ({
|
||||
sorter: false,
|
||||
render: (_text, record) => getKindLabel(record)
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({ id: 'common.table.status' }),
|
||||
dataIndex: ['status', 'phase'],
|
||||
key: 'status',
|
||||
sorter: false,
|
||||
render: (value: string, record: ListItem) =>
|
||||
value ? (
|
||||
<StatusTag
|
||||
statusValue={{
|
||||
status: status[value],
|
||||
text: StorageTypePhaseLabelMap[value] || value,
|
||||
message: record?.status?.phaseMessage || ''
|
||||
}}
|
||||
></StatusTag>
|
||||
) : (
|
||||
'-'
|
||||
)
|
||||
},
|
||||
...creatorCols,
|
||||
// {
|
||||
// title: intl.formatMessage({ id: 'common.table.description' }),
|
||||
|
||||
@@ -104,7 +104,6 @@ const GPUServiceStorageTypes: React.FC = () => {
|
||||
if (type !== 'Table') return;
|
||||
return (
|
||||
<NoResult
|
||||
minHeight="calc(100vh - 300px)"
|
||||
loading={dataSource.loading}
|
||||
loadend={dataSource.loadend}
|
||||
dataSource={dataSource.dataList}
|
||||
|
||||
@@ -51,11 +51,6 @@ export async function queryStorageClass(
|
||||
return request<Global.PageResponse<StorageClassItem>>(STORAGE_CLASS_API, {
|
||||
method: 'GET',
|
||||
params,
|
||||
// `headers` lets the create form pin the request to a specific org so
|
||||
// the picker only offers storage types that org can reference. GETs
|
||||
// otherwise inherit the ambient org context, which is empty in the
|
||||
// platform-admin "All" view and would list every org's types.
|
||||
headers: options?.headers,
|
||||
cancelToken: options?.token
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { PageActionType } from '@/config/types';
|
||||
import useSubmitLock from '@/hooks/use-submit-lock';
|
||||
import { FormDrawer, ModalFooter } from '@gpustack/core-ui';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { FormContext } from '../config/form-context';
|
||||
import { FormData, ListItem } from '../config/types';
|
||||
import GPUServiceStorageForm from '../forms';
|
||||
import useQueryStorageClass from '../services/use-query-storage-class';
|
||||
|
||||
type AddModalProps = {
|
||||
title: string;
|
||||
@@ -14,6 +13,7 @@ type AddModalProps = {
|
||||
onOk: (values: FormData) => void;
|
||||
data?: ListItem | null;
|
||||
onCancel: () => void;
|
||||
storageClassList: Global.BaseOption<string>[];
|
||||
};
|
||||
|
||||
const AddModal: React.FC<AddModalProps> = ({
|
||||
@@ -22,33 +22,11 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
open,
|
||||
onOk,
|
||||
data,
|
||||
onCancel
|
||||
onCancel,
|
||||
storageClassList
|
||||
}) => {
|
||||
const form = useRef<any>(null);
|
||||
const { loading, guard, run, release } = useSubmitLock();
|
||||
// The dropdown gets its own storage-type list, scoped to the org the
|
||||
// create-scope picker targets — distinct from the page-level list, which
|
||||
// stays unscoped so the table can label every org's rows.
|
||||
const { storageClassList, fetchData: fetchStorageClass } =
|
||||
useQueryStorageClass();
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchStorageClass({ page: -1 });
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Platform admin picked a target org in the create-scope slot: reload the
|
||||
// storage-type list pinned to that org so the dropdown only offers types
|
||||
// the org can reference (its own plus any reachable via cluster access).
|
||||
const handleScopeChange = (orgId?: number | null) => {
|
||||
fetchStorageClass(
|
||||
{ page: -1 },
|
||||
orgId != null
|
||||
? { headers: { 'X-Organization-Id': String(orgId) } }
|
||||
: undefined
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
guard(() => form.current?.submit());
|
||||
@@ -92,7 +70,6 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
ref={form}
|
||||
action={action}
|
||||
currentData={data}
|
||||
onScopeChange={handleScopeChange}
|
||||
onFinish={onFinish}
|
||||
onFinishFailed={release}
|
||||
open={open}
|
||||
|
||||
@@ -3,18 +3,21 @@ import { StatusType } from '@/config/types';
|
||||
import { icons } from '@gpustack/core-ui';
|
||||
|
||||
export const StoragePhaseValueMap = {
|
||||
Ready: 'Ready',
|
||||
Deleting: 'Deleting'
|
||||
Available: 'Available',
|
||||
Unavailable: 'Unavailable',
|
||||
Pending: 'Pending'
|
||||
};
|
||||
|
||||
export const StoragePhaseLabelMap: Record<string, string> = {
|
||||
[StoragePhaseValueMap.Ready]: 'Ready',
|
||||
[StoragePhaseValueMap.Deleting]: 'Deleting'
|
||||
[StoragePhaseValueMap.Available]: 'Available',
|
||||
[StoragePhaseValueMap.Unavailable]: 'Unavailable',
|
||||
[StoragePhaseValueMap.Pending]: 'Pending'
|
||||
};
|
||||
|
||||
export const status: Record<string, StatusType> = {
|
||||
[StoragePhaseValueMap.Ready]: StatusMaps.success,
|
||||
[StoragePhaseValueMap.Deleting]: StatusMaps.warning
|
||||
[StoragePhaseValueMap.Available]: StatusMaps.success,
|
||||
[StoragePhaseValueMap.Unavailable]: StatusMaps.error,
|
||||
[StoragePhaseValueMap.Pending]: StatusMaps.transitioning
|
||||
};
|
||||
|
||||
export const rowActionList = [
|
||||
|
||||
@@ -33,7 +33,6 @@ export interface ListItem {
|
||||
};
|
||||
status?: {
|
||||
phase?: string | null;
|
||||
phaseMessage?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,23 +12,7 @@ import { useContext } from 'react';
|
||||
import { FormContext } from '../config/form-context';
|
||||
import { FormData } from '../config/types';
|
||||
|
||||
const Basic = ({
|
||||
action,
|
||||
open,
|
||||
showOrgScope = true,
|
||||
onOrgScopeChange
|
||||
}: {
|
||||
action: string;
|
||||
open: boolean;
|
||||
// Hosts that already fix the tenant scope elsewhere (e.g. the instance
|
||||
// create form, which owns the org picker) hide this slot so the storage
|
||||
// is created in the surrounding scope rather than a second, conflicting
|
||||
// one.
|
||||
showOrgScope?: boolean;
|
||||
// Fired on a user selection in the create-scope picker so the host can
|
||||
// reload the org-scoped storage-type list.
|
||||
onOrgScopeChange?: (orgId: number | null | undefined) => void;
|
||||
}) => {
|
||||
const Basic = ({ action, open }: { action: string; open: boolean }) => {
|
||||
const intl = useIntl();
|
||||
const { getRuleMessage } = useAppUtils();
|
||||
const { storageClassList } = useContext(FormContext);
|
||||
@@ -60,12 +44,7 @@ const Basic = ({
|
||||
label={intl.formatMessage({ id: 'common.table.displayName' })}
|
||||
/>
|
||||
</Form.Item>
|
||||
{showOrgScope && (
|
||||
<PluginExtraFields
|
||||
name="CreateOrgScopeField"
|
||||
context={{ action, onChange: onOrgScopeChange }}
|
||||
/>
|
||||
)}
|
||||
<PluginExtraFields name="CreateOrgScopeField" context={{ action }} />
|
||||
<Flex gap={16}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Form.Item<FormData>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { PageAction } from '@/config';
|
||||
import { PageActionType } from '@/config/types';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { Form } from 'antd';
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
|
||||
import { forwardRef, useEffect, useImperativeHandle } from 'react';
|
||||
import { FormData, ListItem } from '../config/types';
|
||||
import Basic from './basic';
|
||||
|
||||
@@ -11,67 +10,14 @@ interface StorageFormProps {
|
||||
open: boolean;
|
||||
action: PageActionType;
|
||||
currentData?: ListItem | null;
|
||||
// Hide the create-scope org picker when the host already fixes the
|
||||
// tenant scope (e.g. the instance create form). Defaults to shown.
|
||||
showOrgScope?: boolean;
|
||||
// Fired when the create-scope picker (platform admin "All" view)
|
||||
// retargets the form to another org. Only emitted on genuine changes,
|
||||
// never on the initial mount, and never in builds where the picker
|
||||
// isn't mounted (the watched field stays undefined). Lets the parent
|
||||
// reload the org-scoped storage-type options.
|
||||
onScopeChange?: (orgId: number | null | undefined) => void;
|
||||
onFinish: (values: FormData) => Promise<void>;
|
||||
onFinishFailed?: (errorInfo: any) => void;
|
||||
}
|
||||
|
||||
const GPUServiceStorageForm: React.FC<StorageFormProps> = forwardRef(
|
||||
(props, ref) => {
|
||||
const {
|
||||
action,
|
||||
currentData,
|
||||
open,
|
||||
showOrgScope = true,
|
||||
onScopeChange,
|
||||
onFinish,
|
||||
onFinishFailed
|
||||
} = props;
|
||||
const { action, currentData, open, onFinish, onFinishFailed } = props;
|
||||
const [form] = Form.useForm<FormData>();
|
||||
// `organization_id` is owned by the create-scope picker slot; it only
|
||||
// exists/changes when a platform admin retargets the form. Watch it so
|
||||
// the initial/default scope can be propagated once the picker resolves it.
|
||||
const scopeOrgId = Form.useWatch('organization_id', form);
|
||||
const scopeInitRef = useRef(true);
|
||||
|
||||
// Stable wrapper so the effect / change handler always call the latest
|
||||
// callback without re-subscribing on the parent's fn identity.
|
||||
const orgScope = useMemoizedFn((orgId?: number | null) => {
|
||||
onScopeChange?.(orgId);
|
||||
});
|
||||
|
||||
// Genuine retarget by the create-scope picker: drop the stale type pick
|
||||
// (it may name a type the newly chosen org can't reference) and reload the
|
||||
// org-scoped list. Wired to the picker's own onChange, so it fires only on
|
||||
// a user selection — never on the picker's programmatic default.
|
||||
const handleOrgScopeChange = useMemoizedFn((orgId?: number | null) => {
|
||||
scopeInitRef.current = false;
|
||||
form.setFieldValue(['spec', 'type'], undefined);
|
||||
orgScope(orgId ?? null);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
// Re-arm the initial-scope propagation for the next open.
|
||||
scopeInitRef.current = true;
|
||||
return;
|
||||
}
|
||||
// Propagate the initial/default scope once the picker resolves it so the
|
||||
// parent loads the org-scoped type list, leaving any pre-filled type
|
||||
// intact. User-driven retargets go through handleOrgScopeChange instead.
|
||||
if (scopeInitRef.current && scopeOrgId != null) {
|
||||
scopeInitRef.current = false;
|
||||
orgScope(scopeOrgId);
|
||||
}
|
||||
}, [open, scopeOrgId, orgScope]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -110,12 +56,7 @@ const GPUServiceStorageForm: React.FC<StorageFormProps> = forwardRef(
|
||||
preserve={false}
|
||||
initialValues={{}}
|
||||
>
|
||||
<Basic
|
||||
action={action}
|
||||
open={open}
|
||||
showOrgScope={showOrgScope}
|
||||
onOrgScopeChange={handleOrgScopeChange}
|
||||
/>
|
||||
<Basic action={action} open={open} />
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import useCreatorColumn from '@/pages/gpu-service/hooks/use-creator-column';
|
||||
import { usePluginListColumns } from '@/plugins/list-extra-columns';
|
||||
import { AutoTooltip, DropdownButtons, StatusTag } from '@gpustack/core-ui';
|
||||
import { AutoTooltip, DropdownButtons } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import type { ColumnsType } from 'antd/lib/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { useMemo } from 'react';
|
||||
import { rowActionList, status, StoragePhaseLabelMap } from '../config';
|
||||
import { rowActionList } from '../config';
|
||||
import { ListItem } from '../config/types';
|
||||
|
||||
interface ColumnsHookProps {
|
||||
@@ -70,25 +70,24 @@ const useStorageColumns = ({
|
||||
sorter: false,
|
||||
render: (value: string) => (value ? value.replace(/Gi$/, 'GB') : '-')
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({ id: 'common.table.status' }),
|
||||
dataIndex: ['status', 'phase'],
|
||||
key: 'status',
|
||||
sorter: false,
|
||||
render: (value: string, record: ListItem) =>
|
||||
value ? (
|
||||
<StatusTag
|
||||
statusValue={{
|
||||
status: status[value],
|
||||
text: StoragePhaseLabelMap[value] || value,
|
||||
message: record?.status?.phaseMessage || ''
|
||||
}}
|
||||
></StatusTag>
|
||||
) : (
|
||||
'-'
|
||||
)
|
||||
},
|
||||
...creatorCols,
|
||||
// {
|
||||
// title: intl.formatMessage({ id: 'common.table.status' }),
|
||||
// dataIndex: ['status', 'phase'],
|
||||
// key: 'status',
|
||||
// sorter: false,
|
||||
// render: (value: string) =>
|
||||
// value ? (
|
||||
// <StatusTag
|
||||
// statusValue={{
|
||||
// status: status[value],
|
||||
// text: StoragePhaseLabelMap[value] || value
|
||||
// }}
|
||||
// ></StatusTag>
|
||||
// ) : (
|
||||
// '-'
|
||||
// )
|
||||
// },
|
||||
{
|
||||
title: intl.formatMessage({ id: 'common.table.createTime' }),
|
||||
dataIndex: 'created_at',
|
||||
|
||||
@@ -109,7 +109,6 @@ const GPUServiceStorage: React.FC = () => {
|
||||
if (type !== 'Table') return;
|
||||
return (
|
||||
<NoResult
|
||||
minHeight="calc(100vh - 300px)"
|
||||
loading={dataSource.loading}
|
||||
loadend={dataSource.loadend}
|
||||
dataSource={dataSource.dataList}
|
||||
@@ -178,6 +177,7 @@ const GPUServiceStorage: React.FC = () => {
|
||||
action={openStorageModalStatus.action}
|
||||
title={openStorageModalStatus.title}
|
||||
data={openStorageModalStatus.currentData}
|
||||
storageClassList={storageClassList}
|
||||
onCancel={closeStorageModal}
|
||||
onOk={handleModalOk}
|
||||
/>
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
TemplateCard,
|
||||
ThemeTag
|
||||
} from '@gpustack/core-ui';
|
||||
import { useAccess, useIntl } from '@umijs/max';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Button, Tag } from 'antd';
|
||||
import { useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
@@ -144,26 +144,8 @@ interface TemplateCardProps {
|
||||
|
||||
const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
|
||||
const intl = useIntl();
|
||||
const access = useAccess();
|
||||
const { isDarkTheme } = useUserSettings();
|
||||
|
||||
// Only an explicit NULL owner (Global, admin-curated) is admin-only.
|
||||
// A principal-owned row reaches a non-admin's list only when they own
|
||||
// it, and an absent id (single-owner builds omit it on the wire) has
|
||||
// no tenancy to restrict — both are manageable, so the check is strict
|
||||
// ``!== null``. Clone stays available regardless — it reads the source
|
||||
// and creates a fresh copy in the caller's own scope — so a non-admin
|
||||
// can fork a Global preset into their org.
|
||||
const canManage = !!access.canSeeAdmin || data.owner_principal_id !== null;
|
||||
|
||||
const actions = useMemo(
|
||||
() =>
|
||||
canManage
|
||||
? templateActions
|
||||
: templateActions.filter((action) => action.key === 'clone'),
|
||||
[canManage]
|
||||
);
|
||||
|
||||
const manufacturerLabelMap: Record<string, string> = useMemo(() => {
|
||||
return Object.values(GPUsConfigs).reduce(
|
||||
(acc, item) => {
|
||||
@@ -256,7 +238,7 @@ const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
|
||||
<span onClick={handleonClickAction} className="operations">
|
||||
<DropdownActions
|
||||
menu={{
|
||||
items: actions,
|
||||
items: templateActions,
|
||||
onClick: handleOnSelect
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -114,12 +114,6 @@ export const templateActions: Array<{
|
||||
locale: true,
|
||||
icon: icons.EditOutlined
|
||||
},
|
||||
{
|
||||
label: 'common.button.clone',
|
||||
key: 'clone',
|
||||
locale: true,
|
||||
icon: icons.CopyOutlined
|
||||
},
|
||||
{
|
||||
label: 'common.button.delete',
|
||||
key: 'delete',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PageAction } from '@/config';
|
||||
import { PageActionType } from '@/config/types';
|
||||
import { Form } from 'antd';
|
||||
import { forwardRef, useEffect, useImperativeHandle } from 'react';
|
||||
@@ -25,13 +26,11 @@ const GPUServiceTemplateForm: React.FC<TemplateFormProps> = forwardRef(
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefill on Edit and on Clone (Create carrying a source row).
|
||||
// A plain Create opens with no ``currentData`` and keeps the
|
||||
// blank ``initialValues``.
|
||||
if (currentData) {
|
||||
if (action === PageAction.EDIT && currentData) {
|
||||
form.setFieldsValue({
|
||||
...currentData
|
||||
});
|
||||
return;
|
||||
}
|
||||
}, [action, currentData, form, open]);
|
||||
|
||||
|
||||
@@ -45,11 +45,13 @@ const GPUServiceTemplates: React.FC = () => {
|
||||
isInfiniteScroll: true,
|
||||
contentForDelete: intl.formatMessage({ id: 'gpuservice.template' }),
|
||||
defaultQueryParams: {
|
||||
// Default (non-``mine``) scope: Global rows plus rows owned by the
|
||||
// caller's current principal, matching the Inference Backend page.
|
||||
// Global rows a non-admin can't edit render read-only — the card
|
||||
// gates Edit/Delete on ownership.
|
||||
perPage: 24
|
||||
perPage: 24,
|
||||
// Management view: drop Global rows for non-admin callers — the
|
||||
// page is a CRUD surface, and admin-curated Global templates
|
||||
// they can't edit only add visual noise. The instance-create
|
||||
// picker (uses ``useQueryTemplates`` separately) doesn't set
|
||||
// this and so still sees Global presets.
|
||||
mine: true
|
||||
}
|
||||
});
|
||||
const { openTemplateModalStatus, openTemplateModal, closeTemplateModal } =
|
||||
@@ -90,42 +92,6 @@ const GPUServiceTemplates: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const handleCloneTemplate = (row: ListItem) => {
|
||||
// Clone reuses the create flow: drop the source's identity and
|
||||
// ownership so the backend assigns a fresh id and scopes the copy
|
||||
// to the caller's own principal. The prefilled name is editable —
|
||||
// the create endpoint enforces per-owner name uniqueness.
|
||||
const source = _.omit(row, [
|
||||
'id',
|
||||
'owner_principal_id',
|
||||
'creator_id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'status'
|
||||
]) as ListItem;
|
||||
// Default to a ``-clone`` suffix so cloning within the same scope
|
||||
// (e.g. a Global preset kept Global) doesn't immediately collide on
|
||||
// the unique name. Keep within the 63-char name limit by trimming
|
||||
// the base first.
|
||||
const suffix = '-clone';
|
||||
const base = (row.name ?? '').slice(0, 63 - suffix.length);
|
||||
source.name = `${base}${suffix}`;
|
||||
// The card renders ``displayName || name``, so a copied displayName
|
||||
// would make the clone indistinguishable from its source. Append a
|
||||
// localized clone label, trimmed to the same 63-char field limit.
|
||||
if (row.displayName) {
|
||||
const cloneLabel = intl.formatMessage({ id: 'common.button.clone' });
|
||||
const displaySuffix = ` (${cloneLabel})`;
|
||||
const displayBase = row.displayName.slice(0, 63 - displaySuffix.length);
|
||||
source.displayName = `${displayBase}${displaySuffix}`;
|
||||
}
|
||||
openTemplateModal(
|
||||
PageAction.CREATE,
|
||||
intl.formatMessage({ id: 'gpuservice.template.clone' }),
|
||||
source
|
||||
);
|
||||
};
|
||||
|
||||
const handleModalOk = async (data: FormData) => {
|
||||
try {
|
||||
if (openTemplateModalStatus.action === PageAction.EDIT) {
|
||||
@@ -148,10 +114,6 @@ const GPUServiceTemplates: React.FC = () => {
|
||||
handleEditTemplate(item.data);
|
||||
return;
|
||||
}
|
||||
if (item.action === 'clone') {
|
||||
handleCloneTemplate(item.data);
|
||||
return;
|
||||
}
|
||||
if (item.action === 'delete') {
|
||||
handleDelete({ ...item.data, name: item.data.name });
|
||||
}
|
||||
@@ -210,7 +172,6 @@ const GPUServiceTemplates: React.FC = () => {
|
||||
onSelect={handleOnSelect}
|
||||
/>
|
||||
<NoResult
|
||||
minHeight="calc(100vh - 300px)"
|
||||
loading={dataSource.loading}
|
||||
loadend={dataSource.loadend}
|
||||
dataSource={dataSource.dataList}
|
||||
|
||||
@@ -166,7 +166,6 @@ const Catalog: React.FC = () => {
|
||||
isFirst={!dataSource.loadend}
|
||||
></CatalogList>
|
||||
<NoResult
|
||||
minHeight="calc(100vh - 300px)"
|
||||
loading={dataSource.loading}
|
||||
loadend={dataSource.loadend}
|
||||
dataSource={dataSource.dataList}
|
||||
|
||||
@@ -25,7 +25,10 @@ const Metric: React.FC<{ label: React.ReactNode; value: React.ReactNode }> = ({
|
||||
value
|
||||
}) => (
|
||||
<span style={{ lineHeight: 1.2 }}>
|
||||
{label}: <span>{value}</span>
|
||||
{label}:{' '}
|
||||
<span className="font-500" style={{ color: 'var(--ant-color-text)' }}>
|
||||
{value}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -67,7 +70,7 @@ const GPUCard: React.FC<{
|
||||
}
|
||||
description={
|
||||
info || (
|
||||
<Flex wrap gap={8} style={{ fontSize: 11 }}>
|
||||
<Flex wrap gap={8} style={{ fontSize: 13 }}>
|
||||
<Metric
|
||||
label={intl.formatMessage({ id: 'resources.table.total' })}
|
||||
value={convertFileSize(data?.memory?.total || 0)}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
IconFont,
|
||||
StatusTag,
|
||||
TagsWrapper,
|
||||
TemplateCard,
|
||||
@@ -45,7 +44,7 @@ const ModelItemContent = styled.div`
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
cursor: default;
|
||||
.content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -86,8 +85,8 @@ const ModelItemContent = styled.div`
|
||||
`;
|
||||
|
||||
const ModelLogo = styled.img`
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
object-fit: contain;
|
||||
flex: none;
|
||||
@@ -128,23 +127,15 @@ const renderTag = (item: any, index = 0) => {
|
||||
|
||||
const ModelItem: React.FC<{
|
||||
model: Record<string, any>;
|
||||
onClick?: (model: Record<string, any>) => void;
|
||||
}> = (props) => {
|
||||
const { model, onClick } = props;
|
||||
const { model } = props;
|
||||
const intl = useIntl();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleCardClick = () => {
|
||||
onClick?.(model);
|
||||
};
|
||||
|
||||
// ``model.name`` from ``/v2/my-models`` is the OpenAI-style id
|
||||
// (org-prefixed for non-platform routes, bare for platform). Use it
|
||||
// verbatim — the playground / dispatcher both key off that exact id.
|
||||
const handleOpenPlayGroundClick = (e: React.MouseEvent) => {
|
||||
// Card is clickable (opens API access info); keep the playground
|
||||
// action isolated so it doesn't also trigger the card click.
|
||||
e.stopPropagation();
|
||||
const handleOpenPlayGroundClick = () => {
|
||||
const modelName = encodeURIComponent(model.name);
|
||||
for (const [category, path] of Object.entries(categoryToPathMap)) {
|
||||
if (
|
||||
@@ -187,13 +178,12 @@ const ModelItem: React.FC<{
|
||||
<CardWrapper>
|
||||
<TemplateCard
|
||||
height={140}
|
||||
clickable={true}
|
||||
clickable={false}
|
||||
hoverable={true}
|
||||
ghost
|
||||
onClick={handleCardClick}
|
||||
header={
|
||||
<Header>
|
||||
<span className="text gap-16">
|
||||
<span className="text gap-8">
|
||||
<ModelLogo src={getModelLogo(model.name)} alt="" />
|
||||
<span>{model.name}</span>
|
||||
</span>
|
||||
@@ -249,23 +239,14 @@ const ModelItem: React.FC<{
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{[MyModelsStatusValueMap.Ready].includes(model.status) && (
|
||||
{[MyModelsStatusValueMap.Active].includes(model.status) && (
|
||||
<Button
|
||||
size="middle"
|
||||
type="default"
|
||||
className="btn"
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
paddingInline: 12
|
||||
}}
|
||||
type="primary"
|
||||
onClick={handleOpenPlayGroundClick}
|
||||
>
|
||||
{intl.formatMessage({ id: 'models.openinplayground' })}
|
||||
<IconFont
|
||||
type="icon-down2"
|
||||
rotate={-90}
|
||||
style={{ marginLeft: 4 }}
|
||||
></IconFont>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,9 @@ import { PageActionType } from '@/config/types';
|
||||
import useBodyScroll from '@/hooks/use-body-scroll';
|
||||
import useExpandedRowKeys from '@/hooks/use-expanded-row-keys';
|
||||
import useTableRowSelection from '@/hooks/use-table-row-selection';
|
||||
import useWatchList from '@/hooks/use-watch-list';
|
||||
import useNoResourceResult from '@/pages/llmodels/hooks/use-no-resource-result';
|
||||
import { MODEL_ROUTE_TARGETS } from '@/pages/model-routes/apis';
|
||||
import { TargetStatusValueMap } from '@/pages/model-routes/config';
|
||||
import useOpenPlayground from '@/pages/model-routes/hooks/use-open-playground';
|
||||
import useGranfanaLink from '@/pages/resources/hooks/use-grafana-link';
|
||||
@@ -90,7 +92,6 @@ interface ModelsProps {
|
||||
loadend: boolean;
|
||||
total: number;
|
||||
filterValues?: Record<string, any>;
|
||||
targetList?: any[];
|
||||
}
|
||||
|
||||
const getFormattedData = (record: any, extraData = {}) => ({
|
||||
@@ -128,8 +129,7 @@ const Models: React.FC<ModelsProps> = ({
|
||||
queryParams,
|
||||
loading,
|
||||
loadend,
|
||||
total,
|
||||
targetList = []
|
||||
total
|
||||
}) => {
|
||||
const { generateFormValues, clusterList, workerList } =
|
||||
useDeploymentsContext();
|
||||
@@ -155,6 +155,7 @@ const Models: React.FC<ModelsProps> = ({
|
||||
expandedRowKeys
|
||||
} = useExpandedRowKeys(expandAtom);
|
||||
const { handleOpenPlayGround } = useOpenPlayground();
|
||||
const { watchDataList: targetList } = useWatchList(MODEL_ROUTE_TARGETS);
|
||||
const { openViewLogsModal, openViewLogsModalStatus, closeViewLogsModal } =
|
||||
useViewInstanceLogs();
|
||||
|
||||
|
||||
@@ -196,21 +196,21 @@ export const status: any = {
|
||||
};
|
||||
|
||||
export const MyModelsStatusValueMap = {
|
||||
Stopped: 'stopped',
|
||||
NotReady: 'not_ready',
|
||||
Ready: 'ready'
|
||||
Inactive: 'stopped',
|
||||
Degrade: 'not_ready',
|
||||
Active: 'ready'
|
||||
};
|
||||
|
||||
export const MyModelsStatusMap = {
|
||||
[MyModelsStatusValueMap.Stopped]: StatusMaps.inactive,
|
||||
[MyModelsStatusValueMap.NotReady]: StatusMaps.error,
|
||||
[MyModelsStatusValueMap.Ready]: StatusMaps.success
|
||||
[MyModelsStatusValueMap.Inactive]: StatusMaps.inactive,
|
||||
[MyModelsStatusValueMap.Degrade]: StatusMaps.error,
|
||||
[MyModelsStatusValueMap.Active]: StatusMaps.success
|
||||
};
|
||||
|
||||
export const MyModelsStatusLabelMap = {
|
||||
[MyModelsStatusValueMap.Stopped]: 'models.mymodels.status.inactive',
|
||||
[MyModelsStatusValueMap.NotReady]: 'models.mymodels.status.degrade',
|
||||
[MyModelsStatusValueMap.Ready]: 'models.mymodels.status.active'
|
||||
[MyModelsStatusValueMap.Inactive]: 'models.mymodels.status.inactive',
|
||||
[MyModelsStatusValueMap.Degrade]: 'models.mymodels.status.degrade',
|
||||
[MyModelsStatusValueMap.Active]: 'models.mymodels.status.active'
|
||||
};
|
||||
|
||||
export const ScheduleValueMap = {
|
||||
|
||||
@@ -3,8 +3,6 @@ import useSetChunkRequest from '@/hooks/use-chunk-request';
|
||||
import { usePaginationStatus } from '@/hooks/use-pagination-status';
|
||||
import { useTableMultiSort } from '@/hooks/use-table-sort';
|
||||
import useUpdateChunkedList from '@/hooks/use-update-chunk-list';
|
||||
import useWatchList from '@/hooks/use-watch-list';
|
||||
import { MODEL_ROUTE_TARGETS } from '@/pages/model-routes/apis';
|
||||
import { TableOrder, TableProvider } from '@gpustack/core-ui';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import _ from 'lodash';
|
||||
@@ -33,11 +31,6 @@ const Models = forwardRef((props, ref) => {
|
||||
const { setChunkRequest, createAxiosToken } = useSetChunkRequest();
|
||||
const { setChunkRequest: setModelInstanceChunkRequest } =
|
||||
useSetChunkRequest();
|
||||
const {
|
||||
watchDataList: targetList,
|
||||
startWatch: startTargetsWatch,
|
||||
cancelWatch: cancelTargetsWatch
|
||||
} = useWatchList(MODEL_ROUTE_TARGETS);
|
||||
const [modelInstances, setModelInstances] = useState<any[]>([]);
|
||||
const [dataSource, setDataSource] = useState<{
|
||||
dataList: ListItem[];
|
||||
@@ -262,7 +255,6 @@ const Models = forwardRef((props, ref) => {
|
||||
cacheInsDataListRef.current = [];
|
||||
chunkInstanceRequedtRef.current?.current?.cancel?.();
|
||||
instancesToken.current?.cancel?.();
|
||||
cancelTargetsWatch();
|
||||
});
|
||||
|
||||
const resumeRequestsOnPageActive = useMemoizedFn(async () => {
|
||||
@@ -273,7 +265,6 @@ const Models = forwardRef((props, ref) => {
|
||||
await getAllModelInstances();
|
||||
await createModelsInstanceChunkRequest();
|
||||
await createModelsChunkRequest();
|
||||
await startTargetsWatch();
|
||||
});
|
||||
|
||||
const handleOnCancelViewLogs = useMemoizedFn(async () => {
|
||||
@@ -492,7 +483,6 @@ const Models = forwardRef((props, ref) => {
|
||||
total={dataSource.total}
|
||||
deleteIds={dataSource.deletedIds}
|
||||
filterValues={filterValues}
|
||||
targetList={targetList}
|
||||
></TableList>
|
||||
</TableProvider>
|
||||
);
|
||||
|
||||
@@ -85,7 +85,6 @@ interface BasicFormProps {
|
||||
}
|
||||
>[];
|
||||
handleClusterChange: (value: number) => void;
|
||||
onClusterSeed: (value: number) => void;
|
||||
onSourceChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
@@ -95,7 +94,6 @@ const BasicForm: React.FC<BasicFormProps> = (props) => {
|
||||
clusterList,
|
||||
sourceDisable,
|
||||
handleClusterChange,
|
||||
onClusterSeed,
|
||||
onSourceChange
|
||||
} = props;
|
||||
const intl = useIntl();
|
||||
@@ -150,8 +148,6 @@ const BasicForm: React.FC<BasicFormProps> = (props) => {
|
||||
// selection and fall back to the scope's default cluster (then a Ready one,
|
||||
// then the first) so GPU/backend options refetch for it. A selection that's
|
||||
// still valid is left untouched, so a user's (or edit's) choice is kept.
|
||||
// Use the seed callback (not handleClusterChange) so this auto-pick refreshes
|
||||
// options without firing an evaluate request before a model is selected.
|
||||
useEffect(() => {
|
||||
if (!clusterOptions?.length) {
|
||||
return;
|
||||
@@ -171,8 +167,8 @@ const BasicForm: React.FC<BasicFormProps> = (props) => {
|
||||
return;
|
||||
}
|
||||
form.setFieldValue('cluster_id', next);
|
||||
onClusterSeed?.(next);
|
||||
}, [clusterOptions, form, onClusterSeed]);
|
||||
handleClusterChange?.(next);
|
||||
}, [clusterOptions, form, handleClusterChange]);
|
||||
|
||||
const clusterOptionRender = (option: any) => {
|
||||
const { data } = option;
|
||||
|
||||
@@ -236,35 +236,20 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
|
||||
onOk(allValues);
|
||||
};
|
||||
|
||||
// Shared work when the target cluster changes: refetch the GPU/backend
|
||||
// options for the new cluster and reset schedule/gpu selection.
|
||||
const applyClusterScopedOptions = (value: number) => {
|
||||
const handleClusterChange = async (value: number) => {
|
||||
await onClusterChange?.(value);
|
||||
getGPUOptionList({ clusterId: value });
|
||||
getBackendOptions({ cluster_id: value });
|
||||
form.setFieldsValue({
|
||||
scheduleType: ScheduleValueMap.Auto,
|
||||
gpu_selector: null
|
||||
});
|
||||
};
|
||||
|
||||
// User explicitly picked a cluster: refresh scoped options and re-evaluate.
|
||||
const handleClusterChange = async (value: number) => {
|
||||
await onClusterChange?.(value);
|
||||
applyClusterScopedOptions(value);
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 150);
|
||||
});
|
||||
onValuesChange?.({}, form.getFieldsValue());
|
||||
};
|
||||
|
||||
// The basic form seeds a default cluster on open, before a model is picked.
|
||||
// Refresh scoped options for it but don't fire the evaluate request — there
|
||||
// is no model to evaluate yet.
|
||||
const handleClusterSeed = async (value: number) => {
|
||||
await onClusterChange?.(value);
|
||||
applyClusterScopedOptions(value);
|
||||
};
|
||||
|
||||
const getFieldPaths = (obj: Record<string, any>, prefix = ''): string => {
|
||||
const result = Object.entries(obj).flatMap(([key, value]) => {
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
@@ -482,7 +467,6 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
|
||||
clusterList={clusterList}
|
||||
sourceDisable={sourceDisable}
|
||||
handleClusterChange={handleClusterChange}
|
||||
onClusterSeed={handleClusterSeed}
|
||||
onSourceChange={onSourceChange}
|
||||
></BasicForm>
|
||||
<CollapsePanel
|
||||
|
||||
@@ -401,24 +401,6 @@ export const useCheckCompatibility = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Evaluation needs a model reference. The basic form seeds a default cluster
|
||||
// on open, which can fire onValuesChange before the user has picked a model —
|
||||
// skip evaluation until the current source's model field is filled.
|
||||
const noModelSelected = (allValues: any) => {
|
||||
switch (allValues.source) {
|
||||
case modelSourceMap.huggingface_value:
|
||||
return !allValues.huggingface_repo_id;
|
||||
case modelSourceMap.modelscope_value:
|
||||
return !allValues.model_scope_model_id;
|
||||
case modelSourceMap.ollama_library_value:
|
||||
return !allValues.ollama_library_model_name;
|
||||
case modelSourceMap.local_path_value:
|
||||
return !allValues.local_path;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleOnValuesChange = async (params: {
|
||||
changedValues: any;
|
||||
allValues: any;
|
||||
@@ -428,7 +410,6 @@ export const useCheckCompatibility = () => {
|
||||
if (
|
||||
_.isEqual(cacheFormValuesRef.current, allValues) ||
|
||||
noLocalPathValue(allValues) ||
|
||||
noModelSelected(allValues) ||
|
||||
!allValues.replicas
|
||||
) {
|
||||
console.log('No changes detected, skipping evaluation.');
|
||||
|
||||
@@ -24,7 +24,7 @@ const useFilterStatus = (options?: {
|
||||
|
||||
const statusOptions = [
|
||||
{
|
||||
value: MyModelsStatusValueMap.Ready,
|
||||
value: MyModelsStatusValueMap.Active,
|
||||
color: 'var(--ant-color-success)',
|
||||
icon: <Dot color="var(--ant-color-success)"></Dot>,
|
||||
label: intl.formatMessage({
|
||||
@@ -32,7 +32,7 @@ const useFilterStatus = (options?: {
|
||||
})
|
||||
},
|
||||
{
|
||||
value: MyModelsStatusValueMap.Stopped,
|
||||
value: MyModelsStatusValueMap.Inactive,
|
||||
color: 'var(--ant-color-fill-secondary)',
|
||||
icon: <Dot color="var(--ant-color-fill-secondary)"></Dot>,
|
||||
label: intl.formatMessage({
|
||||
@@ -40,7 +40,7 @@ const useFilterStatus = (options?: {
|
||||
})
|
||||
},
|
||||
{
|
||||
value: MyModelsStatusValueMap.NotReady,
|
||||
value: MyModelsStatusValueMap.Degrade,
|
||||
color: 'var(--ant-color-warning)',
|
||||
icon: <Dot color="var(--ant-color-warning)"></Dot>,
|
||||
label: intl.formatMessage({
|
||||
|
||||
@@ -199,13 +199,7 @@ export const useGenerateWorkerOptions = () => {
|
||||
page: -1
|
||||
}),
|
||||
queryClusterList({
|
||||
page: -1,
|
||||
// Own-org clusters only. Feeds the deploy-from-model-file cluster
|
||||
// picker, which must not offer another org's cluster (e.g. the
|
||||
// Default org's "shared with everyone" clusters). The worker
|
||||
// cascader on this page is already own-org via the owner-scoped
|
||||
// worker list.
|
||||
mine: true
|
||||
page: -1
|
||||
})
|
||||
]);
|
||||
const workerList = workerRes.items || ([] as WorkerListItem[]);
|
||||
@@ -278,13 +272,7 @@ export default function useFormInitialValues() {
|
||||
// Exclude clusters that opt in to GPU-instance handling
|
||||
// (k8s_options.gpu_instance_options set) — those are for the
|
||||
// GPU-service flow, not model deployment.
|
||||
gpu_instance_enabled: false,
|
||||
// Only clusters owned by the current org. Drops cross-org grants
|
||||
// (e.g. the Default org's "shared with everyone" clusters) so a
|
||||
// tenant can't deploy onto another org's infrastructure. Platform
|
||||
// admin in the "All" view bypasses this and is scoped instead by
|
||||
// the org picker (see basic.tsx).
|
||||
mine: true
|
||||
gpu_instance_enabled: false
|
||||
});
|
||||
const list = response.items.map((item) => ({
|
||||
label: item.name,
|
||||
|
||||
@@ -95,7 +95,6 @@ const useNoResourceResult = (props: {
|
||||
|
||||
const noResourceResult = (
|
||||
<NoResult
|
||||
minHeight="calc(100vh - 300px)"
|
||||
loading={loading}
|
||||
loadend={loadend}
|
||||
dataSource={dataSource}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
const useViewApIInfo = () => {
|
||||
const [apiAccessInfo, setAPIAccessInfo] = useState<any>({
|
||||
show: false,
|
||||
data: {}
|
||||
});
|
||||
|
||||
const openViewAPIInfo = (row: any) => {
|
||||
setAPIAccessInfo({
|
||||
show: true,
|
||||
data: row
|
||||
});
|
||||
};
|
||||
|
||||
const closeViewAPIInfo = () => {
|
||||
setAPIAccessInfo({
|
||||
show: false,
|
||||
data: {}
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
apiAccessInfo,
|
||||
openViewAPIInfo,
|
||||
closeViewAPIInfo
|
||||
};
|
||||
};
|
||||
|
||||
export default useViewApIInfo;
|
||||
@@ -31,16 +31,6 @@ const filterOptions = {
|
||||
label: 'Error',
|
||||
value: 'error',
|
||||
color: 'var(--ant-color-error)'
|
||||
},
|
||||
{
|
||||
label: 'Pending',
|
||||
value: 'pending',
|
||||
color: 'var(--ant-color-info)'
|
||||
},
|
||||
{
|
||||
label: 'Starting',
|
||||
value: 'starting',
|
||||
color: 'var(--ant-color-info)'
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -109,7 +99,6 @@ const InstanceView = forwardRef((props, ref) => {
|
||||
if (type !== 'Table') return;
|
||||
return (
|
||||
<NoResult
|
||||
minHeight="calc(100vh - 300px)"
|
||||
loading={dataSource.loading}
|
||||
loadend={dataSource.loadend}
|
||||
dataSource={dataSource.dataList}
|
||||
|
||||
@@ -2,22 +2,20 @@ 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 { useAccess, useIntl, useNavigate } from '@umijs/max';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import useMemoizedFn from 'ahooks/lib/useMemoizedFn';
|
||||
import { Button, Input, Space } from 'antd';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, 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 (
|
||||
<span
|
||||
@@ -55,56 +53,29 @@ const UserModels: React.FC = () => {
|
||||
watch: false,
|
||||
isInfiniteScroll: true,
|
||||
defaultQueryParams: {
|
||||
perPage: 24,
|
||||
state: MyModelsStatusValueMap.Ready
|
||||
perPage: 24
|
||||
}
|
||||
});
|
||||
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();
|
||||
|
||||
// Managers start in a loading state so the empty state waits for the
|
||||
// infra queries to resolve — otherwise the initially-empty
|
||||
// cluster/worker lists briefly flash the "no clusters" / "no workers"
|
||||
// guidance before the real data lands.
|
||||
const [infraLoading, setInfraLoading] = useState(!!canManageResources);
|
||||
|
||||
useEffect(() => {
|
||||
if (canManageResources) {
|
||||
setInfraLoading(true);
|
||||
Promise.all([getClusterList(), getWorkerList()]).finally(() => {
|
||||
setInfraLoading(false);
|
||||
});
|
||||
}
|
||||
}, [canManageResources]);
|
||||
|
||||
const statusOptions = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
value: MyModelsStatusValueMap.Ready,
|
||||
value: MyModelsStatusValueMap.Active,
|
||||
color: 'var(--ant-color-success)',
|
||||
label: intl.formatMessage({
|
||||
id: 'models.mymodels.status.active'
|
||||
})
|
||||
},
|
||||
{
|
||||
value: MyModelsStatusValueMap.Stopped,
|
||||
value: MyModelsStatusValueMap.Inactive,
|
||||
color: 'var(--ant-color-fill)',
|
||||
label: intl.formatMessage({
|
||||
id: 'models.mymodels.status.inactive'
|
||||
})
|
||||
},
|
||||
{
|
||||
value: MyModelsStatusValueMap.NotReady,
|
||||
value: MyModelsStatusValueMap.Degrade,
|
||||
color: 'var(--ant-color-warning)',
|
||||
label: intl.formatMessage({
|
||||
id: 'models.mymodels.status.degrade'
|
||||
@@ -120,7 +91,7 @@ const UserModels: React.FC = () => {
|
||||
};
|
||||
|
||||
const renderCard = (data: any) => {
|
||||
return <ModelItem model={data} onClick={openViewAPIInfo} />;
|
||||
return <ModelItem model={data} />;
|
||||
};
|
||||
|
||||
const loadMore = useMemoizedFn((nextPage: number) => {
|
||||
@@ -151,17 +122,17 @@ const UserModels: React.FC = () => {
|
||||
|
||||
const getStatus = useCallback((model: any) => {
|
||||
if (!model.targets && !model.ready_targets) {
|
||||
return MyModelsStatusValueMap.Stopped;
|
||||
return MyModelsStatusValueMap.Inactive;
|
||||
}
|
||||
|
||||
if (model.targets > 0 && !model.ready_targets) {
|
||||
return MyModelsStatusValueMap.NotReady;
|
||||
return MyModelsStatusValueMap.Degrade;
|
||||
}
|
||||
|
||||
if (model.ready_targets > 0 && model.targets > 0) {
|
||||
return MyModelsStatusValueMap.Ready;
|
||||
return MyModelsStatusValueMap.Active;
|
||||
}
|
||||
return MyModelsStatusValueMap.NotReady;
|
||||
return MyModelsStatusValueMap.Degrade;
|
||||
}, []);
|
||||
|
||||
const dataList = useMemo(() => {
|
||||
@@ -180,118 +151,87 @@ const UserModels: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const { noResourceResult } = useNoResourceResult({
|
||||
// Hold the empty state until infra queries resolve so managers don't
|
||||
// see a "no clusters/workers" flash before the real data arrives.
|
||||
loading: dataSource.loading || infraLoading,
|
||||
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 (
|
||||
<>
|
||||
<PageBox>
|
||||
<PageTools
|
||||
marginBottom={22}
|
||||
marginTop={0}
|
||||
left={
|
||||
<Space>
|
||||
<Input
|
||||
placeholder={intl.formatMessage({ id: 'common.filter.name' })}
|
||||
style={{ width: 230 }}
|
||||
size="large"
|
||||
allowClear
|
||||
onClear={() =>
|
||||
handleNameChange({
|
||||
target: {
|
||||
value: ''
|
||||
}
|
||||
})
|
||||
}
|
||||
onChange={handleNameChange}
|
||||
></Input>
|
||||
<BaseSelect
|
||||
allowClear
|
||||
showSearch={false}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'models.filter.category'
|
||||
})}
|
||||
style={{ width: 180 }}
|
||||
size="large"
|
||||
maxTagCount={1}
|
||||
options={categoryOptions}
|
||||
onChange={handleCategoryChange}
|
||||
></BaseSelect>
|
||||
<BaseSelect
|
||||
allowClear
|
||||
showSearch={false}
|
||||
placeholder={intl.formatMessage({ id: 'common.filter.status' })}
|
||||
style={{ width: 180 }}
|
||||
size="large"
|
||||
maxTagCount={1}
|
||||
optionRender={optionRender}
|
||||
labelRender={labelRender}
|
||||
options={statusOptions}
|
||||
value={queryParams.state}
|
||||
onChange={handleStatusChange}
|
||||
></BaseSelect>
|
||||
<Button
|
||||
type="text"
|
||||
style={{ color: 'var(--ant-color-text-tertiary)' }}
|
||||
icon={<SyncOutlined></SyncOutlined>}
|
||||
onClick={handleRefresh}
|
||||
></Button>
|
||||
</Space>
|
||||
}
|
||||
></PageTools>
|
||||
<InfiniteScrollerProvider
|
||||
value={{
|
||||
total: dataSource.totalPage,
|
||||
current: queryParams.page,
|
||||
loading: dataSource.loading,
|
||||
refresh: loadMore
|
||||
}}
|
||||
>
|
||||
<TemplateCardList
|
||||
dataList={dataList}
|
||||
loading={dataSource.loading}
|
||||
activeId={false}
|
||||
isFirst={!dataSource.loadend}
|
||||
renderItem={renderCard}
|
||||
></TemplateCardList>
|
||||
{noResourceResult}
|
||||
</InfiniteScrollerProvider>
|
||||
</PageBox>
|
||||
<APIAccessInfoModal
|
||||
open={apiAccessInfo.show}
|
||||
data={apiAccessInfo.data}
|
||||
onClose={closeViewAPIInfo}
|
||||
></APIAccessInfoModal>
|
||||
</>
|
||||
<PageBox>
|
||||
<PageTools
|
||||
marginBottom={22}
|
||||
marginTop={0}
|
||||
left={
|
||||
<Space>
|
||||
<Input
|
||||
placeholder={intl.formatMessage({ id: 'common.filter.name' })}
|
||||
style={{ width: 230 }}
|
||||
size="large"
|
||||
allowClear
|
||||
onClear={() =>
|
||||
handleNameChange({
|
||||
target: {
|
||||
value: ''
|
||||
}
|
||||
})
|
||||
}
|
||||
onChange={handleNameChange}
|
||||
></Input>
|
||||
<BaseSelect
|
||||
allowClear
|
||||
showSearch={false}
|
||||
placeholder={intl.formatMessage({ id: 'models.filter.category' })}
|
||||
style={{ width: 180 }}
|
||||
size="large"
|
||||
maxTagCount={1}
|
||||
options={categoryOptions}
|
||||
onChange={handleCategoryChange}
|
||||
></BaseSelect>
|
||||
<BaseSelect
|
||||
allowClear
|
||||
showSearch={false}
|
||||
placeholder={intl.formatMessage({ id: 'common.filter.status' })}
|
||||
style={{ width: 180 }}
|
||||
size="large"
|
||||
maxTagCount={1}
|
||||
optionRender={optionRender}
|
||||
labelRender={labelRender}
|
||||
options={statusOptions}
|
||||
onChange={handleStatusChange}
|
||||
></BaseSelect>
|
||||
<Button
|
||||
type="text"
|
||||
style={{ color: 'var(--ant-color-text-tertiary)' }}
|
||||
icon={<SyncOutlined></SyncOutlined>}
|
||||
onClick={handleRefresh}
|
||||
></Button>
|
||||
</Space>
|
||||
}
|
||||
></PageTools>
|
||||
<InfiniteScrollerProvider
|
||||
value={{
|
||||
total: dataSource.totalPage,
|
||||
current: queryParams.page,
|
||||
loading: dataSource.loading,
|
||||
refresh: loadMore
|
||||
}}
|
||||
>
|
||||
<TemplateCardList
|
||||
dataList={dataList}
|
||||
loading={dataSource.loading}
|
||||
activeId={false}
|
||||
isFirst={!dataSource.loadend}
|
||||
renderItem={renderCard}
|
||||
></TemplateCardList>
|
||||
<NoResult
|
||||
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>
|
||||
</PageBox>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ const MODEL_ICON_RULES: [string, string][] = [
|
||||
['gemma', 'google'],
|
||||
['command-r', 'cohere'],
|
||||
['cohere', 'cohere'],
|
||||
['glm', 'zai'],
|
||||
['glm', 'thudm'],
|
||||
['yi-', '01ai'],
|
||||
['ernie', 'ernie'],
|
||||
['hunyuan', 'hunyuan'],
|
||||
|
||||
@@ -131,6 +131,8 @@ const Login = () => {
|
||||
[shouldUseCustomLogin, userSettings]
|
||||
);
|
||||
|
||||
console.log('useCustomLogin', useCustomLogin, userSettings);
|
||||
|
||||
const gotoDefaultPage = async (info: any) => {
|
||||
if (!info || info?.require_password_change) {
|
||||
return;
|
||||
|
||||
@@ -121,7 +121,6 @@ const MaasProvider: React.FC = () => {
|
||||
if (type !== 'Table') return;
|
||||
return (
|
||||
<NoResult
|
||||
minHeight="calc(100vh - 300px)"
|
||||
loading={dataSource.loading}
|
||||
loadend={dataSource.loadend}
|
||||
dataSource={dataSource.dataList}
|
||||
|
||||
@@ -381,7 +381,6 @@ const ModelRoutes: React.FC = () => {
|
||||
expandable={true}
|
||||
empty={
|
||||
<NoResult
|
||||
minHeight="calc(100vh - 300px)"
|
||||
loading={dataSource.loading}
|
||||
loadend={dataSource.loadend}
|
||||
dataSource={dataSource.dataList}
|
||||
|
||||
@@ -72,7 +72,6 @@ const GPUList: React.FC<GPUListProps> = ({ clusterId, source }) => {
|
||||
if (type !== 'Table') return;
|
||||
return (
|
||||
<NoResult
|
||||
minHeight="calc(100vh - 300px)"
|
||||
loading={dataSource.loading}
|
||||
loadend={dataSource.loadend}
|
||||
dataSource={dataSource.dataList}
|
||||
|
||||
@@ -233,7 +233,6 @@ const ModelFiles = () => {
|
||||
if (type !== 'Table') return;
|
||||
return (
|
||||
<NoResult
|
||||
minHeight="calc(100vh - 300px)"
|
||||
loading={dataSource.loading}
|
||||
loadend={dataSource.loadend}
|
||||
dataSource={dataSource.dataList}
|
||||
|
||||
@@ -103,14 +103,8 @@ const Workers: React.FC<WorkersProps> = ({ clusterId, source }) => {
|
||||
|
||||
const getClusterList = async () => {
|
||||
try {
|
||||
// Own-org clusters only (mine=true). A worker can only join a cluster
|
||||
// its org owns, so another org's cluster (e.g. the Default org's
|
||||
// "shared with everyone" clusters) must not be offered in the picker.
|
||||
// The worker list is owner-scoped too, so this list also covers every
|
||||
// cluster the table's name column can reference.
|
||||
const params = {
|
||||
page: -1,
|
||||
mine: true
|
||||
page: -1
|
||||
};
|
||||
const items = await fetchClusterList(params);
|
||||
const clusterMap = items?.reduce(
|
||||
|
||||
@@ -10,8 +10,7 @@
|
||||
* (cpu/memory/ephemeral hours, dangling volumes) are left at 0 — the
|
||||
* whole-machine SKU model meters runtime, not decomposed components.
|
||||
*/
|
||||
import { getIntl, request } from '@umijs/max';
|
||||
import { withDeletedMark } from '../utils/deleted-label';
|
||||
import { request } from '@umijs/max';
|
||||
import { instanceTypeSeriesLabel } from '../utils/format-instance-type';
|
||||
|
||||
export interface ResourceUsageFilters {
|
||||
@@ -20,10 +19,6 @@ export interface ResourceUsageFilters {
|
||||
instance_ids?: number[];
|
||||
gpu_types?: string[];
|
||||
volume_ids?: number[];
|
||||
// Platform-wide "All" view only (backend-gated): consumer-Org ids and
|
||||
// user-group ids (expanded server-side to the groups' direct members).
|
||||
organization_ids?: number[];
|
||||
user_group_ids?: number[];
|
||||
}
|
||||
|
||||
export interface ResourceBreakdownRequest {
|
||||
@@ -69,19 +64,6 @@ export interface ResourceBreakdownItem extends ResourceBreakdownSummary {
|
||||
volume_name?: string;
|
||||
user_id?: number;
|
||||
user_name?: string;
|
||||
// Organization grouping (platform-wide "All" view). ``organization_name``
|
||||
// is resolved live server-side; a gone Org sets ``deleted``.
|
||||
organization_id?: number;
|
||||
organization_name?: string;
|
||||
// The grouped entity (instance / volume / user) no longer exists. The name
|
||||
// fields keep the clean (stale) name; the tables show a DeletedTag off this
|
||||
// flag plus the id, matching the Tokens tab.
|
||||
deleted?: boolean;
|
||||
// Owner user of a per-instance / per-volume row (compound date+dim grouping),
|
||||
// with its own deletion state — independent of the row's ``deleted`` (which
|
||||
// refers to the grouped instance/volume). Lets the export mark the User
|
||||
// column separately, matching the Tokens tab.
|
||||
user_deleted?: boolean;
|
||||
// Grouped-trend rows carry the sub-group label (sku / instance / user / …)
|
||||
// alongside ``date`` so the chart can pivot one series per group.
|
||||
group?: string;
|
||||
@@ -215,20 +197,11 @@ interface ServerMetrics {
|
||||
// gpu_type / type both mean the sku (Type) on the server.
|
||||
|
||||
interface ServerBreakdownItem {
|
||||
date?: string | null;
|
||||
// Grouped entity: ``key`` is its display name, ``id`` its id, ``deleted`` its
|
||||
// own lifecycle state (instance / volume / user / sku, per group_by).
|
||||
key?: string | null;
|
||||
id?: number | null;
|
||||
date?: string | null;
|
||||
sku?: string | null;
|
||||
deleted?: boolean | null;
|
||||
// Owner (creator) of the instance/volume row (compound date+dim grouping),
|
||||
// at the item root alongside the grouped entity, with its OWN deletion state
|
||||
// — independent of ``deleted`` — so the export can show a User column that
|
||||
// marks a deleted owner separately from a deleted instance/volume.
|
||||
creator_id?: number | null;
|
||||
creator_name?: string | null;
|
||||
creator_deleted?: boolean | null;
|
||||
dimensions?: {
|
||||
product?: string | null;
|
||||
unit_cpu_milli?: number | null;
|
||||
@@ -269,7 +242,6 @@ const GROUP_BY_MAP: Record<string, string> = {
|
||||
instance: 'instance',
|
||||
volume: 'volume',
|
||||
user: 'user',
|
||||
organization: 'organization',
|
||||
date: 'date'
|
||||
};
|
||||
|
||||
@@ -305,24 +277,12 @@ function flattenItem(
|
||||
};
|
||||
if (it.date) flat.date = it.date;
|
||||
const id = it.id ?? undefined;
|
||||
const deleted = !!it.deleted;
|
||||
// Deleted entities get a "(Deleted)" suffix, matching the Token breakdown.
|
||||
const rawKey = it.key ?? undefined;
|
||||
// The chart series legend can't render a tag, so it carries the deleted
|
||||
// marker as text ("<name> [Deleted.<id>]"); the tables render a DeletedTag off
|
||||
// ``flat.deleted`` + the id and so keep the clean name.
|
||||
const key =
|
||||
rawKey != null
|
||||
? withDeletedMark(
|
||||
rawKey,
|
||||
deleted,
|
||||
deleted ? getIntl().formatMessage({ id: 'usage.table.deleted' }) : '',
|
||||
id
|
||||
)
|
||||
: rawKey;
|
||||
const key = it.deleted && rawKey != null ? `${rawKey} (Deleted)` : rawKey;
|
||||
// Generic group label — for a compound (date + dim) trend row the key is the
|
||||
// sub-group value (the switch below targets single-dimension table rows).
|
||||
if (rawKey != null) flat.group = key;
|
||||
flat.deleted = deleted;
|
||||
switch (groupBy) {
|
||||
case 'resource_type':
|
||||
flat.resource_type = key;
|
||||
@@ -332,21 +292,17 @@ function flattenItem(
|
||||
flat.gpu_type = key;
|
||||
break;
|
||||
case 'instance':
|
||||
flat.instance_name = rawKey;
|
||||
flat.instance_name = key;
|
||||
flat.instance_id = id;
|
||||
break;
|
||||
case 'volume':
|
||||
flat.volume_name = rawKey;
|
||||
flat.volume_name = key;
|
||||
flat.volume_id = id;
|
||||
break;
|
||||
case 'user':
|
||||
flat.user_name = rawKey;
|
||||
flat.user_name = key;
|
||||
flat.user_id = id;
|
||||
break;
|
||||
case 'organization':
|
||||
flat.organization_name = rawKey;
|
||||
flat.organization_id = id;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -374,12 +330,6 @@ function flattenItem(
|
||||
if (dims.storage_type) flat.storage_type = dims.storage_type;
|
||||
if (dims.capacity_mib != null) flat.capacity_mib = dims.capacity_mib;
|
||||
}
|
||||
// Owner (creator) of a per-instance / per-volume row — the grouped entity is
|
||||
// the instance/volume (``key``/``deleted``), so the owner sits at the item
|
||||
// root with its own deleted flag for the export's User column.
|
||||
if (it.creator_name != null) flat.user_name = it.creator_name;
|
||||
if (it.creator_id != null) flat.user_id = it.creator_id;
|
||||
if (it.creator_deleted != null) flat.user_deleted = !!it.creator_deleted;
|
||||
// Instance-type grouped trend: the series label (``group``) defaults to the
|
||||
// raw flavor slug. Instance Types are grouped by actual shape, so label each
|
||||
// series by that shape — "<product> x <cards>" / "CPU Only · 3 vCPU · 6 GB" —
|
||||
@@ -406,13 +356,7 @@ function flattenResponse(
|
||||
|
||||
function toServerRequest(data: ResourceBreakdownRequest) {
|
||||
const groupByList = data.group_by?.length ? data.group_by : ['resource_type'];
|
||||
const {
|
||||
creator_ids,
|
||||
instance_ids,
|
||||
volume_ids,
|
||||
organization_ids,
|
||||
user_group_ids
|
||||
} = data.filters ?? {};
|
||||
const { creator_ids, instance_ids, volume_ids } = data.filters ?? {};
|
||||
// The non-date dimension drives response flattening into the right field.
|
||||
const dim = groupByList.find((g) => g !== 'date');
|
||||
return {
|
||||
@@ -427,8 +371,6 @@ function toServerRequest(data: ResourceBreakdownRequest) {
|
||||
...(creator_ids?.length ? { creator_ids } : {}),
|
||||
...(instance_ids?.length ? { instance_ids } : {}),
|
||||
...(volume_ids?.length ? { volume_ids } : {}),
|
||||
...(organization_ids?.length ? { organization_ids } : {}),
|
||||
...(user_group_ids?.length ? { user_group_ids } : {}),
|
||||
...(data.order_by ? { order_by: data.order_by } : {}),
|
||||
...(data.descending !== undefined ? { descending: data.descending } : {}),
|
||||
page: data.page ?? 1,
|
||||
@@ -498,8 +440,6 @@ export async function queryResourceEvents(
|
||||
options?: { skipErrorHandler?: boolean; token?: any }
|
||||
): Promise<ResourceEventsResponse> {
|
||||
const creatorIds = data.filters?.creator_ids;
|
||||
const organizationIds = data.filters?.organization_ids;
|
||||
const userGroupIds = data.filters?.user_group_ids;
|
||||
return request<ResourceEventsResponse>(URL.EVENTS, {
|
||||
params: {
|
||||
start_date: data.start_date,
|
||||
@@ -509,12 +449,6 @@ export async function queryResourceEvents(
|
||||
// GET endpoints take list params as CSV strings (avoids axios array
|
||||
// serialization quirks); the server splits them back into lists.
|
||||
...(creatorIds?.length ? { creator_ids: creatorIds.join(',') } : {}),
|
||||
...(organizationIds?.length
|
||||
? { organization_ids: organizationIds.join(',') }
|
||||
: {}),
|
||||
...(userGroupIds?.length
|
||||
? { user_group_ids: userGroupIds.join(',') }
|
||||
: {}),
|
||||
...(data.event_types?.length
|
||||
? { event_types: data.event_types.join(',') }
|
||||
: {}),
|
||||
@@ -532,18 +466,12 @@ export interface ResourceFilterOption {
|
||||
id: number;
|
||||
label: string;
|
||||
deleted?: boolean;
|
||||
// ``org`` / ``user`` / ``group`` — only set on organization options so the
|
||||
// filter dropdown can tag a personal (USER) consumer.
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
export interface ResourceFilterMeta {
|
||||
creators: ResourceFilterOption[];
|
||||
instances: ResourceFilterOption[];
|
||||
volumes: ResourceFilterOption[];
|
||||
// Platform-wide "All" view only (backend returns them empty otherwise).
|
||||
organizations: ResourceFilterOption[];
|
||||
user_groups: ResourceFilterOption[];
|
||||
}
|
||||
|
||||
export async function queryResourceFilterMeta(
|
||||
@@ -556,9 +484,7 @@ export async function queryResourceFilterMeta(
|
||||
return {
|
||||
creators: res.creators || [],
|
||||
instances: res.instances || [],
|
||||
volumes: res.volumes || [],
|
||||
organizations: res.organizations || [],
|
||||
user_groups: res.user_groups || []
|
||||
volumes: res.volumes || []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -568,12 +494,10 @@ export async function queryUsageSummary(
|
||||
end_date: string;
|
||||
scope?: 'self' | 'all';
|
||||
creator_ids?: number[];
|
||||
organization_ids?: number[];
|
||||
user_group_ids?: number[];
|
||||
},
|
||||
options?: { token?: any }
|
||||
): Promise<UsageSummaryResponse> {
|
||||
const { creator_ids, organization_ids, user_group_ids, ...rest } = params;
|
||||
const { creator_ids, ...rest } = params;
|
||||
const res = await request<{
|
||||
total_tokens: number;
|
||||
input_tokens: number;
|
||||
@@ -587,13 +511,7 @@ export async function queryUsageSummary(
|
||||
params: {
|
||||
...rest,
|
||||
scope: params.scope ?? 'all',
|
||||
...(creator_ids?.length ? { creator_ids: creator_ids.join(',') } : {}),
|
||||
...(organization_ids?.length
|
||||
? { organization_ids: organization_ids.join(',') }
|
||||
: {}),
|
||||
...(user_group_ids?.length
|
||||
? { user_group_ids: user_group_ids.join(',') }
|
||||
: {})
|
||||
...(creator_ids?.length ? { creator_ids: creator_ids.join(',') } : {})
|
||||
},
|
||||
method: 'GET',
|
||||
cancelToken: options?.token
|
||||
@@ -610,17 +528,7 @@ export async function queryUsageSummary(
|
||||
end_date: params.end_date,
|
||||
scope: params.scope ?? 'all',
|
||||
group_by: ['gpu_type'],
|
||||
...(creator_ids?.length ||
|
||||
organization_ids?.length ||
|
||||
user_group_ids?.length
|
||||
? {
|
||||
filters: {
|
||||
...(creator_ids?.length ? { creator_ids } : {}),
|
||||
...(organization_ids?.length ? { organization_ids } : {}),
|
||||
...(user_group_ids?.length ? { user_group_ids } : {})
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
...(creator_ids?.length ? { filters: { creator_ids } } : {}),
|
||||
page: 1,
|
||||
perPage: 100
|
||||
},
|
||||
|
||||
@@ -12,15 +12,6 @@ import {
|
||||
UsageBreakdownResponse,
|
||||
UsageFilterItem
|
||||
} from '../config/types';
|
||||
import { withDeletedMark } from '../utils/deleted-label';
|
||||
|
||||
// group dimension → the id field inside ``identity.current`` (the backend nulls
|
||||
// it for deleted entities, so the marker falls back to just "[Deleted]").
|
||||
const GROUP_ID_KEY: Record<string, 'route_id' | 'user_id' | 'api_key_id'> = {
|
||||
route: 'route_id',
|
||||
user: 'user_id',
|
||||
api_key: 'api_key_id'
|
||||
};
|
||||
|
||||
const ControlsWrapper = styled.div`
|
||||
display: flex;
|
||||
@@ -133,22 +124,12 @@ const DailyUsage: React.FC<DailyUsageProps> = (props) => {
|
||||
(a, b) => dayjs(a).valueOf() - dayjs(b).valueOf()
|
||||
);
|
||||
|
||||
const deletedWord = intl.formatMessage({ id: 'usage.table.deleted' });
|
||||
|
||||
const groupOrder: string[] = [];
|
||||
const groupItemsMap = new Map<string, Map<string, BreakdownItem>>();
|
||||
|
||||
items.forEach((item) => {
|
||||
const groupEntity = groupDim
|
||||
? (item[groupDim] as UsageFilterItem)
|
||||
: undefined;
|
||||
const groupLabel = groupDim
|
||||
? withDeletedMark(
|
||||
groupEntity?.label ?? '-',
|
||||
groupEntity?.deleted,
|
||||
deletedWord,
|
||||
groupEntity?.identity?.current?.[GROUP_ID_KEY[groupDim]]
|
||||
)
|
||||
? ((item[groupDim] as UsageFilterItem)?.label ?? '-')
|
||||
: '__total__';
|
||||
|
||||
if (!groupItemsMap.has(groupLabel)) {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* Deleted-entity marker for the usage filter dropdowns (model / user / api-key
|
||||
* on the Tokens tab; user / instance / volume on the resource tabs). Renders an
|
||||
* outlined transparent pill "Deleted·#{id}" — the id keeps two entries sharing
|
||||
* a now-stale label distinguishable. Pairs with dimming the option label to the
|
||||
* tertiary text color.
|
||||
*
|
||||
* Presentational only: each filter resolves the entity id from its own option
|
||||
* shape (Tokens tab ← ``identity.current``; resource tabs ← option ``value``)
|
||||
* and passes it in. When no id is available the tag falls back to plain
|
||||
* "Deleted".
|
||||
*/
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Tag } from 'antd';
|
||||
import React from 'react';
|
||||
|
||||
interface DeletedTagProps {
|
||||
id?: string | number | null;
|
||||
}
|
||||
|
||||
const DeletedTag: React.FC<DeletedTagProps> = ({ id }) => {
|
||||
const intl = useIntl();
|
||||
const label = intl.formatMessage({ id: 'usage.table.deleted' });
|
||||
return (
|
||||
<Tag
|
||||
variant="outlined"
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 11,
|
||||
borderRadius: 12,
|
||||
background: 'transparent'
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{!!id && (
|
||||
<span className="text-tertiary">
|
||||
<span style={{ margin: '0 2px' }}>·</span>#{id}
|
||||
</span>
|
||||
)}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeletedTag;
|
||||
@@ -5,7 +5,6 @@ import { Table, TableColumnType } from 'antd';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useUsageFilters } from '../hooks/use-usage-filters';
|
||||
import useQueryBreakdownList from '../services/use-query-breakdown-list';
|
||||
import { withDeletedMark } from '../utils/deleted-label';
|
||||
import getBreakdownRowKey from '../utils/get-breakdown-row-key';
|
||||
import FilterBar from './filter-bar';
|
||||
|
||||
@@ -50,7 +49,6 @@ const ExportData: React.FC<{
|
||||
initialState
|
||||
} = props || {};
|
||||
const intl = useIntl();
|
||||
const deletedWord = intl.formatMessage({ id: 'usage.table.deleted' });
|
||||
|
||||
// Members are forced to self scope, where the backend forbids grouping by
|
||||
// user (privacy) — including it 403s the export request. Drop the user
|
||||
@@ -125,49 +123,22 @@ const ExportData: React.FC<{
|
||||
{
|
||||
title: intl.formatMessage({ id: 'usage.filter.group.model' }),
|
||||
dataIndex: ['route', 'label'],
|
||||
render: (text: string, record: any) => {
|
||||
return (
|
||||
<AutoTooltip ghost>
|
||||
{withDeletedMark(
|
||||
text,
|
||||
record?.route?.deleted,
|
||||
deletedWord,
|
||||
record?.route?.identity?.current?.route_id
|
||||
)}
|
||||
</AutoTooltip>
|
||||
);
|
||||
render: (text: string) => {
|
||||
return <AutoTooltip ghost>{text}</AutoTooltip>;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({ id: 'dashboard.usage.export.user' }),
|
||||
dataIndex: ['user', 'label'],
|
||||
render: (text: string, record: any) => {
|
||||
return (
|
||||
<AutoTooltip ghost>
|
||||
{withDeletedMark(
|
||||
text,
|
||||
record?.user?.deleted,
|
||||
deletedWord,
|
||||
record?.user?.identity?.current?.user_id
|
||||
)}
|
||||
</AutoTooltip>
|
||||
);
|
||||
render: (text: string) => {
|
||||
return <AutoTooltip ghost>{text}</AutoTooltip>;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({ id: 'usage.filter.group.apikey' }),
|
||||
dataIndex: ['api_key', 'label'],
|
||||
render: (text: string, record: any) => {
|
||||
return (
|
||||
<AutoTooltip ghost>
|
||||
{withDeletedMark(
|
||||
text,
|
||||
record?.api_key?.deleted,
|
||||
deletedWord,
|
||||
record?.api_key?.identity?.current?.api_key_id
|
||||
)}
|
||||
</AutoTooltip>
|
||||
);
|
||||
return <AutoTooltip ghost>{text}</AutoTooltip>;
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -244,24 +215,9 @@ const ExportData: React.FC<{
|
||||
{
|
||||
jsonData: (dataSource.dataList || []).map((item: any) => ({
|
||||
date: item?.date?.label,
|
||||
user: withDeletedMark(
|
||||
item?.user?.label ?? '',
|
||||
item?.user?.deleted,
|
||||
deletedWord,
|
||||
item?.user?.identity?.current?.user_id
|
||||
),
|
||||
route: withDeletedMark(
|
||||
item?.route?.label ?? '',
|
||||
item?.route?.deleted,
|
||||
deletedWord,
|
||||
item?.route?.identity?.current?.route_id
|
||||
),
|
||||
api_key: withDeletedMark(
|
||||
item?.api_key?.label ?? '',
|
||||
item?.api_key?.deleted,
|
||||
deletedWord,
|
||||
item?.api_key?.identity?.current?.api_key_id
|
||||
),
|
||||
user: item?.user?.label,
|
||||
route: item?.route?.label,
|
||||
api_key: item?.api_key?.label,
|
||||
input_tokens: item?.input_tokens,
|
||||
input_cached_tokens: item?.input_cached_tokens,
|
||||
output_tokens: item?.output_tokens,
|
||||
@@ -284,7 +240,7 @@ const ExportData: React.FC<{
|
||||
date: intl.formatMessage({ id: 'dashboard.usage.export.date' }),
|
||||
user: intl.formatMessage({ id: 'dashboard.usage.export.user' }),
|
||||
route: intl.formatMessage({ id: 'usage.filter.group.model' }),
|
||||
api_key: intl.formatMessage({ id: 'usage.filter.group.apikey' }),
|
||||
api_key: intl.formatMessage({ id: 'usage.table.provider' }),
|
||||
input_tokens: intl.formatMessage({
|
||||
id: 'usage.filter.inputTokens'
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import PluginExtraFields from '@/components/plugin-extra-fields';
|
||||
import useRangePickerPreset from '@/pages/dashboard/hooks/use-rangepicker-preset';
|
||||
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
@@ -14,7 +13,6 @@ import React from 'react';
|
||||
import { GroupOption } from '../config';
|
||||
import { UsageFilterItem } from '../config/types';
|
||||
import FilterBarCss from '../styles/filter-bar.less';
|
||||
import DeletedTag from './deleted-tag';
|
||||
|
||||
type valueType = string | number | null;
|
||||
const DefaultDateConfig = {
|
||||
@@ -38,14 +36,6 @@ interface FilterBarProps {
|
||||
routeOptions: OptionType[];
|
||||
userOptions: OptionType[];
|
||||
apiKeyOptions: GroupOption<UsageFilterItem>[];
|
||||
// Platform-wide "All" view only; empty otherwise (backend-gated). Rendered
|
||||
// by the enterprise ``UsageFilterBar`` slot.
|
||||
organizationOptions?: OptionType[];
|
||||
userGroupOptions?: OptionType[];
|
||||
selectedOrganizations?: string[];
|
||||
selectedUserGroups?: string[];
|
||||
onOrganizationsChange?: (value: string[]) => void;
|
||||
onUserGroupsChange?: (value: string[]) => void;
|
||||
activeApiKeys: valueType[][];
|
||||
handlePickerChange: (picker: DateType) => void;
|
||||
onScopeChange: (value: string) => void;
|
||||
@@ -86,12 +76,6 @@ const FilterBar: React.FC<FilterBarProps> = (props) => {
|
||||
onRoutesChange,
|
||||
onUsersChange,
|
||||
onApiKeysChange,
|
||||
organizationOptions,
|
||||
userGroupOptions,
|
||||
selectedOrganizations,
|
||||
selectedUserGroups,
|
||||
onOrganizationsChange,
|
||||
onUserGroupsChange,
|
||||
onExportChart,
|
||||
onExportTable,
|
||||
handleSearch
|
||||
@@ -172,17 +156,6 @@ const FilterBar: React.FC<FilterBarProps> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
// The entity id behind a deleted option, shown in the "Deleted·#{id}" tag so
|
||||
// two deleted entries with the same (now-stale) label stay distinguishable.
|
||||
const getEntityId = (data: any): valueType => {
|
||||
const current = data?.identity?.current;
|
||||
if (!current) return null;
|
||||
return current.user_id ?? current.api_key_id ?? current.route_id ?? null;
|
||||
};
|
||||
|
||||
const deletedLabelStyle = (deleted?: boolean) =>
|
||||
deleted ? { color: 'var(--ant-color-text-tertiary)' } : undefined;
|
||||
|
||||
const handleOnApiKeysChange = (
|
||||
value: valueType[][],
|
||||
selectedOptions: any
|
||||
@@ -202,10 +175,9 @@ const FilterBar: React.FC<FilterBarProps> = (props) => {
|
||||
if (!data.isParent) {
|
||||
return (
|
||||
<span className="flex-center gap-4">
|
||||
<AutoTooltip ghost style={deletedLabelStyle(data.deleted)}>
|
||||
{data.label}
|
||||
</AutoTooltip>
|
||||
{data.deleted && <DeletedTag id={getEntityId(data)} />}
|
||||
<AutoTooltip ghost>{data.label}</AutoTooltip>
|
||||
{data.deleted &&
|
||||
renderTag(intl.formatMessage({ id: 'usage.table.deleted' }))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -221,10 +193,9 @@ const FilterBar: React.FC<FilterBarProps> = (props) => {
|
||||
const { data } = option;
|
||||
return (
|
||||
<span className="flex-center gap-4">
|
||||
<AutoTooltip ghost style={deletedLabelStyle(data.deleted)}>
|
||||
{data.label}
|
||||
</AutoTooltip>
|
||||
{data.deleted && <DeletedTag id={getEntityId(data)} />}
|
||||
<AutoTooltip ghost>{data.label}</AutoTooltip>
|
||||
{data.deleted &&
|
||||
renderTag(intl.formatMessage({ id: 'usage.table.deleted' }))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -243,12 +214,11 @@ const FilterBar: React.FC<FilterBarProps> = (props) => {
|
||||
const { data } = option;
|
||||
return (
|
||||
<span className="flex-center gap-4">
|
||||
<AutoTooltip ghost style={deletedLabelStyle(data.deleted)}>
|
||||
{data.label}
|
||||
</AutoTooltip>
|
||||
<AutoTooltip ghost>{data.label}</AutoTooltip>
|
||||
{data.isCurrent &&
|
||||
renderTag(intl.formatMessage({ id: 'usage.user.currentAccount' }))}
|
||||
{data.deleted && <DeletedTag id={getEntityId(data)} />}
|
||||
{data.deleted &&
|
||||
renderTag(intl.formatMessage({ id: 'usage.table.deleted' }))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -360,21 +330,6 @@ const FilterBar: React.FC<FilterBarProps> = (props) => {
|
||||
onChange={onApiKeysChange}
|
||||
/>
|
||||
)}
|
||||
{/* Enterprise-only Organization / User Group filters (platform-wide
|
||||
"All" view). Renders nothing when no plugin is registered or the
|
||||
backend returned no options. */}
|
||||
<PluginExtraFields
|
||||
name="UsageFilterBar"
|
||||
context={{
|
||||
organizationOptions: organizationOptions || [],
|
||||
userGroupOptions: userGroupOptions || [],
|
||||
selectedOrganizations: selectedOrganizations || [],
|
||||
selectedUserGroups: selectedUserGroups || [],
|
||||
onOrganizationsChange,
|
||||
onUserGroupsChange,
|
||||
optionLabelRender: singleOptionRender
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
style={{ color: 'var(--ant-color-text-tertiary)' }}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
ResourceBreakdownRequest,
|
||||
ResourceBreakdownResponse
|
||||
} from '../apis/resource';
|
||||
import { withDeletedMark } from '../utils/deleted-label';
|
||||
import {
|
||||
exportBreakdownRows,
|
||||
toExportColumns
|
||||
@@ -33,9 +32,6 @@ type Scope = 'self' | 'all';
|
||||
interface SelectOption {
|
||||
value: number;
|
||||
label: string;
|
||||
deleted?: boolean;
|
||||
// ``org`` / ``user`` / ``group`` — set on organization options for the tag.
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
interface ResourceExportDataProps {
|
||||
@@ -65,21 +61,6 @@ interface ResourceExportDataProps {
|
||||
initialDateRange: [dayjs.Dayjs, dayjs.Dayjs];
|
||||
initialSelectedUsers: number[];
|
||||
initialSelectedResources: number[];
|
||||
// Platform-wide "All" view only; empty otherwise. Mirrors the tab's
|
||||
// Organization / User Group filters so the export can re-narrow by them.
|
||||
organizationOptions?: SelectOption[];
|
||||
userGroupOptions?: SelectOption[];
|
||||
initialSelectedOrganizations?: number[];
|
||||
initialSelectedUserGroups?: number[];
|
||||
// Name columns that carry a "[Deleted.#id]" marker when their entity is gone.
|
||||
// Each maps a clean-name field to its id + own deleted flag, so a compound
|
||||
// (date + instance/volume) row can mark the instance/volume and its owner
|
||||
// User independently — mirroring the Tokens tab's chart export.
|
||||
deletedNameFields?: {
|
||||
name: string;
|
||||
id: string;
|
||||
deletedFlag: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
const INITIAL_PAGE = { page: 1, perPage: 100 };
|
||||
@@ -100,12 +81,7 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
|
||||
resourceFilter,
|
||||
initialDateRange,
|
||||
initialSelectedUsers,
|
||||
initialSelectedResources,
|
||||
organizationOptions = [],
|
||||
userGroupOptions = [],
|
||||
initialSelectedOrganizations = [],
|
||||
initialSelectedUserGroups = [],
|
||||
deletedNameFields
|
||||
initialSelectedResources
|
||||
} = props;
|
||||
const intl = useIntl();
|
||||
|
||||
@@ -116,12 +92,6 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
|
||||
const [selectedResources, setSelectedResources] = useState<number[]>(
|
||||
initialSelectedResources
|
||||
);
|
||||
const [selectedOrganizations, setSelectedOrganizations] = useState<number[]>(
|
||||
initialSelectedOrganizations
|
||||
);
|
||||
const [selectedUserGroups, setSelectedUserGroups] = useState<number[]>(
|
||||
initialSelectedUserGroups
|
||||
);
|
||||
const [pageParams, setPageParams] = useState(INITIAL_PAGE);
|
||||
const [data, setData] = useState<ResourceBreakdownResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -137,20 +107,11 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
|
||||
group_by: groupBy,
|
||||
granularity: 'day',
|
||||
filters:
|
||||
selectedUsers.length ||
|
||||
selectedResources.length ||
|
||||
selectedOrganizations.length ||
|
||||
selectedUserGroups.length
|
||||
selectedUsers.length || selectedResources.length
|
||||
? {
|
||||
...(selectedUsers.length ? { creator_ids: selectedUsers } : {}),
|
||||
...(selectedResources.length
|
||||
? { [resourceFilter.key]: selectedResources }
|
||||
: {}),
|
||||
...(selectedOrganizations.length
|
||||
? { organization_ids: selectedOrganizations }
|
||||
: {}),
|
||||
...(selectedUserGroups.length
|
||||
? { user_group_ids: selectedUserGroups }
|
||||
: {})
|
||||
}
|
||||
: undefined,
|
||||
@@ -174,8 +135,6 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
|
||||
setDateRange(initialDateRange);
|
||||
setSelectedUsers(initialSelectedUsers);
|
||||
setSelectedResources(initialSelectedResources);
|
||||
setSelectedOrganizations(initialSelectedOrganizations);
|
||||
setSelectedUserGroups(initialSelectedUserGroups);
|
||||
setPageParams(INITIAL_PAGE);
|
||||
}, [open]);
|
||||
|
||||
@@ -183,15 +142,7 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
fetchPreview(pageParams.page, pageParams.perPage);
|
||||
}, [
|
||||
open,
|
||||
dateRange,
|
||||
selectedUsers,
|
||||
selectedResources,
|
||||
selectedOrganizations,
|
||||
selectedUserGroups,
|
||||
pageParams
|
||||
]);
|
||||
}, [open, dateRange, selectedUsers, selectedResources, pageParams]);
|
||||
|
||||
const previewColumns = useMemo(
|
||||
() => [
|
||||
@@ -206,50 +157,7 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
|
||||
[columns, pageParams.page, pageParams.perPage, intl]
|
||||
);
|
||||
|
||||
// Append the "[Deleted.#id]" text marker to each configured name field of
|
||||
// deleted rows — used for both the preview cells and the exported sheet, so
|
||||
// the two always agree. Rows are export/preview-only copies. Each field marks
|
||||
// off its own deleted flag (instance/volume vs. its owner User) so a compound
|
||||
// row can flag the two entities independently.
|
||||
const markRows = (
|
||||
items: ResourceBreakdownItem[]
|
||||
): ResourceBreakdownItem[] => {
|
||||
if (!deletedNameFields?.length) return items;
|
||||
const deletedWord = intl.formatMessage({ id: 'usage.table.deleted' });
|
||||
return items.map((item) => {
|
||||
let next = item;
|
||||
deletedNameFields.forEach((f) => {
|
||||
if ((item as any)[f.deletedFlag]) {
|
||||
next = {
|
||||
...next,
|
||||
[f.name]: withDeletedMark(
|
||||
(item as any)[f.name] ?? '',
|
||||
true,
|
||||
deletedWord,
|
||||
(item as any)[f.id]
|
||||
)
|
||||
};
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Normalize the date bucket to a plain calendar day (drop the ``T00:00:00``
|
||||
// the hourly ``metered_usage`` carries) so the export matches the Tokens
|
||||
// tab's date-only format. Slice the ISO string rather than re-parsing with
|
||||
// dayjs — a tz-offset timestamp would shift the calendar day on format.
|
||||
// The export always requests day granularity.
|
||||
const formatRowDates = (
|
||||
items: ResourceBreakdownItem[]
|
||||
): ResourceBreakdownItem[] =>
|
||||
items.map((i) =>
|
||||
i.date ? { ...i, date: String(i.date).slice(0, 10) } : i
|
||||
);
|
||||
|
||||
const rows: ResourceBreakdownItem[] = formatRowDates(
|
||||
markRows(data?.items ?? [])
|
||||
);
|
||||
const rows: ResourceBreakdownItem[] = data?.items ?? [];
|
||||
|
||||
const handlePageChange = (page: number, perPage: number) => {
|
||||
setPageParams({ page, perPage });
|
||||
@@ -262,7 +170,7 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
|
||||
try {
|
||||
const res = await queryFn(buildRequest(-1, INITIAL_PAGE.perPage));
|
||||
exportBreakdownRows(
|
||||
formatRowDates(markRows(res.items ?? [])),
|
||||
res.items ?? [],
|
||||
toExportColumns(columns),
|
||||
fileName,
|
||||
sheetName
|
||||
@@ -324,18 +232,6 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
|
||||
},
|
||||
placeholder: resourceFilter.placeholder
|
||||
}}
|
||||
organizationOptions={organizationOptions}
|
||||
userGroupOptions={userGroupOptions}
|
||||
selectedOrganizations={selectedOrganizations}
|
||||
selectedUserGroups={selectedUserGroups}
|
||||
onOrganizationsChange={(ids) => {
|
||||
setSelectedOrganizations(ids);
|
||||
setPageParams(INITIAL_PAGE);
|
||||
}}
|
||||
onUserGroupsChange={(ids) => {
|
||||
setSelectedUserGroups(ids);
|
||||
setPageParams(INITIAL_PAGE);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Table
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
* ``extra`` lets a tab append its own filters (e.g. Resource Events' resource
|
||||
* type / event type) inline, keeping one consistent bar.
|
||||
*/
|
||||
import PluginExtraFields from '@/components/plugin-extra-fields';
|
||||
import useRangePickerPreset from '@/pages/dashboard/hooks/use-rangepicker-preset';
|
||||
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
import { AutoTooltip, IconFont, SimpleSelect } from '@gpustack/core-ui';
|
||||
@@ -18,7 +17,6 @@ import { Button, DatePicker, Dropdown, MenuProps } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import React from 'react';
|
||||
import FilterBarCss from '../styles/filter-bar.less';
|
||||
import DeletedTag from './deleted-tag';
|
||||
|
||||
const DefaultDateConfig = {
|
||||
maxRange: 90,
|
||||
@@ -29,7 +27,6 @@ interface SelectOption {
|
||||
value: number;
|
||||
label: string;
|
||||
deleted?: boolean;
|
||||
isCurrent?: boolean;
|
||||
}
|
||||
|
||||
// Optional per-tab entity filter (GPU instance on the GPU tab / volume on the
|
||||
@@ -55,14 +52,6 @@ interface ResourceFilterBarProps {
|
||||
onExportChart?: () => void;
|
||||
onExportTable?: () => void;
|
||||
extra?: React.ReactNode;
|
||||
// Platform-wide "All" view only; empty otherwise (backend-gated). Rendered
|
||||
// by the enterprise ``ResourceUsageFilterBar`` slot.
|
||||
organizationOptions?: SelectOption[];
|
||||
userGroupOptions?: SelectOption[];
|
||||
selectedOrganizations?: number[];
|
||||
selectedUserGroups?: number[];
|
||||
onOrganizationsChange?: (ids: number[]) => void;
|
||||
onUserGroupsChange?: (ids: number[]) => void;
|
||||
}
|
||||
|
||||
const ResourceFilterBar: React.FC<ResourceFilterBarProps> = (props) => {
|
||||
@@ -77,13 +66,7 @@ const ResourceFilterBar: React.FC<ResourceFilterBarProps> = (props) => {
|
||||
onRefresh,
|
||||
onExportChart,
|
||||
onExportTable,
|
||||
extra,
|
||||
organizationOptions,
|
||||
userGroupOptions,
|
||||
selectedOrganizations,
|
||||
selectedUserGroups,
|
||||
onOrganizationsChange,
|
||||
onUserGroupsChange
|
||||
extra
|
||||
} = props;
|
||||
const intl = useIntl();
|
||||
|
||||
@@ -154,32 +137,20 @@ const ResourceFilterBar: React.FC<ResourceFilterBarProps> = (props) => {
|
||||
)
|
||||
: [dayjs().add(-DefaultDateConfig.defaultRange, 'd'), dayjs()];
|
||||
|
||||
const renderTag = (tag: string) => (
|
||||
<span className="text-tertiary" style={{ fontSize: 12, marginRight: 4 }}>
|
||||
[{tag}]
|
||||
const userOptionRender = (option: any) => (
|
||||
<span className="flex-center gap-4">
|
||||
<AutoTooltip ghost>{option?.data?.label}</AutoTooltip>
|
||||
{option?.data?.deleted && (
|
||||
<span
|
||||
className="text-tertiary"
|
||||
style={{ fontSize: 12, marginRight: 4 }}
|
||||
>
|
||||
[{intl.formatMessage({ id: 'usage.table.deleted' })}]
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
const deletedLabelStyle = (deleted?: boolean) =>
|
||||
deleted ? { color: 'var(--ant-color-text-tertiary)' } : undefined;
|
||||
|
||||
// The user / instance / volume filters mirror the Tokens tab: the option
|
||||
// ``value`` is the entity id, so a deleted entry stays distinguishable via the
|
||||
// "Deleted·#{id}" tag.
|
||||
const userOptionRender = (option: any) => {
|
||||
const { data } = option;
|
||||
return (
|
||||
<span className="flex-center gap-4">
|
||||
<AutoTooltip ghost style={deletedLabelStyle(data?.deleted)}>
|
||||
{data?.label}
|
||||
</AutoTooltip>
|
||||
{data?.isCurrent &&
|
||||
renderTag(intl.formatMessage({ id: 'usage.user.currentAccount' }))}
|
||||
{data?.deleted && <DeletedTag id={data?.value} />}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={FilterBarCss.wrapper}>
|
||||
<div className={FilterBarCss.filters}>
|
||||
@@ -231,21 +202,6 @@ const ResourceFilterBar: React.FC<ResourceFilterBarProps> = (props) => {
|
||||
/>
|
||||
)}
|
||||
{extra}
|
||||
{/* Enterprise-only Organization / User Group filters (platform-wide
|
||||
"All" view). Renders nothing when no plugin is registered or the
|
||||
backend returned no options. */}
|
||||
<PluginExtraFields
|
||||
name="ResourceUsageFilterBar"
|
||||
context={{
|
||||
organizationOptions: organizationOptions || [],
|
||||
userGroupOptions: userGroupOptions || [],
|
||||
selectedOrganizations: selectedOrganizations || [],
|
||||
selectedUserGroups: selectedUserGroups || [],
|
||||
onOrganizationsChange,
|
||||
onUserGroupsChange,
|
||||
optionLabelRender: userOptionRender
|
||||
}}
|
||||
/>
|
||||
{onRefresh && (
|
||||
<Button
|
||||
type="text"
|
||||
|
||||
@@ -137,14 +137,12 @@ export function groupToOptions<T, TChild>(
|
||||
options: {
|
||||
getGroupKey: (item: T) => string;
|
||||
getGroupType: (item: T) => string;
|
||||
// ``index`` is a stable per-row token so ``getChild`` can mint a unique
|
||||
// option value for id-less (deleted) entries that share a display name.
|
||||
getChild: (item: T, index: number) => TChild;
|
||||
getChild: (item: T) => TChild;
|
||||
}
|
||||
): GroupOption<TChild>[] {
|
||||
const groupedMap = new Map<string, GroupOption<TChild>>();
|
||||
|
||||
data.forEach((item, index) => {
|
||||
data.forEach((item) => {
|
||||
const groupKey = options.getGroupKey(item);
|
||||
const groupType = options.getGroupType(item);
|
||||
|
||||
@@ -158,7 +156,7 @@ export function groupToOptions<T, TChild>(
|
||||
});
|
||||
}
|
||||
|
||||
const child = options.getChild(item, index) as TChild & { value: string };
|
||||
const child = options.getChild(item) as TChild & { value: string };
|
||||
groupedMap.get(groupKey)!.children.push(child);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ export interface UsageFilterItem {
|
||||
identity: {
|
||||
value: {
|
||||
cluster_name: string;
|
||||
model_name: string | null;
|
||||
user_name: string;
|
||||
api_key_name: string | null;
|
||||
access_key: string | null;
|
||||
@@ -9,16 +10,12 @@ export interface UsageFilterItem {
|
||||
provider_type: string | null;
|
||||
provider_name: string | null;
|
||||
route_name: string | null;
|
||||
// Resolved live from principals (platform-wide "All" view only).
|
||||
organization_name?: string | null;
|
||||
group_name?: string | null;
|
||||
};
|
||||
current: {
|
||||
model_id: string | null;
|
||||
user_id: number | null;
|
||||
api_key_id: string | null;
|
||||
route_id: number | null;
|
||||
organization_id?: number | null;
|
||||
group_id?: number | null;
|
||||
};
|
||||
};
|
||||
label: string;
|
||||
@@ -52,6 +49,7 @@ export interface TimeSeriesData {
|
||||
|
||||
export type BreakdownItem = {
|
||||
cluster_name: string;
|
||||
model_name: string;
|
||||
user_name: string;
|
||||
api_key_name: string;
|
||||
input_tokens: number;
|
||||
@@ -69,7 +67,6 @@ export type BreakdownItem = {
|
||||
model: UsageFilterItem;
|
||||
route: UsageFilterItem;
|
||||
api_key: UsageFilterItem;
|
||||
organization: UsageFilterItem;
|
||||
date: {
|
||||
value: string;
|
||||
label: string;
|
||||
@@ -91,26 +88,20 @@ export interface UsageBreakdownResponse {
|
||||
|
||||
export interface UsageMeta {
|
||||
filters: {
|
||||
models: UsageFilterItem[];
|
||||
users: UsageFilterItem[];
|
||||
api_keys: UsageFilterItem[];
|
||||
routes: UsageFilterItem[];
|
||||
// Platform-wide "All" view only; empty otherwise (backend-gated).
|
||||
organizations?: UsageFilterItem[];
|
||||
user_groups?: UsageFilterItem[];
|
||||
};
|
||||
}
|
||||
|
||||
export type FilterOptionType = Omit<UsageFilterItem, 'label' | 'deleted'>;
|
||||
|
||||
// The full breakdown filter set (route / user / api_key + org / user_group).
|
||||
// Every breakdown table sends all active dimensions — matching the trend
|
||||
// chart — so e.g. a user filter narrows the Models table too, not only the
|
||||
// Users table. ``organizations`` / ``user_groups`` are only ever populated in
|
||||
// the platform-wide "All" view (their filter options are backend-gated).
|
||||
// The full breakdown filter set (route / user / api_key). Every breakdown
|
||||
// table sends all active dimensions — matching the trend chart — so e.g. a
|
||||
// user filter narrows the Models table too, not only the Users table.
|
||||
export type BreakdownFilters = {
|
||||
routes?: FilterOptionType[];
|
||||
users?: FilterOptionType[];
|
||||
api_keys?: FilterOptionType[];
|
||||
organizations?: FilterOptionType[];
|
||||
user_groups?: FilterOptionType[];
|
||||
};
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
* tab (date range + "filter by user" for managers); the resource-type /
|
||||
* event-type selects ride in the bar's ``extra`` slot.
|
||||
*/
|
||||
import { SimpleSelect } from '@gpustack/core-ui';
|
||||
import { useAccess, useIntl } from '@umijs/max';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { Input, Select, Table, Tag } from 'antd';
|
||||
@@ -243,18 +242,16 @@ const ResourceEvents: React.FC = () => {
|
||||
options={RESOURCE_TYPE_OPTIONS}
|
||||
style={{ minWidth: 200 }}
|
||||
/>
|
||||
<SimpleSelect
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
showSearch
|
||||
maxTagCount={'responsive'}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'usage.events.eventType'
|
||||
})}
|
||||
value={queryParams.eventTypes}
|
||||
onChange={(v) => fetchEvents({ eventTypes: v, page: 1 })}
|
||||
options={EVENT_TYPE_OPTIONS}
|
||||
style={{ width: 240 }}
|
||||
style={{ minWidth: 240 }}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
// columns.ts
|
||||
import { AutoTooltip } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Tag } from 'antd';
|
||||
import { useMemo } from 'react';
|
||||
import DeletedTag from '../components/deleted-tag';
|
||||
import { BreakdownItem as ListItem } from '../config/types';
|
||||
|
||||
const useAPIKeysColumns = () => {
|
||||
const useModelsColumns = (): Array<{
|
||||
title: string;
|
||||
dataIndex: string | string[];
|
||||
key: string;
|
||||
}> => {
|
||||
const intl = useIntl();
|
||||
|
||||
return useMemo(() => {
|
||||
@@ -15,18 +19,23 @@ const useAPIKeysColumns = () => {
|
||||
dataIndex: ['api_key', 'identity', 'value', 'api_key_name'],
|
||||
key: 'api_key_name',
|
||||
render: (text: string, record: ListItem) => (
|
||||
<span className="flex items-center gap-8">
|
||||
<span className="flex items-center">
|
||||
<AutoTooltip ghost title={<span>{text}</span>}>
|
||||
<span
|
||||
className={
|
||||
record.api_key?.deleted ? 'text-tertiary' : 'text-primary'
|
||||
}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
<span className="text-primary">{text}</span>
|
||||
</AutoTooltip>
|
||||
{record.api_key?.deleted && (
|
||||
<DeletedTag id={record.api_key?.identity?.current?.api_key_id} />
|
||||
<Tag
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
borderRadius: 12,
|
||||
color: 'var(--ant-color-text-tertiary)',
|
||||
borderColor: 'var(--ant-color-split)',
|
||||
backgroundColor: 'transparent'
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
{intl.formatMessage({ id: 'usage.table.deleted' })}
|
||||
</Tag>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
@@ -113,4 +122,4 @@ const useAPIKeysColumns = () => {
|
||||
}, [intl]);
|
||||
};
|
||||
|
||||
export default useAPIKeysColumns;
|
||||
export default useModelsColumns;
|
||||
|
||||
@@ -4,24 +4,12 @@ import {
|
||||
usersTableDataAtom
|
||||
} from '@/atoms/usage';
|
||||
import { exportJsonToExcel } from '@gpustack/core-ui/excel';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { useStore } from 'jotai';
|
||||
import _ from 'lodash';
|
||||
import { withDeletedMark } from '../utils/deleted-label';
|
||||
import useAPIKeysColumns from './use-apikeys-columns';
|
||||
import useModelsColumns from './use-models-columns';
|
||||
import useUsersColumns from './use-users-columns';
|
||||
|
||||
// The name column's ``dataIndex`` starts with the embedded entity object
|
||||
// (route / user / api_key); that entity carries ``deleted`` + the id used for
|
||||
// the "[Deleted.<id>]" export marker.
|
||||
const ID_KEY_BY_ENTITY: Record<string, 'route_id' | 'user_id' | 'api_key_id'> =
|
||||
{
|
||||
route: 'route_id',
|
||||
user: 'user_id',
|
||||
api_key: 'api_key_id'
|
||||
};
|
||||
|
||||
type ColumnLike = {
|
||||
title: any;
|
||||
// Plugin-contributed columns may omit `dataIndex` — they render via a
|
||||
@@ -35,14 +23,11 @@ type ColumnLike = {
|
||||
const toFieldKey = (dataIndex: string | string[]): string =>
|
||||
Array.isArray(dataIndex) ? dataIndex.join('_') : dataIndex;
|
||||
|
||||
const buildSheetMeta = (columns: ColumnLike[], deletedWord: string) => {
|
||||
const buildSheetMeta = (columns: ColumnLike[]) => {
|
||||
const fields: string[] = [];
|
||||
const fieldLabels: Record<string, any> = {};
|
||||
const formatMap: Record<string, (raw: any, row: any) => any> = {};
|
||||
|
||||
// The first entity-backed column is the name column; its exported value gets
|
||||
// the "<name> [Deleted.<id>]" marker for deleted rows.
|
||||
let nameMarked = false;
|
||||
columns.forEach((col) => {
|
||||
if (!col.dataIndex) return;
|
||||
const key = toFieldKey(col.dataIndex);
|
||||
@@ -50,22 +35,7 @@ const buildSheetMeta = (columns: ColumnLike[], deletedWord: string) => {
|
||||
fieldLabels[key] = col.title;
|
||||
if (Array.isArray(col.dataIndex)) {
|
||||
const path = col.dataIndex;
|
||||
if (!nameMarked) {
|
||||
nameMarked = true;
|
||||
const entityKey = path[0];
|
||||
const idKey = ID_KEY_BY_ENTITY[entityKey];
|
||||
formatMap[key] = (_raw, row) => {
|
||||
const entity = row?.[entityKey];
|
||||
return withDeletedMark(
|
||||
_.get(row, path),
|
||||
entity?.deleted,
|
||||
deletedWord,
|
||||
idKey ? entity?.identity?.current?.[idKey] : undefined
|
||||
);
|
||||
};
|
||||
} else {
|
||||
formatMap[key] = (_raw, row) => _.get(row, path);
|
||||
}
|
||||
formatMap[key] = (_raw, row) => _.get(row, path);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -74,20 +44,18 @@ const buildSheetMeta = (columns: ColumnLike[], deletedWord: string) => {
|
||||
|
||||
const useExportTable = () => {
|
||||
const store = useStore();
|
||||
const intl = useIntl();
|
||||
const modelsColumns = useModelsColumns();
|
||||
const apiKeysColumns = useAPIKeysColumns();
|
||||
const usersColumns = useUsersColumns();
|
||||
|
||||
const exportTable = () => {
|
||||
const deletedWord = intl.formatMessage({ id: 'usage.table.deleted' });
|
||||
const usersTableData = store.get(usersTableDataAtom);
|
||||
const apiKeysTableData = store.get(apiKeysTableDataAtom);
|
||||
const modelsTableData = store.get(modelsTableDataAtom);
|
||||
|
||||
const modelsMeta = buildSheetMeta(modelsColumns, deletedWord);
|
||||
const apiKeysMeta = buildSheetMeta(apiKeysColumns, deletedWord);
|
||||
const usersMeta = buildSheetMeta(usersColumns, deletedWord);
|
||||
const modelsMeta = buildSheetMeta(modelsColumns);
|
||||
const apiKeysMeta = buildSheetMeta(apiKeysColumns);
|
||||
const usersMeta = buildSheetMeta(usersColumns);
|
||||
|
||||
exportJsonToExcel({
|
||||
fileName: 'table_data.xlsx',
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { getGPUStackPlugin } from '@/plugins';
|
||||
import { AutoTooltip } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Tag } from 'antd';
|
||||
import { useMemo } from 'react';
|
||||
import DeletedTag from '../components/deleted-tag';
|
||||
import { BreakdownItem as ListItem } from '../config/types';
|
||||
|
||||
// Plugin slot: enterprise plugins can contribute extra columns to the
|
||||
@@ -51,22 +51,27 @@ const useModelsColumns = (): ColumnDef[] => {
|
||||
dataIndex: ['route', 'label'],
|
||||
key: 'route_name',
|
||||
render: (text: string, record: ListItem) => (
|
||||
<span className="flex items-center gap-8">
|
||||
<span className="flex items-center">
|
||||
<AutoTooltip
|
||||
ghost
|
||||
style={{ maxWidth: 400 }}
|
||||
title={<span>{text}</span>}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
record.route?.deleted ? 'text-tertiary' : 'text-primary'
|
||||
}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
<span className="text-primary">{text}</span>
|
||||
</AutoTooltip>
|
||||
{record.route?.deleted && (
|
||||
<DeletedTag id={record.route?.identity?.current?.route_id} />
|
||||
<Tag
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
borderRadius: 12,
|
||||
color: 'var(--ant-color-text-tertiary)',
|
||||
borderColor: 'var(--ant-color-split)',
|
||||
backgroundColor: 'transparent'
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
{intl.formatMessage({ id: 'usage.table.deleted' })}
|
||||
</Tag>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useModel } from '@@/plugin-model';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
queryResourceFilterMeta,
|
||||
@@ -9,54 +8,22 @@ export interface SelectOption {
|
||||
value: number;
|
||||
label: string;
|
||||
deleted?: boolean;
|
||||
// The signed-in user's own entry, sorted first and tagged "[Current Account]"
|
||||
// in the filter dropdown (matches the Tokens tab).
|
||||
isCurrent?: boolean;
|
||||
// ``org`` / ``user`` / ``group`` — set on organization options so the filter
|
||||
// can tag a personal (USER) consumer.
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
export interface ResourceMetaOptions {
|
||||
creators: SelectOption[];
|
||||
instances: SelectOption[];
|
||||
volumes: SelectOption[];
|
||||
// Platform-wide "All" view only (empty otherwise, backend-gated).
|
||||
organizations: SelectOption[];
|
||||
user_groups: SelectOption[];
|
||||
}
|
||||
|
||||
const EMPTY: ResourceMetaOptions = {
|
||||
creators: [],
|
||||
instances: [],
|
||||
volumes: [],
|
||||
organizations: [],
|
||||
user_groups: []
|
||||
volumes: []
|
||||
};
|
||||
|
||||
// Deleted entries sink to the bottom of the dropdown; live ones keep their
|
||||
// incoming order (sort is stable).
|
||||
const toOptions = (items: ResourceFilterOption[]): SelectOption[] =>
|
||||
items
|
||||
.map((i) => ({ value: i.id, label: i.label, deleted: i.deleted }))
|
||||
.sort((a, b) => Number(!!a.deleted) - Number(!!b.deleted));
|
||||
|
||||
// Tag the signed-in user's own entry. Ordering: current account first, deleted
|
||||
// entries last, everything else keeps its incoming order — matching the Tokens
|
||||
// tab's "[Current Account]" treatment while pushing stale rows out of the way.
|
||||
const toUserOptions = (
|
||||
items: ResourceFilterOption[],
|
||||
currentUserId?: number
|
||||
): SelectOption[] => {
|
||||
const options = items.map((i) => ({
|
||||
value: i.id,
|
||||
label: i.label,
|
||||
deleted: i.deleted,
|
||||
isCurrent: currentUserId != null && i.id === currentUserId
|
||||
}));
|
||||
const rank = (o: SelectOption) => (o.isCurrent ? 0 : o.deleted ? 2 : 1);
|
||||
return options.sort((a, b) => rank(a) - rank(b));
|
||||
};
|
||||
items.map((i) => ({ value: i.id, label: i.label, deleted: i.deleted }));
|
||||
|
||||
/**
|
||||
* Loads the resource tabs' filter dropdown sources in one call:
|
||||
@@ -72,38 +39,21 @@ export default function useResourceMeta(
|
||||
scope: 'self' | 'all' = 'all'
|
||||
): ResourceMetaOptions {
|
||||
const [meta, setMeta] = useState<ResourceMetaOptions>(EMPTY);
|
||||
const { initialState } = useModel('@@initialState');
|
||||
const currentUserId = initialState?.currentUser?.id;
|
||||
|
||||
useEffect(() => {
|
||||
queryResourceFilterMeta(scope)
|
||||
.then((res) =>
|
||||
setMeta({
|
||||
creators: toUserOptions(res.creators, currentUserId),
|
||||
creators: toOptions(res.creators),
|
||||
instances: toOptions(res.instances),
|
||||
volumes: toOptions(res.volumes),
|
||||
// Keep ``kind`` on org options so the filter can tag personal (USER)
|
||||
// consumers; deleted ones still sink to the bottom.
|
||||
organizations: res.organizations
|
||||
.map((i) => ({
|
||||
value: i.id,
|
||||
label: i.label,
|
||||
deleted: i.deleted,
|
||||
kind: i.kind
|
||||
}))
|
||||
.sort((a, b) => Number(!!a.deleted) - Number(!!b.deleted)),
|
||||
// Groups are never flagged deleted; keep incoming order.
|
||||
user_groups: res.user_groups.map((i) => ({
|
||||
value: i.id,
|
||||
label: i.label
|
||||
}))
|
||||
volumes: toOptions(res.volumes)
|
||||
})
|
||||
)
|
||||
.catch(() => {
|
||||
// Network/auth errors surface via the global interceptor; leave the
|
||||
// dropdowns empty rather than crashing the tab.
|
||||
});
|
||||
}, [scope, currentUserId]);
|
||||
}, [scope]);
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,10 @@
|
||||
import { exportJsonToExcel } from '@gpustack/core-ui/excel';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import dayjs from 'dayjs';
|
||||
import _ from 'lodash';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { GroupOption } from '../config';
|
||||
import { BreakdownItem, UsageFilterItem } from '../config/types';
|
||||
import useQueryTimeSeriesData from '../services/use-query-timeseries-data';
|
||||
import { withDeletedMark } from '../utils/deleted-label';
|
||||
|
||||
// group dimension → the id field inside ``identity.current`` (null for deleted
|
||||
// entities on the Tokens tab, so the marker degrades to just "[Deleted]").
|
||||
const GROUP_ID_KEY: Record<
|
||||
string,
|
||||
'route_id' | 'user_id' | 'api_key_id' | 'organization_id'
|
||||
> = {
|
||||
route: 'route_id',
|
||||
user: 'user_id',
|
||||
api_key: 'api_key_id',
|
||||
organization: 'organization_id'
|
||||
};
|
||||
|
||||
const DefaultDateConfig = {
|
||||
defaultRange: 29
|
||||
@@ -42,8 +28,6 @@ interface UseUsageFiltersParams {
|
||||
users?: UserOptionType[];
|
||||
api_keys?: GroupOptionType[];
|
||||
routes?: RouteOptionType[];
|
||||
organizations?: RouteOptionType[];
|
||||
user_groups?: RouteOptionType[];
|
||||
};
|
||||
chartFilters: {
|
||||
metric: string;
|
||||
@@ -69,16 +53,12 @@ interface UseUsageFiltersParams {
|
||||
routes?: FilterOptionType[];
|
||||
users?: FilterOptionType[];
|
||||
api_keys?: FilterOptionType[];
|
||||
organizations?: FilterOptionType[];
|
||||
user_groups?: FilterOptionType[];
|
||||
};
|
||||
commonFilters: {
|
||||
scope: string;
|
||||
routes: string[];
|
||||
users: string[];
|
||||
api_keys: string[];
|
||||
organizations: string[];
|
||||
user_groups: string[];
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
};
|
||||
@@ -94,7 +74,6 @@ export const useUsageFilters = ({
|
||||
autoFetchOnFilterChange = true,
|
||||
onFetchData
|
||||
}: UseUsageFiltersParams) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
activeRoutes: initialActiveRoutes = [],
|
||||
activeApiKeys: initialActiveApiKeys = [],
|
||||
@@ -122,8 +101,6 @@ export const useUsageFilters = ({
|
||||
routes: initialActiveRoutes,
|
||||
users: initialUsers || [],
|
||||
api_keys: extractSelectedValues(initialActiveApiKeys),
|
||||
organizations: [] as string[],
|
||||
user_groups: [] as string[],
|
||||
start_date:
|
||||
start_date ||
|
||||
dayjs()
|
||||
@@ -135,8 +112,6 @@ export const useUsageFilters = ({
|
||||
const routeOptions = metaData?.routes || [];
|
||||
const userOptions = metaData?.users || [];
|
||||
const apiKeyOptions = metaData?.api_keys || [];
|
||||
const organizationOptions = metaData?.organizations || [];
|
||||
const userGroupOptions = metaData?.user_groups || [];
|
||||
const [activeApiKeys, setActiveApiKeys] =
|
||||
useState<ValueType[][]>(initialActiveApiKeys);
|
||||
|
||||
@@ -174,8 +149,6 @@ export const useUsageFilters = ({
|
||||
routes?: FilterOptionType[];
|
||||
users?: FilterOptionType[];
|
||||
api_keys?: FilterOptionType[];
|
||||
organizations?: FilterOptionType[];
|
||||
user_groups?: FilterOptionType[];
|
||||
} = {};
|
||||
|
||||
if (selected.routes.length > 0) {
|
||||
@@ -206,24 +179,6 @@ export const useUsageFilters = ({
|
||||
}));
|
||||
}
|
||||
|
||||
if (selected.organizations.length > 0) {
|
||||
const orgSet = new Set(selected.organizations);
|
||||
filters.organizations = organizationOptions
|
||||
.filter((item) => orgSet.has(item.value))
|
||||
.map((item) => ({
|
||||
identity: item.identity
|
||||
}));
|
||||
}
|
||||
|
||||
if (selected.user_groups.length > 0) {
|
||||
const groupSet = new Set(selected.user_groups);
|
||||
filters.user_groups = userGroupOptions
|
||||
.filter((item) => groupSet.has(item.value))
|
||||
.map((item) => ({
|
||||
identity: item.identity
|
||||
}));
|
||||
}
|
||||
|
||||
return filters;
|
||||
};
|
||||
|
||||
@@ -301,7 +256,7 @@ export const useUsageFilters = ({
|
||||
};
|
||||
|
||||
const handleFilterChange = (
|
||||
type: 'routes' | 'users' | 'api_keys' | 'organizations' | 'user_groups',
|
||||
type: 'routes' | 'users' | 'api_keys',
|
||||
value: string[]
|
||||
) => {
|
||||
const selectedValues: string[] = value.map((item) => {
|
||||
@@ -333,21 +288,12 @@ export const useUsageFilters = ({
|
||||
|
||||
const dateMap: Record<string, Record<string, any>> = {};
|
||||
const groupLabels = new Set<string>();
|
||||
const deletedWord = intl.formatMessage({ id: 'usage.table.deleted' });
|
||||
|
||||
items.forEach((item) => {
|
||||
const date = item.date?.value;
|
||||
if (!date) return;
|
||||
const groupEntity = groupDim
|
||||
? (item[groupDim] as UsageFilterItem)
|
||||
: undefined;
|
||||
const groupLabel = groupDim
|
||||
? withDeletedMark(
|
||||
groupEntity?.label ?? '-',
|
||||
groupEntity?.deleted,
|
||||
deletedWord,
|
||||
groupEntity?.identity?.current?.[GROUP_ID_KEY[groupDim]]
|
||||
)
|
||||
? ((item[groupDim] as UsageFilterItem)?.label ?? '-')
|
||||
: metric;
|
||||
groupLabels.add(groupLabel);
|
||||
if (!dateMap[date]) dateMap[date] = { date };
|
||||
@@ -398,13 +344,9 @@ export const useUsageFilters = ({
|
||||
selectedRoutes: commonFilters.routes,
|
||||
selectedUsers: commonFilters.users,
|
||||
selectedApiKeys: commonFilters.api_keys,
|
||||
selectedOrganizations: commonFilters.organizations,
|
||||
selectedUserGroups: commonFilters.user_groups,
|
||||
routeOptions,
|
||||
userOptions,
|
||||
apiKeyOptions,
|
||||
organizationOptions,
|
||||
userGroupOptions,
|
||||
activeApiKeys,
|
||||
handleSearch,
|
||||
handleActiveApiKeysChange,
|
||||
@@ -413,10 +355,6 @@ export const useUsageFilters = ({
|
||||
onRoutesChange: (value: string[]) => handleFilterChange('routes', value),
|
||||
onUsersChange: (value: string[]) => handleFilterChange('users', value),
|
||||
onApiKeysChange: (value: string[]) => handleFilterChange('api_keys', value),
|
||||
onOrganizationsChange: (value: string[]) =>
|
||||
handleFilterChange('organizations', value),
|
||||
onUserGroupsChange: (value: string[]) =>
|
||||
handleFilterChange('user_groups', value),
|
||||
onExportChart: handleOnExportChart
|
||||
};
|
||||
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
// columns.ts
|
||||
import { AutoTooltip } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Tag } from 'antd';
|
||||
import { useMemo } from 'react';
|
||||
import DeletedTag from '../components/deleted-tag';
|
||||
import { BreakdownItem as ListItem } from '../config/types';
|
||||
|
||||
interface ColumnsHookProps {
|
||||
sortOrder: string[];
|
||||
}
|
||||
|
||||
const useUsersColumns = () => {
|
||||
const useModelsColumns = (): Array<{
|
||||
title: string;
|
||||
dataIndex: string | string[];
|
||||
key: string;
|
||||
}> => {
|
||||
const intl = useIntl();
|
||||
|
||||
return useMemo(() => {
|
||||
@@ -19,22 +23,27 @@ const useUsersColumns = () => {
|
||||
dataIndex: ['user', 'identity', 'value', 'user_name'],
|
||||
key: 'user_name',
|
||||
render: (text: string, record: ListItem) => (
|
||||
<span className="flex items-center gap-8">
|
||||
<span className="flex items-center">
|
||||
<AutoTooltip
|
||||
ghost
|
||||
style={{ maxWidth: 400 }}
|
||||
title={<span>{text}</span>}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
record.user?.deleted ? 'text-tertiary' : 'text-primary'
|
||||
}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
<span className="text-primary">{text}</span>
|
||||
</AutoTooltip>
|
||||
{record.user?.deleted && (
|
||||
<DeletedTag id={record.user?.identity?.current?.user_id} />
|
||||
<Tag
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
borderRadius: 12,
|
||||
color: 'var(--ant-color-text-tertiary)',
|
||||
borderColor: 'var(--ant-color-split)',
|
||||
backgroundColor: 'transparent'
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
{intl.formatMessage({ id: 'usage.table.deleted' })}
|
||||
</Tag>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
@@ -123,4 +132,4 @@ const useUsersColumns = () => {
|
||||
}, [intl]);
|
||||
};
|
||||
|
||||
export default useUsersColumns;
|
||||
export default useModelsColumns;
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
* Talks to the new ``/usage/gpu-instances/{meta,breakdown}`` endpoints.
|
||||
*/
|
||||
import useCoolColors from '@/hooks/use-cool-colors';
|
||||
import { getGPUStackPlugin } from '@/plugins';
|
||||
import { formatLargeNumber } from '@/utils';
|
||||
import { SimpleCard } from '@gpustack/core-ui';
|
||||
import { useAccess, useIntl } from '@umijs/max';
|
||||
@@ -28,7 +27,6 @@ import ResourceFilterBar from '../components/resource-filter-bar';
|
||||
import useResourceMeta from '../hooks/use-resource-meta';
|
||||
import {
|
||||
exportBreakdownSheets,
|
||||
markDeletedNames,
|
||||
toExportColumns
|
||||
} from '../utils/export-breakdown';
|
||||
import {
|
||||
@@ -45,31 +43,6 @@ type Scope = 'self' | 'all';
|
||||
type Metric = 'gpu_hours' | 'instance_hours';
|
||||
type GroupKey = 'gpu_type' | 'instance' | 'user';
|
||||
|
||||
// Enterprise-provided extra bottom sub-tab (e.g. the Organization breakdown).
|
||||
// Registered on the enterprise plugin under ``usage.resourceBreakdownExtraTabs``;
|
||||
// empty when no plugin is loaded, so the OSS build renders nothing extra.
|
||||
interface ResourceBreakdownExtraTab {
|
||||
key: string;
|
||||
labelId: string;
|
||||
// Plain function (not a hook) — called during render to gate the tab; the
|
||||
// plugin reads any runtime state non-reactively (Rules of Hooks).
|
||||
isVisible?: (ctx: { scope: Scope }) => boolean;
|
||||
Component: React.ComponentType<{
|
||||
tab: 'gpu-instances' | 'storage';
|
||||
dateRange: [dayjs.Dayjs, dayjs.Dayjs];
|
||||
scope: Scope;
|
||||
filters: {
|
||||
creator_ids?: number[];
|
||||
instance_ids?: number[];
|
||||
volume_ids?: number[];
|
||||
organization_ids?: number[];
|
||||
user_group_ids?: number[];
|
||||
};
|
||||
pageResetKey?: number;
|
||||
refreshKey?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
const GpuInstancesTab: React.FC = () => {
|
||||
const access = useAccess();
|
||||
const intl = useIntl();
|
||||
@@ -90,13 +63,13 @@ const GpuInstancesTab: React.FC = () => {
|
||||
|
||||
const TABLE_TABS: { key: GroupKey; label: string }[] = useMemo(() => {
|
||||
const tabs = [
|
||||
{
|
||||
key: 'instance' as GroupKey,
|
||||
label: intl.formatMessage({ id: 'usage.table.instances' })
|
||||
},
|
||||
{
|
||||
key: 'gpu_type' as GroupKey,
|
||||
label: intl.formatMessage({ id: 'usage.table.instanceTypes' })
|
||||
},
|
||||
{
|
||||
key: 'instance' as GroupKey,
|
||||
label: intl.formatMessage({ id: 'usage.table.instances' })
|
||||
}
|
||||
];
|
||||
// Managers see the org-wide User breakdown; members only their own rows.
|
||||
@@ -125,11 +98,6 @@ const GpuInstancesTab: React.FC = () => {
|
||||
]);
|
||||
const [selectedUsers, setSelectedUsers] = useState<number[]>([]);
|
||||
const [selectedInstances, setSelectedInstances] = useState<number[]>([]);
|
||||
// Platform-wide "All" view only (enterprise-gated); empty otherwise.
|
||||
const [selectedOrganizations, setSelectedOrganizations] = useState<number[]>(
|
||||
[]
|
||||
);
|
||||
const [selectedUserGroups, setSelectedUserGroups] = useState<number[]>([]);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [metric, setMetric] = useState<Metric>('gpu_hours');
|
||||
const [granularity, setGranularity] = useState<Granularity>('day');
|
||||
@@ -137,21 +105,10 @@ const GpuInstancesTab: React.FC = () => {
|
||||
const [chartGroupBy, setChartGroupBy] = useState<GroupKey | null>(null);
|
||||
// ``null`` group_by = no row grouping, just the summary KPIs.
|
||||
// The chart needs the ``date`` group; tables use the active table tab.
|
||||
// Widened to ``string``: enterprise extra tabs use arbitrary keys.
|
||||
const [activeTableTab, setActiveTableTab] = useState<string>('instance');
|
||||
const [activeTableTab, setActiveTableTab] = useState<GroupKey>('gpu_type');
|
||||
|
||||
const {
|
||||
creators: userOptions,
|
||||
instances: instanceOptions,
|
||||
organizations,
|
||||
user_groups: userGroups
|
||||
} = useResourceMeta(scope);
|
||||
|
||||
// Enterprise-provided extra bottom sub-tabs (empty in the OSS build).
|
||||
// Visibility is a plain ``isVisible(ctx)`` function, evaluated at render —
|
||||
// no hooks in a loop.
|
||||
const extraBreakdownTabs: ResourceBreakdownExtraTab[] =
|
||||
getGPUStackPlugin()?.usage?.resourceBreakdownExtraTabs ?? [];
|
||||
const { creators: userOptions, instances: instanceOptions } =
|
||||
useResourceMeta(scope);
|
||||
|
||||
// The daily chart fetches group_by=date here; each bottom table owns its own
|
||||
// fetch (group_by=tab key) inside InstancesBreakdownTable. Bumped on any
|
||||
@@ -164,37 +121,20 @@ const GpuInstancesTab: React.FC = () => {
|
||||
fetchData: fetchChartData
|
||||
} = useQueryGpuInstancesBreakdown({ key: 'gpuInstancesBreakdownChart' });
|
||||
|
||||
// Single source of truth for the active filter set — memoized so its
|
||||
// reference only changes when a selection changes (an inline object would
|
||||
// refire the extra tab's fetch effect on every render), and shared by both
|
||||
// the chart request and the enterprise extra tab (no duplicated spread).
|
||||
const breakdownFilters = useMemo(
|
||||
() => ({
|
||||
...(selectedUsers.length ? { creator_ids: selectedUsers } : {}),
|
||||
...(selectedInstances.length ? { instance_ids: selectedInstances } : {}),
|
||||
...(selectedOrganizations.length
|
||||
? { organization_ids: selectedOrganizations }
|
||||
: {}),
|
||||
...(selectedUserGroups.length
|
||||
? { user_group_ids: selectedUserGroups }
|
||||
: {})
|
||||
}),
|
||||
[
|
||||
selectedUsers,
|
||||
selectedInstances,
|
||||
selectedOrganizations,
|
||||
selectedUserGroups
|
||||
]
|
||||
);
|
||||
|
||||
const baseRequest = (): Omit<ResourceBreakdownRequest, 'group_by'> => ({
|
||||
start_date: dateRange[0].format('YYYY-MM-DD'),
|
||||
end_date: dateRange[1].format('YYYY-MM-DD'),
|
||||
scope,
|
||||
granularity,
|
||||
filters: Object.keys(breakdownFilters).length
|
||||
? breakdownFilters
|
||||
: undefined,
|
||||
filters:
|
||||
selectedUsers.length || selectedInstances.length
|
||||
? {
|
||||
...(selectedUsers.length ? { creator_ids: selectedUsers } : {}),
|
||||
...(selectedInstances.length
|
||||
? { instance_ids: selectedInstances }
|
||||
: {})
|
||||
}
|
||||
: undefined,
|
||||
page: 1,
|
||||
perPage: 50
|
||||
});
|
||||
@@ -218,8 +158,6 @@ const GpuInstancesTab: React.FC = () => {
|
||||
dateRange,
|
||||
selectedUsers,
|
||||
selectedInstances,
|
||||
selectedOrganizations,
|
||||
selectedUserGroups,
|
||||
granularity,
|
||||
chartGroupBy,
|
||||
refreshKey
|
||||
@@ -364,13 +302,9 @@ const GpuInstancesTab: React.FC = () => {
|
||||
})
|
||||
)
|
||||
);
|
||||
const deletedWord = intl.formatMessage({ id: 'usage.table.deleted' });
|
||||
exportBreakdownSheets(
|
||||
tableExportGroups.map((g, i) => ({
|
||||
// The Instance Types sheet's name (``gpu_type``) is already marked by
|
||||
// the adapter; the instance / user sheets keep the clean name (the
|
||||
// table renders a tag) so mark it here for the export only.
|
||||
rows: markDeletedNames(results[i]?.items ?? [], g.key, deletedWord),
|
||||
rows: results[i]?.items ?? [],
|
||||
columns: toExportColumns(g.columns),
|
||||
sheetName: g.sheetName
|
||||
})),
|
||||
@@ -384,21 +318,6 @@ const GpuInstancesTab: React.FC = () => {
|
||||
dataIndex: 'date',
|
||||
key: 'date'
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({ id: 'usage.table.instance' }),
|
||||
dataIndex: 'instance_name',
|
||||
key: 'instance_name'
|
||||
},
|
||||
// Owner User column — org admins only (members see just their own rows).
|
||||
...(canManageUsers
|
||||
? [
|
||||
{
|
||||
title: intl.formatMessage({ id: 'usage.table.users' }),
|
||||
dataIndex: 'user_name',
|
||||
key: 'user_name'
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: intl.formatMessage({ id: 'usage.metric.gpuHours' }),
|
||||
dataIndex: 'gpu_hours',
|
||||
@@ -425,7 +344,7 @@ const GpuInstancesTab: React.FC = () => {
|
||||
|
||||
// The preview modal now only backs the by-date chart export.
|
||||
const exportConfig = {
|
||||
groupBy: ['date', 'instance'],
|
||||
groupBy: ['date'],
|
||||
columns: chartExportColumns,
|
||||
fileName: `gpu-instances_chart_${dateSuffix}.xlsx`,
|
||||
sheetName: intl.formatMessage({ id: 'usage.tabs.gpuInstances' })
|
||||
@@ -456,18 +375,6 @@ const GpuInstancesTab: React.FC = () => {
|
||||
},
|
||||
placeholder: intl.formatMessage({ id: 'usage.filter.instance' })
|
||||
}}
|
||||
organizationOptions={organizations}
|
||||
userGroupOptions={userGroups}
|
||||
selectedOrganizations={selectedOrganizations}
|
||||
selectedUserGroups={selectedUserGroups}
|
||||
onOrganizationsChange={(ids) => {
|
||||
setSelectedOrganizations(ids);
|
||||
setPageResetKey((k) => k + 1);
|
||||
}}
|
||||
onUserGroupsChange={(ids) => {
|
||||
setSelectedUserGroups(ids);
|
||||
setPageResetKey((k) => k + 1);
|
||||
}}
|
||||
onRefresh={() => setRefreshKey((k) => k + 1)}
|
||||
onExportChart={() => setExportMode('chart')}
|
||||
onExportTable={handleExportTable}
|
||||
@@ -508,49 +415,26 @@ const GpuInstancesTab: React.FC = () => {
|
||||
{/* Bottom tabs + table */}
|
||||
<Tabs
|
||||
activeKey={activeTableTab}
|
||||
onChange={(k) => setActiveTableTab(k)}
|
||||
items={[
|
||||
...TABLE_TABS.map((t) => ({
|
||||
key: t.key,
|
||||
label: t.label,
|
||||
// Keep every pane mounted so each table holds its own page/sort and
|
||||
// switching tabs neither refetches nor resets the others.
|
||||
forceRender: true,
|
||||
children: (
|
||||
<InstancesBreakdownTable
|
||||
key={t.key}
|
||||
groupKey={t.key}
|
||||
dateRange={dateRange}
|
||||
scope={scope}
|
||||
selectedUsers={selectedUsers}
|
||||
selectedInstances={selectedInstances}
|
||||
selectedOrganizations={selectedOrganizations}
|
||||
selectedUserGroups={selectedUserGroups}
|
||||
pageResetKey={pageResetKey}
|
||||
refreshKey={refreshKey}
|
||||
/>
|
||||
)
|
||||
})),
|
||||
// Enterprise Organization breakdown sub-tab(s) — appended after the
|
||||
// built-in tabs; nothing here in the OSS build.
|
||||
...extraBreakdownTabs
|
||||
.filter((t) => (t.isVisible ? t.isVisible({ scope }) : true))
|
||||
.map((t) => ({
|
||||
key: t.key,
|
||||
label: intl.formatMessage({ id: t.labelId }),
|
||||
forceRender: true,
|
||||
children: (
|
||||
<t.Component
|
||||
tab="gpu-instances"
|
||||
dateRange={dateRange}
|
||||
scope={scope}
|
||||
filters={breakdownFilters}
|
||||
pageResetKey={pageResetKey}
|
||||
refreshKey={refreshKey}
|
||||
/>
|
||||
)
|
||||
}))
|
||||
]}
|
||||
onChange={(k) => setActiveTableTab(k as GroupKey)}
|
||||
items={TABLE_TABS.map((t) => ({
|
||||
key: t.key,
|
||||
label: t.label,
|
||||
// Keep every pane mounted so each table holds its own page/sort and
|
||||
// switching tabs neither refetches nor resets the others.
|
||||
forceRender: true,
|
||||
children: (
|
||||
<InstancesBreakdownTable
|
||||
key={t.key}
|
||||
groupKey={t.key}
|
||||
dateRange={dateRange}
|
||||
scope={scope}
|
||||
selectedUsers={selectedUsers}
|
||||
selectedInstances={selectedInstances}
|
||||
pageResetKey={pageResetKey}
|
||||
refreshKey={refreshKey}
|
||||
/>
|
||||
)
|
||||
}))}
|
||||
/>
|
||||
|
||||
<ResourceExportData
|
||||
@@ -573,24 +457,6 @@ const GpuInstancesTab: React.FC = () => {
|
||||
initialDateRange={dateRange}
|
||||
initialSelectedUsers={selectedUsers}
|
||||
initialSelectedResources={selectedInstances}
|
||||
organizationOptions={organizations}
|
||||
userGroupOptions={userGroups}
|
||||
initialSelectedOrganizations={selectedOrganizations}
|
||||
initialSelectedUserGroups={selectedUserGroups}
|
||||
deletedNameFields={[
|
||||
// The row's ``deleted`` is the grouped instance; the owner user
|
||||
// carries its own ``user_deleted``.
|
||||
{ name: 'instance_name', id: 'instance_id', deletedFlag: 'deleted' },
|
||||
...(canManageUsers
|
||||
? [
|
||||
{
|
||||
name: 'user_name',
|
||||
id: 'user_id',
|
||||
deletedFlag: 'user_deleted'
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -24,9 +24,6 @@ interface Props {
|
||||
scope: Scope;
|
||||
selectedUsers: number[];
|
||||
selectedInstances: number[];
|
||||
// Platform-wide "All" view only (enterprise-gated); empty otherwise.
|
||||
selectedOrganizations?: number[];
|
||||
selectedUserGroups?: number[];
|
||||
// Bumped by the parent when a filter changes, so each mounted table snaps
|
||||
// back to page 1 independently.
|
||||
pageResetKey?: number;
|
||||
@@ -45,8 +42,6 @@ const InstancesBreakdownTable: React.FC<Props> = ({
|
||||
scope,
|
||||
selectedUsers,
|
||||
selectedInstances,
|
||||
selectedOrganizations = [],
|
||||
selectedUserGroups = [],
|
||||
pageResetKey = 0,
|
||||
refreshKey = 0
|
||||
}) => {
|
||||
@@ -106,20 +101,11 @@ const InstancesBreakdownTable: React.FC<Props> = ({
|
||||
end_date: dateRange[1].format('YYYY-MM-DD'),
|
||||
scope,
|
||||
filters:
|
||||
selectedUsers.length ||
|
||||
selectedInstances.length ||
|
||||
selectedOrganizations.length ||
|
||||
selectedUserGroups.length
|
||||
selectedUsers.length || selectedInstances.length
|
||||
? {
|
||||
...(selectedUsers.length ? { creator_ids: selectedUsers } : {}),
|
||||
...(selectedInstances.length
|
||||
? { instance_ids: selectedInstances }
|
||||
: {}),
|
||||
...(selectedOrganizations.length
|
||||
? { organization_ids: selectedOrganizations }
|
||||
: {}),
|
||||
...(selectedUserGroups.length
|
||||
? { user_group_ids: selectedUserGroups }
|
||||
: {})
|
||||
}
|
||||
: undefined,
|
||||
@@ -134,8 +120,6 @@ const InstancesBreakdownTable: React.FC<Props> = ({
|
||||
scope,
|
||||
selectedUsers,
|
||||
selectedInstances,
|
||||
selectedOrganizations,
|
||||
selectedUserGroups,
|
||||
queryParams.page,
|
||||
queryParams.perPage,
|
||||
queryParams.sort_by,
|
||||
|
||||
@@ -2,11 +2,9 @@ import {
|
||||
buildInstanceTypeRecordFromMiB,
|
||||
renderInstanceType
|
||||
} from '@/pages/gpu-service/instances/utils/render-instance-type';
|
||||
import { AutoTooltip } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { useMemo } from 'react';
|
||||
import { ResourceBreakdownItem } from '../../apis/resource';
|
||||
import DeletedTag from '../../components/deleted-tag';
|
||||
import { instanceTypeSeriesLabel } from '../../utils/format-instance-type';
|
||||
import { parseRollup } from '../../utils/time-buckets';
|
||||
|
||||
@@ -110,22 +108,6 @@ const useInstancesColumns = (groupKey: GroupKey) => {
|
||||
key: 'last_active',
|
||||
render: (v?: string) => (v ? parseRollup(v).format('YYYY-MM-DD') : '-')
|
||||
};
|
||||
// Name cell with a DeletedTag when the entity no longer exists — mirrors the
|
||||
// Tokens tab. The id keeps two deleted rows sharing a stale name distinct.
|
||||
const renderName = (text: string, id?: number, deleted?: boolean) => (
|
||||
<span className="flex items-center gap-8">
|
||||
<AutoTooltip
|
||||
ghost
|
||||
style={{
|
||||
maxWidth: 400,
|
||||
...(deleted ? { color: 'var(--ant-color-text-tertiary)' } : null)
|
||||
}}
|
||||
>
|
||||
{text || '-'}
|
||||
</AutoTooltip>
|
||||
{deleted && <DeletedTag id={id ?? null} />}
|
||||
</span>
|
||||
);
|
||||
if (groupKey === 'gpu_type') {
|
||||
return [
|
||||
instanceTypeColType,
|
||||
@@ -141,11 +123,9 @@ const useInstancesColumns = (groupKey: GroupKey) => {
|
||||
if (groupKey === 'instance') {
|
||||
return [
|
||||
{
|
||||
title: intl.formatMessage({ id: 'common.table.name' }),
|
||||
title: intl.formatMessage({ id: 'usage.table.instance' }),
|
||||
dataIndex: 'instance_name',
|
||||
key: 'instance_name',
|
||||
render: (text: string, row: ResourceBreakdownItem) =>
|
||||
renderName(text, row.instance_id, row.deleted)
|
||||
key: 'instance_name'
|
||||
},
|
||||
instanceTypeColInstance,
|
||||
...baseValueCols,
|
||||
@@ -155,11 +135,9 @@ const useInstancesColumns = (groupKey: GroupKey) => {
|
||||
// user tab
|
||||
return [
|
||||
{
|
||||
title: intl.formatMessage({ id: 'common.table.name' }),
|
||||
title: intl.formatMessage({ id: 'usage.table.user' }),
|
||||
dataIndex: 'user_name',
|
||||
key: 'user_name',
|
||||
render: (text: string, row: ResourceBreakdownItem) =>
|
||||
renderName(text, row.user_id, row.deleted)
|
||||
key: 'user_name'
|
||||
},
|
||||
...baseValueCols,
|
||||
lastActiveCol
|
||||
|
||||
@@ -13,16 +13,6 @@ type RouteOptionType = UsageFilterItem & {
|
||||
value: string;
|
||||
};
|
||||
|
||||
// The multi-select keys options by ``value``; two entries sharing a display
|
||||
// name (e.g. an active model and a deleted one with the same name) must get
|
||||
// DIFFERENT values or selecting one selects the other. Prefer the real id;
|
||||
// id-less (deleted) entries fall back to a per-row token so they stay
|
||||
// independently selectable.
|
||||
const optionValue = (
|
||||
id: string | number | null | undefined,
|
||||
index: number
|
||||
): string => (id != null ? `id:${id}` : `row:${index}`);
|
||||
|
||||
export default function useQueryUsageMetaData() {
|
||||
const { detailData, loading, cancelRequest, fetchData } =
|
||||
useQueryData<UsageMeta>({
|
||||
@@ -31,39 +21,48 @@ export default function useQueryUsageMetaData() {
|
||||
});
|
||||
const { initialState } = useModel('@@initialState');
|
||||
const [result, setResult] = useState<{
|
||||
models: GroupOption<UsageFilterItem>[];
|
||||
users: UserOptionType[];
|
||||
api_keys: GroupOption<UsageFilterItem>[];
|
||||
routes: RouteOptionType[];
|
||||
organizations: RouteOptionType[];
|
||||
user_groups: RouteOptionType[];
|
||||
}>({
|
||||
models: [],
|
||||
users: [],
|
||||
api_keys: [],
|
||||
routes: [],
|
||||
organizations: [],
|
||||
user_groups: []
|
||||
routes: []
|
||||
});
|
||||
|
||||
// Current account first, deleted entries last, everything else keeps its
|
||||
// incoming order (sort is stable).
|
||||
// the current user sort in the first place
|
||||
const sortUsers = (users: UsageFilterItem[]) => {
|
||||
const currentUserId = initialState?.currentUser?.id;
|
||||
const rank = (u: UsageFilterItem) =>
|
||||
currentUserId && u.identity.current?.user_id === currentUserId
|
||||
? 0
|
||||
: u.deleted
|
||||
? 2
|
||||
: 1;
|
||||
return [...users].sort((a, b) => rank(a) - rank(b));
|
||||
if (!currentUserId) return users;
|
||||
|
||||
const sortedUsers = [...users].sort((a, b) => {
|
||||
if (a.identity.current?.user_id === currentUserId) return -1;
|
||||
if (b.identity.current?.user_id === currentUserId) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return sortedUsers;
|
||||
};
|
||||
|
||||
const queryMetaData = async () => {
|
||||
const res = await fetchData({});
|
||||
const sortedUsers = sortUsers(res?.filters?.users || []);
|
||||
const data = {
|
||||
models: groupToOptions(res?.filters?.models || [], {
|
||||
getGroupKey: (item) => item.identity.value.provider_name || 'gpustack',
|
||||
getGroupType: (item) =>
|
||||
item.identity.value.provider_type || 'deployments',
|
||||
getChild: (item) => ({
|
||||
...item,
|
||||
value: item.label,
|
||||
label: item.identity.value.model_name || ''
|
||||
})
|
||||
}),
|
||||
users:
|
||||
sortedUsers.map((item, index) => ({
|
||||
value: optionValue(item.identity.current?.user_id, index),
|
||||
sortedUsers.map((item) => ({
|
||||
value: item.label,
|
||||
isCurrent:
|
||||
item.identity.current?.user_id === initialState?.currentUser?.id,
|
||||
...item
|
||||
@@ -72,39 +71,16 @@ export default function useQueryUsageMetaData() {
|
||||
getGroupKey: (item) => item.identity.value.user_name || 'unknown_user',
|
||||
getGroupType: (item) =>
|
||||
item.identity.value.api_key_is_custom ? 'custom' : 'default',
|
||||
getChild: (item, index) => ({
|
||||
getChild: (item) => ({
|
||||
...item,
|
||||
value: optionValue(item.identity.current?.api_key_id, index),
|
||||
value: item.label,
|
||||
label: item.identity.value.api_key_name || ''
|
||||
})
|
||||
// Deleted keys sink to the bottom within each user group.
|
||||
}).map((group) => ({
|
||||
...group,
|
||||
children: [...group.children].sort(
|
||||
(a, b) => Number(!!(a as any).deleted) - Number(!!(b as any).deleted)
|
||||
)
|
||||
})),
|
||||
// Deleted models sink to the bottom of the dropdown.
|
||||
}),
|
||||
routes:
|
||||
(res?.filters?.routes || [])
|
||||
.map((item, index) => ({
|
||||
...item,
|
||||
value: optionValue(item.identity.current?.route_id, index)
|
||||
}))
|
||||
.sort((a, b) => Number(!!a.deleted) - Number(!!b.deleted)) || [],
|
||||
// Platform-wide "All" view only (backend returns these empty otherwise).
|
||||
// Deleted orgs sink to the bottom; groups are never flagged deleted.
|
||||
organizations:
|
||||
(res?.filters?.organizations || [])
|
||||
.map((item, index) => ({
|
||||
...item,
|
||||
value: optionValue(item.identity.current?.organization_id, index)
|
||||
}))
|
||||
.sort((a, b) => Number(!!a.deleted) - Number(!!b.deleted)) || [],
|
||||
user_groups:
|
||||
(res?.filters?.user_groups || []).map((item, index) => ({
|
||||
(res?.filters?.routes || []).map((item) => ({
|
||||
...item,
|
||||
value: optionValue(item.identity.current?.group_id, index)
|
||||
value: item.label
|
||||
})) || []
|
||||
};
|
||||
setResult(data);
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
* so there's no phase filter — just date / scope / volume / user.
|
||||
*/
|
||||
import useCoolColors from '@/hooks/use-cool-colors';
|
||||
import { getGPUStackPlugin } from '@/plugins';
|
||||
import { formatLargeNumber } from '@/utils';
|
||||
import { SimpleCard } from '@gpustack/core-ui';
|
||||
import { useAccess, useIntl } from '@umijs/max';
|
||||
@@ -31,7 +30,6 @@ import ResourceFilterBar from '../components/resource-filter-bar';
|
||||
import useResourceMeta from '../hooks/use-resource-meta';
|
||||
import {
|
||||
exportBreakdownSheets,
|
||||
markDeletedNames,
|
||||
toExportColumns
|
||||
} from '../utils/export-breakdown';
|
||||
import {
|
||||
@@ -48,31 +46,6 @@ type Scope = 'self' | 'all';
|
||||
type Metric = 'storage_gb_days' | 'storage_gb_hours';
|
||||
type GroupKey = 'volume' | 'user';
|
||||
|
||||
// Enterprise-provided extra bottom sub-tab (e.g. the Organization breakdown).
|
||||
// Registered on the enterprise plugin under ``usage.resourceBreakdownExtraTabs``;
|
||||
// empty when no plugin is loaded, so the OSS build renders nothing extra.
|
||||
interface ResourceBreakdownExtraTab {
|
||||
key: string;
|
||||
labelId: string;
|
||||
// Plain function (not a hook) — called during render to gate the tab; the
|
||||
// plugin reads any runtime state non-reactively (Rules of Hooks).
|
||||
isVisible?: (ctx: { scope: Scope }) => boolean;
|
||||
Component: React.ComponentType<{
|
||||
tab: 'gpu-instances' | 'storage';
|
||||
dateRange: [dayjs.Dayjs, dayjs.Dayjs];
|
||||
scope: Scope;
|
||||
filters: {
|
||||
creator_ids?: number[];
|
||||
instance_ids?: number[];
|
||||
volume_ids?: number[];
|
||||
organization_ids?: number[];
|
||||
user_group_ids?: number[];
|
||||
};
|
||||
pageResetKey?: number;
|
||||
refreshKey?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
const StorageTab: React.FC = () => {
|
||||
const access = useAccess();
|
||||
const intl = useIntl();
|
||||
@@ -122,31 +95,15 @@ const StorageTab: React.FC = () => {
|
||||
]);
|
||||
const [selectedUsers, setSelectedUsers] = useState<number[]>([]);
|
||||
const [selectedVolumes, setSelectedVolumes] = useState<number[]>([]);
|
||||
// Platform-wide "All" view only (enterprise-gated); empty otherwise.
|
||||
const [selectedOrganizations, setSelectedOrganizations] = useState<number[]>(
|
||||
[]
|
||||
);
|
||||
const [selectedUserGroups, setSelectedUserGroups] = useState<number[]>([]);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [metric, setMetric] = useState<Metric>('storage_gb_days');
|
||||
const [granularity, setGranularity] = useState<Granularity>('day');
|
||||
// Optional trend group-by (split the chart into one series per group).
|
||||
const [chartGroupBy, setChartGroupBy] = useState<GroupKey | null>(null);
|
||||
// Widened to ``string``: enterprise extra tabs use arbitrary keys.
|
||||
const [activeTableTab, setActiveTableTab] = useState<string>('volume');
|
||||
const [activeTableTab, setActiveTableTab] = useState<GroupKey>('volume');
|
||||
|
||||
const {
|
||||
creators: userOptions,
|
||||
volumes: volumeOptions,
|
||||
organizations,
|
||||
user_groups: userGroups
|
||||
} = useResourceMeta(scope);
|
||||
|
||||
// Enterprise-provided extra bottom sub-tabs (empty in the OSS build).
|
||||
// Visibility is a plain ``isVisible(ctx)`` function, evaluated at render —
|
||||
// no hooks in a loop.
|
||||
const extraBreakdownTabs: ResourceBreakdownExtraTab[] =
|
||||
getGPUStackPlugin()?.usage?.resourceBreakdownExtraTabs ?? [];
|
||||
const { creators: userOptions, volumes: volumeOptions } =
|
||||
useResourceMeta(scope);
|
||||
|
||||
// Bumped on any filter change to snap every mounted table back to page 1;
|
||||
// each table owns its own page/sort state otherwise.
|
||||
@@ -158,32 +115,18 @@ const StorageTab: React.FC = () => {
|
||||
fetchData: fetchChartData
|
||||
} = useQueryStorageBreakdown({ key: 'storageBreakdownChart' });
|
||||
|
||||
// Single source of truth for the active filter set — memoized so its
|
||||
// reference only changes when a selection changes (an inline object would
|
||||
// refire the extra tab's fetch effect on every render), and shared by both
|
||||
// the chart request and the enterprise extra tab (no duplicated spread).
|
||||
const breakdownFilters = useMemo(
|
||||
() => ({
|
||||
...(selectedUsers.length ? { creator_ids: selectedUsers } : {}),
|
||||
...(selectedVolumes.length ? { volume_ids: selectedVolumes } : {}),
|
||||
...(selectedOrganizations.length
|
||||
? { organization_ids: selectedOrganizations }
|
||||
: {}),
|
||||
...(selectedUserGroups.length
|
||||
? { user_group_ids: selectedUserGroups }
|
||||
: {})
|
||||
}),
|
||||
[selectedUsers, selectedVolumes, selectedOrganizations, selectedUserGroups]
|
||||
);
|
||||
|
||||
const baseRequest = (): Omit<ResourceBreakdownRequest, 'group_by'> => ({
|
||||
start_date: dateRange[0].format('YYYY-MM-DD'),
|
||||
end_date: dateRange[1].format('YYYY-MM-DD'),
|
||||
scope,
|
||||
granularity,
|
||||
filters: Object.keys(breakdownFilters).length
|
||||
? breakdownFilters
|
||||
: undefined,
|
||||
filters:
|
||||
selectedUsers.length || selectedVolumes.length
|
||||
? {
|
||||
...(selectedUsers.length ? { creator_ids: selectedUsers } : {}),
|
||||
...(selectedVolumes.length ? { volume_ids: selectedVolumes } : {})
|
||||
}
|
||||
: undefined,
|
||||
page: 1,
|
||||
perPage: 50
|
||||
});
|
||||
@@ -208,8 +151,6 @@ const StorageTab: React.FC = () => {
|
||||
dateRange,
|
||||
selectedUsers,
|
||||
selectedVolumes,
|
||||
selectedOrganizations,
|
||||
selectedUserGroups,
|
||||
granularity,
|
||||
chartGroupBy,
|
||||
refreshKey
|
||||
@@ -348,10 +289,9 @@ const StorageTab: React.FC = () => {
|
||||
})
|
||||
)
|
||||
);
|
||||
const deletedWord = intl.formatMessage({ id: 'usage.table.deleted' });
|
||||
exportBreakdownSheets(
|
||||
tableExportGroups.map((g, i) => ({
|
||||
rows: markDeletedNames(results[i]?.items ?? [], g.key, deletedWord),
|
||||
rows: results[i]?.items ?? [],
|
||||
columns: toExportColumns(g.columns),
|
||||
sheetName: g.sheetName
|
||||
})),
|
||||
@@ -365,21 +305,6 @@ const StorageTab: React.FC = () => {
|
||||
dataIndex: 'date',
|
||||
key: 'date'
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({ id: 'usage.tabs.storage' }),
|
||||
dataIndex: 'volume_name',
|
||||
key: 'volume_name'
|
||||
},
|
||||
// Owner User column — org admins only (members see just their own rows).
|
||||
...(canManageUsers
|
||||
? [
|
||||
{
|
||||
title: intl.formatMessage({ id: 'usage.table.users' }),
|
||||
dataIndex: 'user_name',
|
||||
key: 'user_name'
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: intl.formatMessage({ id: 'usage.metric.gbDays' }),
|
||||
dataIndex: 'storage_gb_days',
|
||||
@@ -406,7 +331,7 @@ const StorageTab: React.FC = () => {
|
||||
|
||||
// The preview modal now only backs the by-date chart export.
|
||||
const exportConfig = {
|
||||
groupBy: ['date', 'volume'],
|
||||
groupBy: ['date'],
|
||||
columns: chartExportColumns,
|
||||
fileName: `storage_chart_${dateSuffix}.xlsx`,
|
||||
sheetName: intl.formatMessage({ id: 'usage.tabs.storage' })
|
||||
@@ -436,18 +361,6 @@ const StorageTab: React.FC = () => {
|
||||
},
|
||||
placeholder: intl.formatMessage({ id: 'usage.filter.storage' })
|
||||
}}
|
||||
organizationOptions={organizations}
|
||||
userGroupOptions={userGroups}
|
||||
selectedOrganizations={selectedOrganizations}
|
||||
selectedUserGroups={selectedUserGroups}
|
||||
onOrganizationsChange={(ids) => {
|
||||
setSelectedOrganizations(ids);
|
||||
setPageResetKey((k) => k + 1);
|
||||
}}
|
||||
onUserGroupsChange={(ids) => {
|
||||
setSelectedUserGroups(ids);
|
||||
setPageResetKey((k) => k + 1);
|
||||
}}
|
||||
onRefresh={() => setRefreshKey((k) => k + 1)}
|
||||
onExportChart={() => setExportMode('chart')}
|
||||
onExportTable={handleExportTable}
|
||||
@@ -485,49 +398,26 @@ const StorageTab: React.FC = () => {
|
||||
|
||||
<Tabs
|
||||
activeKey={activeTableTab}
|
||||
onChange={(k) => setActiveTableTab(k)}
|
||||
items={[
|
||||
...TABLE_TABS.map((t) => ({
|
||||
key: t.key,
|
||||
label: t.label,
|
||||
// Keep every pane mounted so each table holds its own page/sort and
|
||||
// switching tabs neither refetches nor resets the other.
|
||||
forceRender: true,
|
||||
children: (
|
||||
<StorageBreakdownTable
|
||||
key={t.key}
|
||||
groupKey={t.key}
|
||||
dateRange={dateRange}
|
||||
scope={scope}
|
||||
selectedUsers={selectedUsers}
|
||||
selectedVolumes={selectedVolumes}
|
||||
selectedOrganizations={selectedOrganizations}
|
||||
selectedUserGroups={selectedUserGroups}
|
||||
pageResetKey={pageResetKey}
|
||||
refreshKey={refreshKey}
|
||||
/>
|
||||
)
|
||||
})),
|
||||
// Enterprise Organization breakdown sub-tab(s) — appended after the
|
||||
// built-in tabs; nothing here in the OSS build.
|
||||
...extraBreakdownTabs
|
||||
.filter((t) => (t.isVisible ? t.isVisible({ scope }) : true))
|
||||
.map((t) => ({
|
||||
key: t.key,
|
||||
label: intl.formatMessage({ id: t.labelId }),
|
||||
forceRender: true,
|
||||
children: (
|
||||
<t.Component
|
||||
tab="storage"
|
||||
dateRange={dateRange}
|
||||
scope={scope}
|
||||
filters={breakdownFilters}
|
||||
pageResetKey={pageResetKey}
|
||||
refreshKey={refreshKey}
|
||||
/>
|
||||
)
|
||||
}))
|
||||
]}
|
||||
onChange={(k) => setActiveTableTab(k as GroupKey)}
|
||||
items={TABLE_TABS.map((t) => ({
|
||||
key: t.key,
|
||||
label: t.label,
|
||||
// Keep every pane mounted so each table holds its own page/sort and
|
||||
// switching tabs neither refetches nor resets the other.
|
||||
forceRender: true,
|
||||
children: (
|
||||
<StorageBreakdownTable
|
||||
key={t.key}
|
||||
groupKey={t.key}
|
||||
dateRange={dateRange}
|
||||
scope={scope}
|
||||
selectedUsers={selectedUsers}
|
||||
selectedVolumes={selectedVolumes}
|
||||
pageResetKey={pageResetKey}
|
||||
refreshKey={refreshKey}
|
||||
/>
|
||||
)
|
||||
}))}
|
||||
/>
|
||||
|
||||
<ResourceExportData
|
||||
@@ -550,24 +440,6 @@ const StorageTab: React.FC = () => {
|
||||
initialDateRange={dateRange}
|
||||
initialSelectedUsers={selectedUsers}
|
||||
initialSelectedResources={selectedVolumes}
|
||||
organizationOptions={organizations}
|
||||
userGroupOptions={userGroups}
|
||||
initialSelectedOrganizations={selectedOrganizations}
|
||||
initialSelectedUserGroups={selectedUserGroups}
|
||||
deletedNameFields={[
|
||||
// The row's ``deleted`` is the grouped volume; the owner user
|
||||
// carries its own ``user_deleted``.
|
||||
{ name: 'volume_name', id: 'volume_id', deletedFlag: 'deleted' },
|
||||
...(canManageUsers
|
||||
? [
|
||||
{
|
||||
name: 'user_name',
|
||||
id: 'user_id',
|
||||
deletedFlag: 'user_deleted'
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user