Compare commits

...
40 Commits
Author SHA1 Message Date
jialinandjialin 84304eadb7 fix(playgroundimage): allow to input size 2026-06-18 16:03:35 +08:00
jialinandjialin ab3739219e chore: update vllm, sglang, preset argument options 2026-06-18 16:03:35 +08:00
jialinandjialin 7cf4932f3a fix(deploymentlog): show the checkbox only when container exsiting previous 2026-06-18 11:50:19 +08:00
jialinandjialin 032f1eefef fix(deployments): show a prefix before model name in playground 2026-06-18 11:50:19 +08:00
jialinandjialin e35be94f7c fix(provider): filter bailian do not work 2026-06-18 11:50:19 +08:00
jialinandjialin e76e2ad85a fix(worker): IP cell text wrapping 2026-06-18 11:50:19 +08:00
jialinandjialin cb19cd2476 fix: show cpu only spec info 2026-06-17 20:11:06 +08:00
jialinandjialin c532732ce9 fix: show CPU Only in usage instanc types 2026-06-17 18:29:58 +08:00
jialinandjialin 2bc10dbb2e fix: bailian logo, instance logs params container_name 2026-06-17 16:49:25 +08:00
jialinandjialin b634a03ef9 fix: use alibaba cloud icon for qwen provider 2026-06-17 15:30:43 +08:00
jialinandjialin ac64bb98ad fix: Only display name editable when editing storage and storage type 2026-06-17 12:35:24 +08:00
jialinandjialin aa49539210 fix: select gpu stype when creating cluster from gpu service 2026-06-17 12:35:24 +08:00
jialinandjialin 1f5678f781 fix: update nividia notes 2026-06-17 12:35:24 +08:00
jialinandjialin 7adc089e94 fix: save the default_width, default_height in image size options 2026-06-17 12:35:24 +08:00
jialinandjialin 6e1e9c07ee fix: template name readonly in editting 2026-06-17 11:06:39 +08:00
jialinandjialin 1d55382f3b fix: fetch all model instance after stopping 2026-06-17 11:06:39 +08:00
jialinandjialin e68bb0bc4e fix: export data should contain all tables 2026-06-16 22:28:46 +08:00
jialinandjialin c8e14dade5 fix: remove vendor logo in template list 2026-06-16 22:28:46 +08:00
jialinandjialin e4c1a3f5c2 refactor: usage instance tab, split tables 2026-06-16 22:28:46 +08:00
jialinandjialin cdd6ef9cb3 refactor: use hook for fetch event 2026-06-16 22:28:46 +08:00
jialinandjialin 737d43fd16 refactor: use hook for fetch chart 2026-06-16 22:28:46 +08:00
jialinandjialin 9f5163531c refactor: usage storage tab, split tables 2026-06-16 22:28:46 +08:00
jialinandjialin df5cf0f4e5 chore: nvidia notes in add worker 2026-06-16 18:35:19 +08:00
jialinandjialin e160b64697 fix: do not show billing menu in ent 2026-06-16 18:35:19 +08:00
jialinandjialin 7d36c6ac4a revert: show model name from api directly in apikeys 2026-06-16 15:25:46 +08:00
jialinandjialin 0820edf55f fix: refresh data in card page list 2026-06-16 15:25:46 +08:00
jialinandjialin 12e324893f fix: gpu instance state color 2026-06-16 14:17:43 +08:00
jialinandjialin 4017c4dd33 style: table hover bg, remove description in billing 2026-06-16 14:17:43 +08:00
jialinandjialin 17b65d80e1 fix(style): API info access url overflow 2026-06-16 14:17:43 +08:00
jialinandjialin 47cb3321ab fix(style): forgot password layout 2026-06-16 14:17:43 +08:00
jialinandjialin 547b9f617b fix(style): forget button in login page 2026-06-15 15:48:41 +08:00
jialinandjialin 9e7bd78693 fix: allowed models do not contain the org name 2026-06-15 14:45:27 +08:00
jialinandjialin c53d60b628 fix: remove the depude code in buildTrend 2026-06-14 19:40:19 +08:00
jialinandjialin 9566e0cdb2 fix: queryParams do not be updated timely 2026-06-14 19:21:36 +08:00
jialinandjialin ed3e779d22 fix: do not trigger setInitialized in catch when error occured 2026-06-14 19:14:18 +08:00
jialinandjialin 3e2133753d fix: barchart, piechart flash issue 2026-06-14 19:14:18 +08:00
jialinandjialin 736a853192 chore: add cancel token 2026-06-14 19:14:18 +08:00
jialinandjialin 08ef2ffa9a refactor(usagesummary): fetch data 2026-06-14 19:14:18 +08:00
jialinandjialin 6cf512cccd refactor(usage): split tabs into separate dirs 2026-06-14 19:14:18 +08:00
jialinandjialin 6f56e614f3 chore: instance creationg UE 2026-06-14 19:14:18 +08:00
83 changed files with 3511 additions and 1626 deletions
+1
View File
@@ -346,6 +346,7 @@ const baseRoutes = [
icon: 'icon-billing-outlined', icon: 'icon-billing-outlined',
selectedIcon: 'icon-billing-filled', selectedIcon: 'icon-billing-filled',
defaultIcon: 'icon-billing-outlined', defaultIcon: 'icon-billing-outlined',
hideInMenu: process.env.ENABLE_ENTERPRISE === 'true',
// OSS exposes the menu as a teaser for the enterprise billing // OSS exposes the menu as a teaser for the enterprise billing
// module. The page itself just renders an upsell notice — the real // module. The page itself just renders an upsell notice — the real
// billing UI lives in the enterprise plugin and shadows this route // billing UI lives in the enterprise plugin and shadows this route
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>AlibabaCloud</title><path d="M14.752 4.64h5.274C22.242 4.64 24 6.475 24 8.691V15.8a3.947 3.947 0 01-3.974 3.975h-5.274l1.299-1.835 3.822-1.222c.688-.23 1.146-.918 1.146-1.605v-5.81c0-.687-.458-1.375-1.146-1.605L16.05 6.475l-1.3-1.835zM2.98 15.111c0 .688.46 1.376 1.147 1.606l3.822 1.146 1.3 1.835H3.974A3.947 3.947 0 010 15.723V8.69c0-2.216 1.758-4.05 3.975-4.05h5.273L7.95 6.474 4.127 7.697c-.688.23-1.146.918-1.146 1.606v5.808z" fill="#FF6A00"></path><path d="M16.051 11.213H8.025v1.835h8.026v-1.835z" fill="#FF6A00"></path></svg>

After

Width:  |  Height:  |  Size: 656 B

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>BaiLian</title><path d="M6.336 8.919v6.162l5.335-3.083L6.337 8.92z" fill="#1C54E3"></path><path d="M21.394 5.288s-.006-.006-.01-.006L17.01 2.754 6.336 8.92l5.335 3.082 9.701-5.6.016-.01a.635.635 0 00.006-1.1v-.003z" fill="#AA9AFF"></path><path d="M21.71 12.465a.62.62 0 00-.316.085s-.006 0-.009.003l-4.375 2.528 5.05 2.915h.006a2.06 2.06 0 00.28-1.04v-3.855a.637.637 0 00-.636-.636z" fill="#00EAD1"></path><path d="M22.06 17.996l-5.05-2.915L6.34 21.242l4.27 2.465s.016.006.022.012a2.102 2.102 0 002.093 0c.006-.003.016-.006.022-.012l8.538-4.93c.003 0 .006-.003.01-.006.321-.183.589-.45.775-.772h-.006l-.004-.003z" fill="#00CEC9"></path><path d="M11.672 11.998l-5.336 3.083-1.444.832-3.605 2.083H1.28c.173.303.416.555.709.738l.078.044.016.01.02.012 4.232 2.442 10.671-6.161-5.335-3.082z" fill="#00EAD1"></path><path d="M12.74.29c-.1-.06-.208-.107-.315-.148-.02-.006-.038-.016-.057-.022a2.121 2.121 0 00-.7-.12c-.233 0-.457.038-.668.11l-.031.01a2.196 2.196 0 00-.372.17L2.068 5.222s-.003 0-.006.003c-.324.183-.592.451-.781.773h.006l5.049 2.918L17.01 2.758 12.74.29z" fill="#7347FF"></path><path d="M1.287 6.001H1.28A2.06 2.06 0 001 7.041v9.915c0 .378.1.735.28 1.043h.007l5.049-2.918V8.919l-5.05-2.918z" fill="#0423DA"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -63,6 +63,7 @@ export const fromClusterCreationAtom = atom(false);
export const clusterSessionAtom = atom<{ export const clusterSessionAtom = atom<{
firstAddWorker: boolean; firstAddWorker: boolean;
firstAddCluster: boolean; firstAddCluster: boolean;
presetClusterType?: 'model' | 'gpu';
// Provider to preselect when the create flow opens — set by the // Provider to preselect when the create flow opens — set by the
// empty-state CTA on feature pages that need a specific provider // empty-state CTA on feature pages that need a specific provider
// (e.g. GPU Service can only schedule on Kubernetes, so its // (e.g. GPU Service can only schedule on Kubernetes, so its
-1
View File
@@ -276,7 +276,6 @@ body {
border-radius: var(--table-td-radius); border-radius: var(--table-td-radius);
> td { > td {
background-color: unset !important;
border-bottom: 1px solid var(--ant-color-split); border-bottom: 1px solid var(--ant-color-split);
} }
} }
+7 -3
View File
@@ -18,6 +18,7 @@ export function useQueryDataList<
Response = Array<ListItem> Response = Array<ListItem>
>(option: { >(option: {
key: string; key: string;
manual?: boolean;
responseType?: 'array' | 'object'; responseType?: 'array' | 'object';
fetchList: ( fetchList: (
params: Params, params: Params,
@@ -38,6 +39,7 @@ export function useQueryDataList<
fetchList, fetchList,
getLabel, getLabel,
getValue, getValue,
manual = true,
responseType = 'array', responseType = 'array',
errorMsg errorMsg
} = option; } = option;
@@ -70,7 +72,7 @@ export function useQueryDataList<
return responseType === 'array' ? res.items || [] : res; return responseType === 'array' ? res.items || [] : res;
}, },
{ {
manual: true, manual: manual,
debounceWait: option.debounceWait || 300, debounceWait: option.debounceWait || 300,
onSuccess: () => {}, onSuccess: () => {},
onError: (error) => { onError: (error) => {
@@ -105,16 +107,18 @@ export function useQueryDataList<
export function useQueryData<Detail, Params = any>(option: { export function useQueryData<Detail, Params = any>(option: {
key: string; key: string;
delay?: number; delay?: number;
manual?: boolean;
fetchDetail: (params: Params, options?: any) => Promise<Detail>; fetchDetail: (params: Params, options?: any) => Promise<Detail>;
getData?: (response: Detail, params?: any) => any; getData?: (response: Detail, params?: any) => any;
errorMsg?: string; errorMsg?: string;
}): { }): {
loading: boolean; loading: boolean;
detailData: Detail; detailData: Detail;
manual?: boolean;
cancelRequest: () => void; cancelRequest: () => void;
fetchData: (params: Params, extra?: any) => Promise<Detail>; fetchData: (params: Params, extra?: any) => Promise<Detail>;
} { } {
const { key, fetchDetail, getData, errorMsg, delay } = option; const { key, fetchDetail, getData, errorMsg, delay, manual = true } = option;
const axiosTokenRef = useRef<CancelTokenSource | null>(null); const axiosTokenRef = useRef<CancelTokenSource | null>(null);
const [detailData, setDetailData] = useState<Detail>({} as Detail); const [detailData, setDetailData] = useState<Detail>({} as Detail);
@@ -142,7 +146,7 @@ export function useQueryData<Detail, Params = any>(option: {
return res; return res;
}, },
{ {
manual: true, manual: manual,
onSuccess: () => {}, onSuccess: () => {},
onError: (error) => { onError: (error) => {
message.error( message.error(
+1 -1
View File
@@ -25,7 +25,7 @@ export default {
'ai.provider.ollama': 'Ollama', 'ai.provider.ollama': 'Ollama',
'ai.provider.openai': 'OpenAI', 'ai.provider.openai': 'OpenAI',
'ai.provider.openrouter': 'OpenRouter', 'ai.provider.openrouter': 'OpenRouter',
'ai.provider.qwen': 'Qwen', 'ai.provider.qwen': 'Alibaba Cloud Model Studio',
'ai.provider.spark': 'Spark', 'ai.provider.spark': 'Spark',
'ai.provider.stepfun': 'StepFun', 'ai.provider.stepfun': 'StepFun',
'ai.provider.together-ai': 'TogetherAI', 'ai.provider.together-ai': 'TogetherAI',
+1 -1
View File
@@ -144,7 +144,7 @@ export default {
'clusters.addworker.theadNotes-02': 'clusters.addworker.theadNotes-02':
'T-Head PPU uses the Container Device Interface (CDI) for device injection and requires the <span class="bold-text">/var/run/cdi</span> directory to be available for CDI generation.', 'T-Head PPU uses the Container Device Interface (CDI) for device injection and requires the <span class="bold-text">/var/run/cdi</span> directory to be available for CDI generation.',
'clusters.addworker.nvidiaNotes': 'clusters.addworker.nvidiaNotes':
'The built-in inference backends in GPUStack v2.1 require <span class="bold-text">CUDA 12.6+</span>. Please ensure your NVIDIA driver version is <span class="bold-text">560</span> or newer.', 'The built-in inference backends in GPUStack require <span class="bold-text">CUDA 12.8+</span>. Please ensure your NVIDIA driver version is <span class="bold-text">570</span> or newer.',
'clusters.volume.title': 'Volume Mounts', 'clusters.volume.title': 'Volume Mounts',
'clusters.volume.name': 'Volume Name', 'clusters.volume.name': 'Volume Name',
'clusters.volume.mountPath': 'Container Path', 'clusters.volume.mountPath': 'Container Path',
+1 -1
View File
@@ -25,7 +25,7 @@ export default {
'ai.provider.ollama': 'Ollama', 'ai.provider.ollama': 'Ollama',
'ai.provider.openai': 'OpenAI', 'ai.provider.openai': 'OpenAI',
'ai.provider.openrouter': 'OpenRouter', 'ai.provider.openrouter': 'OpenRouter',
'ai.provider.qwen': 'Qwen', 'ai.provider.qwen': 'Alibaba Cloud Model Studio',
'ai.provider.spark': 'Spark', 'ai.provider.spark': 'Spark',
'ai.provider.stepfun': 'StepFun', 'ai.provider.stepfun': 'StepFun',
'ai.provider.together-ai': 'TogetherAI', 'ai.provider.together-ai': 'TogetherAI',
+1 -1
View File
@@ -144,7 +144,7 @@ export default {
'clusters.addworker.theadNotes-02': 'clusters.addworker.theadNotes-02':
'T-Head PPU uses the Container Device Interface (CDI) for device injection and requires the <span class="bold-text">/var/run/cdi</span> directory to be available for CDI generation.', 'T-Head PPU uses the Container Device Interface (CDI) for device injection and requires the <span class="bold-text">/var/run/cdi</span> directory to be available for CDI generation.',
'clusters.addworker.nvidiaNotes': 'clusters.addworker.nvidiaNotes':
'The built-in inference backends in GPUStack v2.1 require <span class="bold-text">CUDA 12.6+</span>. Please ensure your NVIDIA driver version is <span class="bold-text">560</span> or newer.', 'The built-in inference backends in GPUStack require <span class="bold-text">CUDA 12.8+</span>. Please ensure your NVIDIA driver version is <span class="bold-text">570</span> or newer.',
'clusters.volume.title': 'Volume Mounts', 'clusters.volume.title': 'Volume Mounts',
'clusters.volume.name': 'Volume Name', 'clusters.volume.name': 'Volume Name',
'clusters.volume.mountPath': 'Container Path', 'clusters.volume.mountPath': 'Container Path',
+1 -1
View File
@@ -25,7 +25,7 @@ export default {
'ai.provider.ollama': 'Ollama', 'ai.provider.ollama': 'Ollama',
'ai.provider.openai': 'OpenAI', 'ai.provider.openai': 'OpenAI',
'ai.provider.openrouter': 'OpenRouter', 'ai.provider.openrouter': 'OpenRouter',
'ai.provider.qwen': 'Qwen', 'ai.provider.qwen': 'Alibaba Cloud Model Studio',
'ai.provider.spark': 'Spark', 'ai.provider.spark': 'Spark',
'ai.provider.stepfun': 'StepFun', 'ai.provider.stepfun': 'StepFun',
'ai.provider.together-ai': 'TogetherAI', 'ai.provider.together-ai': 'TogetherAI',
+1 -1
View File
@@ -145,7 +145,7 @@ export default {
'clusters.addworker.theadNotes-02': 'clusters.addworker.theadNotes-02':
'T-Head PPU uses the Container Device Interface (CDI) for device injection and requires the <span class="bold-text">/var/run/cdi</span> directory to be available for CDI generation.', 'T-Head PPU uses the Container Device Interface (CDI) for device injection and requires the <span class="bold-text">/var/run/cdi</span> directory to be available for CDI generation.',
'clusters.addworker.nvidiaNotes': 'clusters.addworker.nvidiaNotes':
'The built-in inference backends in GPUStack v2.1 require <span class="bold-text">CUDA 12.6+</span>. Please ensure your NVIDIA driver version is <span class="bold-text">560</span> or newer.', 'The built-in inference backends in GPUStack require <span class="bold-text">CUDA 12.8+</span>. Please ensure your NVIDIA driver version is <span class="bold-text">570</span> or newer.',
'clusters.volume.title': 'Volume Mounts', 'clusters.volume.title': 'Volume Mounts',
'clusters.volume.name': 'Volume Name', 'clusters.volume.name': 'Volume Name',
'clusters.volume.mountPath': 'Container Path', 'clusters.volume.mountPath': 'Container Path',
+1 -1
View File
@@ -25,7 +25,7 @@ export default {
'ai.provider.ollama': 'Ollama', 'ai.provider.ollama': 'Ollama',
'ai.provider.openai': 'OpenAI', 'ai.provider.openai': 'OpenAI',
'ai.provider.openrouter': 'OpenRouter', 'ai.provider.openrouter': 'OpenRouter',
'ai.provider.qwen': 'Qwen', 'ai.provider.qwen': 'Alibaba Cloud Model Studio',
'ai.provider.spark': 'Spark', 'ai.provider.spark': 'Spark',
'ai.provider.stepfun': 'StepFun', 'ai.provider.stepfun': 'StepFun',
'ai.provider.together-ai': 'TogetherAI', 'ai.provider.together-ai': 'TogetherAI',
+1 -1
View File
@@ -145,7 +145,7 @@ export default {
'clusters.addworker.theadNotes-02': 'clusters.addworker.theadNotes-02':
'T-Head PPU, cihaz enjeksiyonu için Container Device Interface (CDI) kullanır ve CDI oluşturma için <span class="bold-text">/var/run/cdi</span> dizininin kullanılabilir olmasını gerektirir.', 'T-Head PPU, cihaz enjeksiyonu için Container Device Interface (CDI) kullanır ve CDI oluşturma için <span class="bold-text">/var/run/cdi</span> dizininin kullanılabilir olmasını gerektirir.',
'clusters.addworker.nvidiaNotes': 'clusters.addworker.nvidiaNotes':
'GPUStack v2.1\'deki yerleşik çıkarım altyapıları <span class="bold-text">CUDA 12.6+</span> gerektirir. Lütfen NVIDIA sürücü sürümünüzün <span class="bold-text">560</span> veya daha yeni olduğundan emin olun.', 'GPUStack\'teki yerleşik çıkarım altyapıları <span class="bold-text">CUDA 12.8+</span> gerektirir. Lütfen NVIDIA sürücü sürümünüzün <span class="bold-text">570</span> veya daha yeni olduğundan emin olun.',
'clusters.volume.title': 'Volume Mounts', 'clusters.volume.title': 'Volume Mounts',
'clusters.volume.name': 'Volume Name', 'clusters.volume.name': 'Volume Name',
'clusters.volume.mountPath': 'Container Path', 'clusters.volume.mountPath': 'Container Path',
+1 -1
View File
@@ -25,7 +25,7 @@ export default {
'ai.provider.ollama': 'Ollama', 'ai.provider.ollama': 'Ollama',
'ai.provider.openai': 'OpenAI', 'ai.provider.openai': 'OpenAI',
'ai.provider.openrouter': 'OpenRouter', 'ai.provider.openrouter': 'OpenRouter',
'ai.provider.qwen': '通义千问', 'ai.provider.qwen': '阿里云百炼',
'ai.provider.spark': '讯飞星火', 'ai.provider.spark': '讯飞星火',
'ai.provider.stepfun': '阶跃星辰', 'ai.provider.stepfun': '阶跃星辰',
'ai.provider.together-ai': 'Together AI', 'ai.provider.together-ai': 'Together AI',
+1 -1
View File
@@ -138,7 +138,7 @@ export default {
'clusters.addworker.theadNotes-02': 'clusters.addworker.theadNotes-02':
'平头哥(T-Head)PPU 使用容器设备接口(CDI)进行设备注入,因此需要确保 <span class="bold-text">/var/run/cdi</span> 目录可用以生成 CDI。', '平头哥(T-Head)PPU 使用容器设备接口(CDI)进行设备注入,因此需要确保 <span class="bold-text">/var/run/cdi</span> 目录可用以生成 CDI。',
'clusters.addworker.nvidiaNotes': 'clusters.addworker.nvidiaNotes':
'GPUStack v2.1 内置推理后端依赖 <span class="bold-text">CUDA 12.6</span> 及以上版本,请确保 NVIDIA 驱动版本为 <span class="bold-text">560</span> 或以上。', 'GPUStack 内置推理后端依赖 <span class="bold-text">CUDA 12.8</span> 及以上版本,请确保 NVIDIA 驱动版本为 <span class="bold-text">570</span> 或以上。',
'clusters.volume.title': '卷挂载', 'clusters.volume.title': '卷挂载',
'clusters.volume.name': '卷名称', 'clusters.volume.name': '卷名称',
'clusters.volume.mountPath': '容器内路径', 'clusters.volume.mountPath': '容器内路径',
+63 -9
View File
@@ -1,9 +1,15 @@
import useCoolColors from '@/hooks/use-cool-colors'; import useCoolColors from '@/hooks/use-cool-colors';
import { Chart } from '@gpustack/core-ui'; import { Chart } from '@gpustack/core-ui';
import { formatLargeNumber } from '@gpustack/core-ui/utils'; import { formatLargeNumber } from '@gpustack/core-ui/utils';
import { Empty, theme } from 'antd'; import { Empty, Spin, theme } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
import React, { useEffect, useMemo, useRef } from 'react'; import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState
} from 'react';
export interface BarSeriesItem { export interface BarSeriesItem {
name: string; name: string;
@@ -17,6 +23,7 @@ export interface BarChartProps {
xAxisData: string[]; xAxisData: string[];
height: number | string; height: number | string;
width?: number | string; width?: number | string;
loading?: boolean;
legendData?: { name: string; icon?: string }[]; legendData?: { name: string; icon?: string }[];
labelFormatter?: (val: any, index?: number) => string; labelFormatter?: (val: any, index?: number) => string;
tooltipValueFormatter?: (val: any) => string; tooltipValueFormatter?: (val: any) => string;
@@ -38,6 +45,7 @@ const BarChart: React.FC<BarChartProps> = (props) => {
height, height,
width, width,
legendData, legendData,
loading,
labelFormatter, labelFormatter,
tooltipValueFormatter, tooltipValueFormatter,
title, title,
@@ -48,6 +56,27 @@ const BarChart: React.FC<BarChartProps> = (props) => {
const chartRef = useRef<{ chart: any } | null>(null); const chartRef = useRef<{ chart: any } | null>(null);
const generateCoolColors = useCoolColors(); const generateCoolColors = useCoolColors();
// ECharts reads the DOM width at init time; with a "100%" width it can pick
// up a stale/tiny value while the flex child's layout is still resolving,
// rendering every bar squeezed at the left edge until its internal (throttled)
// ResizeObserver corrects it ~100ms later — a visible blue-sliver flash. We
// measure the container ourselves via a callback ref (which fires during
// commit, before paint) and feed ECharts an explicit pixel width, so the very
// first render is already correct. No gating, so the chart mounts with no
// extra delay; the ResizeObserver keeps the width in sync afterwards.
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const [measuredWidth, setMeasuredWidth] = useState(0);
const measureRef = useCallback((node: HTMLDivElement | null) => {
resizeObserverRef.current?.disconnect();
if (!node) return;
setMeasuredWidth(node.clientWidth);
resizeObserverRef.current = new ResizeObserver(() => {
setMeasuredWidth(node.clientWidth);
});
resizeObserverRef.current.observe(node);
}, []);
useEffect(() => () => resizeObserverRef.current?.disconnect(), []);
const dynamicColors = useMemo( const dynamicColors = useMemo(
() => generateCoolColors(seriesData.length), () => generateCoolColors(seriesData.length),
[seriesData.length, generateCoolColors] [seriesData.length, generateCoolColors]
@@ -277,18 +306,43 @@ const BarChart: React.FC<BarChartProps> = (props) => {
justifyContent: 'center' justifyContent: 'center'
}} }}
> >
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} /> {loading ? (
<Spin size="middle" />
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</div> </div>
); );
} }
return ( return (
<Chart <div
ref={chartRef as any} ref={measureRef}
options={options as any} style={{ width: width || '100%', height, position: 'relative' }}
height={height} >
width={width || '100%'} <Chart
/> ref={chartRef as any}
options={options as any}
height={height}
width={measuredWidth || width || '100%'}
/>
{loading && (
<div
style={{
position: 'absolute',
inset: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--ant-color-bg-container)',
opacity: 0.6,
pointerEvents: 'none'
}}
>
<Spin size="middle" />
</div>
)}
</div>
); );
}; };
+29 -3
View File
@@ -2,7 +2,13 @@ import useCoolColors from '@/hooks/use-cool-colors';
import { Chart } from '@gpustack/core-ui'; import { Chart } from '@gpustack/core-ui';
import { formatLargeNumber } from '@gpustack/core-ui/utils'; import { formatLargeNumber } from '@gpustack/core-ui/utils';
import { Empty, Spin, theme } from 'antd'; import { Empty, Spin, theme } from 'antd';
import React, { useMemo, useRef } from 'react'; import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState
} from 'react';
export interface PieChartItem { export interface PieChartItem {
name: string; name: string;
@@ -37,6 +43,26 @@ const PieChart: React.FC<PieChartProps> = ({
const chartRef = useRef<{ chart: any } | null>(null); const chartRef = useRef<{ chart: any } | null>(null);
const generateCoolColors = useCoolColors(); const generateCoolColors = useCoolColors();
// ECharts reads the DOM width at init time; with a "100%" width it can pick
// up a stale/tiny value while layout is still resolving, briefly rendering
// the donut undersized before its internal (throttled) ResizeObserver
// corrects it — a visible flash. We measure the container via a callback ref
// (fires during commit, before paint) and feed ECharts an explicit pixel
// width so the first render is already correct. The ResizeObserver keeps it
// in sync afterwards.
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const [measuredWidth, setMeasuredWidth] = useState(0);
const measureRef = useCallback((node: HTMLDivElement | null) => {
resizeObserverRef.current?.disconnect();
if (!node) return;
setMeasuredWidth(node.clientWidth);
resizeObserverRef.current = new ResizeObserver(() => {
setMeasuredWidth(node.clientWidth);
});
resizeObserverRef.current.observe(node);
}, []);
useEffect(() => () => resizeObserverRef.current?.disconnect(), []);
const colors = useMemo(() => { const colors = useMemo(() => {
const generatedColors = generateCoolColors(data.length + colorOffset); const generatedColors = generateCoolColors(data.length + colorOffset);
return generatedColors.slice(colorOffset); return generatedColors.slice(colorOffset);
@@ -142,12 +168,12 @@ const PieChart: React.FC<PieChartProps> = ({
const displayTotal = total ?? data.reduce((sum, item) => sum + item.value, 0); const displayTotal = total ?? data.reduce((sum, item) => sum + item.value, 0);
return ( return (
<div style={{ width, height, position: 'relative' }}> <div ref={measureRef} style={{ width, height, position: 'relative' }}>
<Chart <Chart
ref={chartRef as any} ref={chartRef as any}
options={options as any} options={options as any}
height={height} height={height}
width={width} width={measuredWidth || width}
/> />
<div <div
style={{ style={{
+1 -1
View File
@@ -251,7 +251,7 @@ const BackendList = () => {
selectHolder={intl.formatMessage({ id: 'backend.filter.source' })} selectHolder={intl.formatMessage({ id: 'backend.filter.source' })}
buttonText={intl.formatMessage({ id: 'backend.button.add' })} buttonText={intl.formatMessage({ id: 'backend.button.add' })}
handleClickPrimary={handleAddBackend} handleClickPrimary={handleAddBackend}
handleSearch={handleSearch} handleSearch={() => loadMore(1)}
handleSelectChange={handleFilterBySource} handleSelectChange={handleFilterBySource}
handleInputChange={handleNameChange} handleInputChange={handleNameChange}
rowSelection={rowSelection} rowSelection={rowSelection}
@@ -17,6 +17,9 @@ const ViewLogsModal: React.FC<ViewModalProps> = (props) => {
const logsViewerRef = React.useRef<any>(null); const logsViewerRef = React.useRef<any>(null);
const requestRef = React.useRef<any>(null); const requestRef = React.useRef<any>(null);
const contentRef = React.useRef<any>(null); const contentRef = React.useRef<any>(null);
const [params, setParams] = React.useState<any>({
follow: true
});
const handleCancel = useCallback(() => { const handleCancel = useCallback(() => {
logsViewerRef.current?.abort(); logsViewerRef.current?.abort();
@@ -94,9 +97,7 @@ const ViewLogsModal: React.FC<ViewModalProps> = (props) => {
tail={undefined} tail={undefined}
enableScorllLoad={true} enableScorllLoad={true}
isDownloading={false} isDownloading={false}
params={{ params={params}
follow: true
}}
></LogsViewer> ></LogsViewer>
</div> </div>
</Modal> </Modal>
+1 -1
View File
@@ -59,7 +59,7 @@ const Billing: React.FC = () => {
</li> </li>
))} ))}
</ul> </ul>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center', marginTop: 56 }}>
<Button <Button
type="primary" type="primary"
href="https://gpustack.ai/enterprise" href="https://gpustack.ai/enterprise"
@@ -59,9 +59,16 @@ const ClusterCreate: React.FC<{
// empty-state CTAs that already know which kind of cluster the user // empty-state CTAs that already know which kind of cluster the user
// is heading for (e.g. GPU Service's "Add a Kubernetes Cluster"). // is heading for (e.g. GPU Service's "Add a Kubernetes Cluster").
providerHint?: string; providerHint?: string;
presetClusterType?: 'model' | 'gpu';
setCurrentTitle?: (title: string) => void; setCurrentTitle?: (title: string) => void;
onClose?: () => void; onClose?: () => void;
}> = ({ onClose, action, providerHint, setCurrentTitle }) => { }> = ({
onClose,
action,
providerHint,
presetClusterType,
setCurrentTitle
}) => {
const stepList = useStepList(); const stepList = useStepList();
const [systemConfigState] = useAtom(systemConfigAtom); const [systemConfigState] = useAtom(systemConfigAtom);
const intl = useIntl(); const intl = useIntl();
@@ -377,6 +384,7 @@ const ClusterCreate: React.FC<{
)} )}
<StepsContext.Provider <StepsContext.Provider
value={{ value={{
presetClusterType: presetClusterType,
formValues: formValues, formValues: formValues,
systemConfig: systemConfigState systemConfig: systemConfigState
}} }}
@@ -6,10 +6,10 @@ import ClusterCreate from './cluster-create';
interface ClusterModalProps { interface ClusterModalProps {
open: boolean; open: boolean;
title: string; title: string;
// When set, ClusterCreate preselects this provider and skips the pendingProviderHint?: {
// provider-catalog step. Used by feature pages (e.g. GPU Service) providerHint?: string;
// whose empty state already implies which kind of cluster is needed. presetClusterType?: 'model' | 'gpu';
providerHint?: string; };
onClose: () => void; onClose: () => void;
} }
@@ -17,7 +17,7 @@ const ClusterModal: React.FC<ClusterModalProps> = ({
open, open,
onClose, onClose,
title, title,
providerHint pendingProviderHint
}) => { }) => {
const [currentTitle, setCurrentTitle] = React.useState<string>(title); const [currentTitle, setCurrentTitle] = React.useState<string>(title);
const handleCancel = () => { const handleCancel = () => {
@@ -46,7 +46,8 @@ const ClusterModal: React.FC<ClusterModalProps> = ({
<ClusterCreate <ClusterCreate
onClose={handleCancel} onClose={handleCancel}
action={PageAction.CREATE} action={PageAction.CREATE}
providerHint={providerHint} providerHint={pendingProviderHint?.providerHint}
presetClusterType={pendingProviderHint?.presetClusterType}
setCurrentTitle={setCurrentTitle} setCurrentTitle={setCurrentTitle}
></ClusterCreate> ></ClusterCreate>
</GSDrawer> </GSDrawer>
+16 -6
View File
@@ -321,13 +321,20 @@ const Clusters: React.FC = () => {
// the session atom is cleared right after we open the modal, but // the session atom is cleared right after we open the modal, but
// ClusterCreate mounts a tick later and needs the value to skip the // ClusterCreate mounts a tick later and needs the value to skip the
// provider-catalog step. Cache it locally and clear on close. // provider-catalog step. Cache it locally and clear on close.
const [pendingProviderHint, setPendingProviderHint] = useState< const [pendingProviderHint, setPendingProviderHint] = useState<{
string | undefined providerHint?: string;
>(undefined); presetClusterType?: 'model' | 'gpu';
}>({
providerHint: undefined,
presetClusterType: undefined
});
useEffect(() => { useEffect(() => {
if (clusterSession?.firstAddCluster && dataSource.loadend) { if (clusterSession?.firstAddCluster && dataSource.loadend) {
setPendingProviderHint(clusterSession.providerHint); setPendingProviderHint({
providerHint: clusterSession.providerHint,
presetClusterType: clusterSession.presetClusterType
});
openClusterModal(); openClusterModal();
// reset session // reset session
setClusterSession(null); setClusterSession(null);
@@ -335,7 +342,10 @@ const Clusters: React.FC = () => {
}, [clusterSession, dataSource.loadend]); }, [clusterSession, dataSource.loadend]);
const handleClusterModalClose = () => { const handleClusterModalClose = () => {
setPendingProviderHint(undefined); setPendingProviderHint({
providerHint: undefined,
presetClusterType: undefined
});
closeClusterModal(); closeClusterModal();
}; };
@@ -464,7 +474,7 @@ const Clusters: React.FC = () => {
id: 'menu.resources.clusterCreate' id: 'menu.resources.clusterCreate'
})} })}
open={clusterModalStatus.open} open={clusterModalStatus.open}
providerHint={pendingProviderHint} pendingProviderHint={pendingProviderHint}
onClose={handleClusterModalClose} onClose={handleClusterModalClose}
></ClusterModal> ></ClusterModal>
{AddWorkerModal} {AddWorkerModal}
@@ -6,6 +6,7 @@ import { Form } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
import React, { useEffect, useId, useMemo } from 'react'; import React, { useEffect, useId, useMemo } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { useStepsContext } from '../config/steps-context';
import { ClusterListItem as ListItem } from '../config/types'; import { ClusterListItem as ListItem } from '../config/types';
import ImageCredential from './image-credential'; import ImageCredential from './image-credential';
import K8SVolumeMount from './k8s-volume-mount'; import K8SVolumeMount from './k8s-volume-mount';
@@ -175,6 +176,7 @@ const RadioDot = styled.span<{ $active: boolean }>`
export const ClusterTypeSelector: React.FC = () => { export const ClusterTypeSelector: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const form = Form.useFormInstance(); const form = Form.useFormInstance();
const { presetClusterType } = useStepsContext();
const labelId = useId(); const labelId = useId();
const gpuInstanceOptions = Form.useWatch(GPU_INSTANCE_OPTIONS_PATH, { const gpuInstanceOptions = Form.useWatch(GPU_INSTANCE_OPTIONS_PATH, {
form, form,
@@ -211,6 +213,12 @@ export const ClusterTypeSelector: React.FC = () => {
} }
]; ];
useEffect(() => {
if (presetClusterType) {
handleSelect(presetClusterType);
}
}, [presetClusterType]);
return ( return (
<ClusterTypeWrap> <ClusterTypeWrap>
<ClusterTypeLabel id={labelId}> <ClusterTypeLabel id={labelId}>
@@ -1,13 +1,15 @@
import { createContext, useContext } from 'react'; import { createContext, useContext } from 'react';
export interface StepsContextProps { export interface StepsContextProps {
presetClusterType?: 'model' | 'gpu';
formValues: Record<string, any>; formValues: Record<string, any>;
systemConfig?: Record<string, any>; systemConfig?: Record<string, any>;
} }
export const StepsContext = createContext<StepsContextProps>({ export const StepsContext = createContext<StepsContextProps>({
formValues: {}, formValues: {},
systemConfig: {} systemConfig: {},
presetClusterType: undefined
}); });
export const useStepsContext = () => useContext(StepsContext); export const useStepsContext = () => useContext(StepsContext);
@@ -14,7 +14,7 @@ export const useQueryClusterList = (options?: { useStateData?: boolean }) => {
const { useStateData = true } = options || {}; const { useStateData = true } = options || {};
const axiosTokenRef = useRef<CancelTokenSource | null>(null); const axiosTokenRef = useRef<CancelTokenSource | null>(null);
const [dataList, setDataList] = useState< const [dataList, setDataList] = useState<
Array<Partial<ClusterListItem> & { label: string; value: number }> Array<ClusterListItem & { label: string; value: number }>
>([]); >([]);
const { const {
@@ -1,7 +1,6 @@
import { PageAction } from '@/config'; import { PageAction } from '@/config';
import { PageActionType } from '@/config/types'; import { PageActionType } from '@/config/types';
import useSubmitLock from '@/hooks/use-submit-lock'; import useSubmitLock from '@/hooks/use-submit-lock';
import { useQueryClusterList } from '@/pages/cluster-management/services/use-query-cluster-list';
import useUserDirectory from '@/pages/gpu-service/hooks/use-user-directory'; import useUserDirectory from '@/pages/gpu-service/hooks/use-user-directory';
import Separator from '@/pages/llmodels/components/separator'; import Separator from '@/pages/llmodels/components/separator';
import { getGPUStackPlugin } from '@/plugins'; import { getGPUStackPlugin } from '@/plugins';
@@ -13,7 +12,7 @@ import {
ModalFooter ModalFooter
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl, useModel } from '@umijs/max'; import { useIntl, useModel } from '@umijs/max';
import { Empty, Input, Typography } from 'antd'; import { Input, Typography } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { ListItem as TemplateItem } from '../../templates/config/types'; import { ListItem as TemplateItem } from '../../templates/config/types';
@@ -31,6 +30,12 @@ type AddModalProps = {
open: boolean; open: boolean;
width?: number | string; width?: number | string;
realAction?: string; realAction?: string;
clusterList?: Array<{
label: string;
value: number;
id: number;
owner_principal_id?: number;
}>;
onOk: (values: FormData) => void; onOk: (values: FormData) => void;
data?: ListItem | null; data?: ListItem | null;
onCancel: () => void; onCancel: () => void;
@@ -78,6 +83,7 @@ const AddModal: React.FC<AddModalProps> = ({
data, data,
onCancel, onCancel,
width, width,
clusterList = [],
realAction realAction
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
@@ -100,16 +106,18 @@ const AddModal: React.FC<AddModalProps> = ({
const [instanceKeyword, setInstanceKeyword] = useState(''); const [instanceKeyword, setInstanceKeyword] = useState('');
const [templateKeyword, setTemplateKeyword] = useState(''); const [templateKeyword, setTemplateKeyword] = useState('');
const { loading, guard, run, release } = useSubmitLock(); const { loading, guard, run, release } = useSubmitLock();
const initializedRef = useRef(false); const [initialized, setInitialized] = useState(false);
const { const {
detailData: instanceTypeList, detailData: instanceTypeList,
loading: instanceTypesLoading, loading: instanceTypesLoading,
fetchData fetchData
} = useQueryInstanceTypes(); } = useQueryInstanceTypes();
const { detailData: templatesData, fetchData: fetchTemplates } = const {
useQueryTemplates(); detailData: templatesData,
const { clusterList, fetchClusterList } = useQueryClusterList(); loading: templateLoading,
fetchData: fetchTemplates
} = useQueryTemplates();
// Set by the create-scope picker (admin "All" view) via onScopeChange. // Set by the create-scope picker (admin "All" view) via onScopeChange.
// undefined = no picker (org context) → no client-side scoping. // undefined = no picker (org context) → no client-side scoping.
const [scopeOrgId, setScopeOrgId] = useState<number | null | undefined>( const [scopeOrgId, setScopeOrgId] = useState<number | null | undefined>(
@@ -125,18 +133,13 @@ const AddModal: React.FC<AddModalProps> = ({
// fetched list client-side, so it doesn't rely on the request scope. // fetched list client-side, so it doesn't rely on the request scope.
const filterTypesByOwner = ( const filterTypesByOwner = (
types: InstanceTypeItem[], types: InstanceTypeItem[],
clusters: Array<{
id?: number;
value?: number;
owner_principal_id?: number;
}>,
orgId?: number | null orgId?: number | null
): InstanceTypeItem[] => { ): InstanceTypeItem[] => {
if (orgId == null) return types; if (orgId == null) return types;
const owned = new Set( const owned = new Set(
(clusters || []) (clusterList || [])
.filter((c) => c.owner_principal_id === orgId) .filter((c) => c.owner_principal_id === orgId)
.map((c) => c.id ?? c.value) .map((c) => c.id || c.value)
); );
return types return types
.map((it) => ({ .map((it) => ({
@@ -157,7 +160,7 @@ const AddModal: React.FC<AddModalProps> = ({
}; };
const ownedInstanceTypes = useMemo( const ownedInstanceTypes = useMemo(
() => filterTypesByOwner(instanceTypeList, clusterList as any, scopeOrgId), () => filterTypesByOwner(instanceTypeList, scopeOrgId),
[instanceTypeList, clusterList, scopeOrgId] [instanceTypeList, clusterList, scopeOrgId]
); );
@@ -279,11 +282,6 @@ const AddModal: React.FC<AddModalProps> = ({
const applyAutoSelection = ( const applyAutoSelection = (
instanceTypes: InstanceTypeItem[], instanceTypes: InstanceTypeItem[],
templates: TemplateItem[], templates: TemplateItem[],
clusters?: Array<{
id?: number;
value?: number;
owner_principal_id?: number;
}>,
orgId?: number | null orgId?: number | null
) => { ) => {
// On edit / view, surface the persisted selection in the card list. // On edit / view, surface the persisted selection in the card list.
@@ -303,7 +301,7 @@ const AddModal: React.FC<AddModalProps> = ({
} }
// Scope to clusters the chosen org owns (admin "All" view). // Scope to clusters the chosen org owns (admin "All" view).
const owned = filterTypesByOwner(instanceTypes, clusters || [], orgId); const owned = filterTypesByOwner(instanceTypes, orgId);
// On create, auto-select the first available instance type (clears the // On create, auto-select the first available instance type (clears the
// selection when the chosen org has none). // selection when the chosen org has none).
@@ -314,26 +312,22 @@ const AddModal: React.FC<AddModalProps> = ({
// The query hook cancels any in-flight request on each new call, so when // The query hook cancels any in-flight request on each new call, so when
// this runs twice in quick succession (drawer open, then the scope // this runs twice in quick succession (drawer open, then the scope
// picker settling on its default) the latest scope's result wins. // picker settling on its default) the latest scope's result wins.
const loadCreateResources = (orgId?: number | null) => { const loadCreateResources = async (orgId?: number | null) => {
const session = ++sessionRef.current; const session = ++sessionRef.current;
try { try {
Promise.all([ const [instanceResItems, templatesRes] = await Promise.all([
fetchData({ page: -1 }), fetchData({ page: -1 }),
fetchTemplates({ page: -1 }), fetchTemplates({ page: -1 })
fetchClusterList({ page: -1 }) ]);
]).then(([instanceResItems, templatesRes, clusters]) => { if (sessionRef.current !== session) return;
if (sessionRef.current !== session) return; applyAutoSelection(
applyAutoSelection( instanceResItems || [],
instanceResItems || [], templatesRes?.items || [],
templatesRes?.items || [], orgId
(Array.isArray(clusters) ? clusters : (clusters as any)?.items) || [], );
orgId setInitialized(true);
);
initializedRef.current = true;
});
} catch (error) { } catch (error) {
} finally { setInitialized(true);
initializedRef.current = true;
} }
}; };
@@ -352,13 +346,13 @@ const AddModal: React.FC<AddModalProps> = ({
// owner-scoped auto-selection re-fills them from the new org, or leaves // owner-scoped auto-selection re-fills them from the new org, or leaves
// them empty (blocking submit) when the chosen org has no clusters. // them empty (blocking submit) when the chosen org has no clusters.
clearSelection(); clearSelection();
initializedRef.current = false; setInitialized(false);
loadCreateResources(orgId); loadCreateResources(orgId);
}; };
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
initializedRef.current = false; setInitialized(false);
sessionRef.current += 1; sessionRef.current += 1;
setInstanceTypeSelection({ setInstanceTypeSelection({
instanceType: undefined, instanceType: undefined,
@@ -619,15 +613,12 @@ const AddModal: React.FC<AddModalProps> = ({
onChange={(e) => setTemplateKeyword(e.target.value)} onChange={(e) => setTemplateKeyword(e.target.value)}
/> />
</div> </div>
{filteredTemplates.length > 0 && initializedRef.current ? ( <TemplateSelector
<TemplateSelector value={templateId}
value={templateId} loading={templateLoading || !initialized}
groups={templateGroups} groups={templateGroups}
onChange={handleTemplateChange} onChange={handleTemplateChange}
/> />
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</div> </div>
</ColumnWrapper> </ColumnWrapper>
<Separator></Separator> <Separator></Separator>
@@ -1,17 +1,10 @@
import FileSkeleton from '@/pages/llmodels/components/model-source/file-skeleton'; import { FileSkeletonRows } from '@/pages/llmodels/components/model-source/file-skeleton';
import { TemplateCard } from '@gpustack/core-ui'; import { TemplateCard } from '@gpustack/core-ui';
import { Empty, Spin } from 'antd'; import { Empty, Flex, Spin } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
import styled from 'styled-components';
import { InstanceTypeItem as InstanceTypeItemModel } from '../config/types'; import { InstanceTypeItem as InstanceTypeItemModel } from '../config/types';
import InstanceTypeItem from './instance-type-item'; import InstanceTypeItem from './instance-type-item';
const TypeGrid = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`;
interface InstanceTypeListProps { interface InstanceTypeListProps {
value?: string; value?: string;
onChange?: (item: InstanceTypeItemModel) => void; onChange?: (item: InstanceTypeItemModel) => void;
@@ -34,11 +27,11 @@ const InstanceTypeList: React.FC<InstanceTypeListProps> = ({
if (loading) { if (loading) {
return ( return (
<Spin spinning size="middle"> <Spin spinning size="middle">
<TypeGrid style={{ minHeight: 200 }}> <Flex orientation="vertical" gap={16} style={{ minHeight: 200 }}>
{_.times(6, (index: number) => ( {_.times(6, (index: number) => (
<FileSkeleton key={index} counts={3} itemHeight={106} /> <FileSkeletonRows key={index} counts={2} itemHeight={106} />
))} ))}
</TypeGrid> </Flex>
</Spin> </Spin>
); );
} }
@@ -46,7 +39,7 @@ const InstanceTypeList: React.FC<InstanceTypeListProps> = ({
} }
return ( return (
<TypeGrid> <Flex orientation="vertical" gap={16}>
{dataList.map((item) => { {dataList.map((item) => {
const name = item.name; const name = item.name;
return ( return (
@@ -64,7 +57,7 @@ const InstanceTypeList: React.FC<InstanceTypeListProps> = ({
</TemplateCard> </TemplateCard>
); );
})} })}
</TypeGrid> </Flex>
); );
}; };
@@ -70,9 +70,9 @@ export const status: Record<string, StatusType> = {
[InstanceStatusValueMap.NotReady]: StatusMaps.error, [InstanceStatusValueMap.NotReady]: StatusMaps.error,
[InstanceStatusValueMap.Ready]: StatusMaps.success, [InstanceStatusValueMap.Ready]: StatusMaps.success,
[InstanceStatusValueMap.Starting]: StatusMaps.transitioning, [InstanceStatusValueMap.Starting]: StatusMaps.transitioning,
[InstanceStatusValueMap.Deleting]: StatusMaps.error, [InstanceStatusValueMap.Deleting]: StatusMaps.warning,
[InstanceStatusValueMap.Stopping]: StatusMaps.error, [InstanceStatusValueMap.Stopping]: StatusMaps.transitioning,
[InstanceStatusValueMap.Stopped]: StatusMaps.warning, [InstanceStatusValueMap.Stopped]: StatusMaps.inactive,
[InstanceStatusValueMap.CreateFailed]: StatusMaps.error, [InstanceStatusValueMap.CreateFailed]: StatusMaps.error,
[InstanceStatusValueMap.SSHPublicKeyCreateFailed]: StatusMaps.error, [InstanceStatusValueMap.SSHPublicKeyCreateFailed]: StatusMaps.error,
[InstanceStatusValueMap.PersistentVolumeTypeCreateFailed]: StatusMaps.error, [InstanceStatusValueMap.PersistentVolumeTypeCreateFailed]: StatusMaps.error,
+25 -14
View File
@@ -129,6 +129,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
// exists/changes when a platform admin retargets the form. Watch it // exists/changes when a platform admin retargets the form. Watch it
// so the parent can re-scope offerings (see onScopeChange). // so the parent can re-scope offerings (see onScopeChange).
const scopeOrgId = Form.useWatch('organization_id', form); const scopeOrgId = Form.useWatch('organization_id', form);
const ports = Form.useWatch(['spec', 'ports'], form) || [];
const scopeInitRef = useRef(true); const scopeInitRef = useRef(true);
// Keep the latest callback in a ref so the scope-change effect can call it // Keep the latest callback in a ref so the scope-change effect can call it
// without depending on its identity (parent may pass a new fn each render). // without depending on its identity (parent may pass a new fn each render).
@@ -151,17 +152,31 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
: [TABKeysMap.INSTANCE_TYPE, TABKeysMap.TEMPLATE, TABKeysMap.STORAGE] : [TABKeysMap.INSTANCE_TYPE, TABKeysMap.TEMPLATE, TABKeysMap.STORAGE]
}); });
const hasSSHPort = useMemo(
() =>
ports.some(
(item: any) => item?.protocol === 'TCP' && item?.port === SSH_PORT
),
[ports]
);
const isGPUType = useMemo(() => { const isGPUType = useMemo(() => {
const spec = parseJsonSafe(description || '{}', {} as any)?.spec; const spec = parseJsonSafe(description || '{}', {} as any)?.spec;
console.log('derived spec from description', spec);
return spec?.acceleratable; return spec?.acceleratable;
}, [description]); }, [description]);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
fetchSSHData({ page: 1, perPage: 100 }); const initSSHKeys = async () => {
// await 200 ms
await new Promise((resolve) => {
setTimeout(resolve, 200);
});
fetchSSHData({ page: -1 });
};
initSSHKeys();
} }
}, [open, action]); }, [open]);
// Skip the first run (initial mount value); thereafter notify the // Skip the first run (initial mount value); thereafter notify the
// parent whenever the chosen create scope changes so it can reload // parent whenever the chosen create scope changes so it can reload
@@ -174,16 +189,6 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
onScopeChangeRef.current?.(scopeOrgId); onScopeChangeRef.current?.(scopeOrgId);
}, [scopeOrgId]); }, [scopeOrgId]);
const ports = Form.useWatch(['spec', 'ports'], form) || [];
const hasSSHPort = useMemo(
() =>
ports.some(
(item: any) => item?.protocol === 'TCP' && item?.port === SSH_PORT
),
[ports]
);
useEffect(() => { useEffect(() => {
if ( if (
hasSSHPort && hasSSHPort &&
@@ -554,7 +559,13 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
image: '', image: '',
imagePullPolicy: DefaultImagePullPolicy, imagePullPolicy: DefaultImagePullPolicy,
command: [], command: [],
ports: [], ports: [
{
protocol: 'TCP',
port: SSH_PORT,
name: 'SSH'
}
],
env: [], env: [],
volumeMount: '', volumeMount: '',
resources: { resources: {
@@ -36,7 +36,13 @@ const StorageVolume = ({
const [overlayOpen, setOverlayOpen] = useState(false); const [overlayOpen, setOverlayOpen] = useState(false);
useEffect(() => { useEffect(() => {
fetchStorage({ page: 1, perPage: 100 }); const initStorage = async () => {
await new Promise((resolve) => {
setTimeout(resolve, 200);
});
fetchStorage({ page: -1 });
};
initStorage();
}, []); }, []);
const storageOptions = useMemo( const storageOptions = useMemo(
@@ -1,15 +1,12 @@
import { FileSkeletonRows } from '@/pages/llmodels/components/model-source/file-skeleton';
import { AutoTooltip, IconFont, TemplateCard } from '@gpustack/core-ui'; import { AutoTooltip, IconFont, TemplateCard } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Empty, Flex, Spin } from 'antd';
import _ from 'lodash';
import { Fragment } from 'react'; import { Fragment } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { ListItem as TemplateItem } from '../../templates/config/types'; import { ListItem as TemplateItem } from '../../templates/config/types';
const TemplateGrid = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`;
const GroupTitle = styled.div` const GroupTitle = styled.div`
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
@@ -49,6 +46,12 @@ const TemplateContent = styled.div`
} }
`; `;
const TypeGrid = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`;
export interface TemplateGroup { export interface TemplateGroup {
key: string; key: string;
label: React.ReactNode; label: React.ReactNode;
@@ -57,6 +60,7 @@ export interface TemplateGroup {
interface TemplateSelectorProps { interface TemplateSelectorProps {
value?: number; value?: number;
loading?: boolean;
onChange?: (value: number, item: TemplateItem) => void; onChange?: (value: number, item: TemplateItem) => void;
groups?: TemplateGroup[]; groups?: TemplateGroup[];
} }
@@ -64,6 +68,7 @@ interface TemplateSelectorProps {
const TemplateSelector: React.FC<TemplateSelectorProps> = ({ const TemplateSelector: React.FC<TemplateSelectorProps> = ({
value, value,
onChange, onChange,
loading,
groups = [] groups = []
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
@@ -73,6 +78,22 @@ const TemplateSelector: React.FC<TemplateSelectorProps> = ({
onChange?.(item.id, item); onChange?.(item.id, item);
}; };
if (loading) {
return (
<Spin spinning size="middle">
<Flex orientation="vertical" gap={16} style={{ minHeight: 200 }}>
{_.times(6, (index: number) => (
<FileSkeletonRows key={index} counts={2} itemHeight={106} />
))}
</Flex>
</Spin>
);
}
if (!groups.length) {
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />;
}
const renderItem = (item: TemplateItem) => ( const renderItem = (item: TemplateItem) => (
<TemplateCard <TemplateCard
key={item.id} key={item.id}
@@ -120,14 +141,14 @@ const TemplateSelector: React.FC<TemplateSelectorProps> = ({
const showGroupTitles = groups.length > 1; const showGroupTitles = groups.length > 1;
return ( return (
<TemplateGrid> <Flex orientation="vertical" gap={16}>
{groups.map((group) => ( {groups.map((group) => (
<Fragment key={group.key}> <Fragment key={group.key}>
{showGroupTitles && <GroupTitle>{group.label}</GroupTitle>} {showGroupTitles && <GroupTitle>{group.label}</GroupTitle>}
{group.items.map(renderItem)} {group.items.map(renderItem)}
</Fragment> </Fragment>
))} ))}
</TemplateGrid> </Flex>
); );
}; };
+5 -2
View File
@@ -51,6 +51,7 @@ const GPUService: React.FC = () => {
setClusterSession({ setClusterSession({
firstAddWorker: false, firstAddWorker: false,
firstAddCluster: true, firstAddCluster: true,
presetClusterType: 'gpu',
providerHint: ProviderValueMap.Kubernetes providerHint: ProviderValueMap.Kubernetes
}); });
navigate('/resources/clusters/list'); navigate('/resources/clusters/list');
@@ -111,7 +112,7 @@ const GPUService: React.FC = () => {
useEffect(() => { useEffect(() => {
fetchClusterList({ page: -1 }); fetchClusterList({ page: -1 });
(async () => { const fetchPVCapacities = async () => {
try { try {
const res = await queryGPUServiceStorage({ page: -1 } as any); const res = await queryGPUServiceStorage({ page: -1 } as any);
const map: Record<string, string> = {}; const map: Record<string, string> = {};
@@ -124,7 +125,8 @@ const GPUService: React.FC = () => {
} catch { } catch {
// best-effort; the popover falls back to the PV name // best-effort; the popover falls back to the PV name
} }
})(); };
fetchPVCapacities();
}, []); }, []);
const hasK8sCluster = useMemo( const hasK8sCluster = useMemo(
@@ -387,6 +389,7 @@ const GPUService: React.FC = () => {
data={openInstanceModalStatus.currentData} data={openInstanceModalStatus.currentData}
width={openInstanceModalStatus.width} width={openInstanceModalStatus.width}
realAction={openInstanceModalStatus.realAction} realAction={openInstanceModalStatus.realAction}
clusterList={clusterList}
onCancel={closeInstanceModal} onCancel={closeInstanceModal}
onOk={handleModalOk} onOk={handleModalOk}
/> />
@@ -85,6 +85,9 @@ const formatResources = (
}; };
}; };
// Spec popover categories, in render order.
type SpecCategory = 'gpu' | 'cpu' | 'ram' | 'disk';
export const renderInstanceType = ( export const renderInstanceType = (
record: ListItem, record: ListItem,
options: { options: {
@@ -92,22 +95,31 @@ export const renderInstanceType = (
// name → capacity (e.g. "20Gi") for referenced persistent volumes, so the // name → capacity (e.g. "20Gi") for referenced persistent volumes, so the
// Disk → Persistent row can show the size instead of just the PV name. // Disk → Persistent row can show the size instead of just the PV name.
pvCapacityByName?: Record<string, string>; pvCapacityByName?: Record<string, string>;
// Limit the spec popover to these categories (default: all). The Instance
// Types breakdown only wants CPU + RAM, for example.
categories?: SpecCategory[];
// Override the primary label (default: derived "<product> x <count>" /
// "CPU Only"). The Instance Types breakdown keeps its plain product name.
title?: string;
} }
) => { ) => {
const { intl, pvCapacityByName } = options; const { intl, pvCapacityByName, categories } = options;
const description = const description =
parseJsonSafe<any>(record?.description || '{}', {}).spec || {}; parseJsonSafe<any>(record?.description || '{}', {}).spec || {};
const resources = formatResources({ spec: description }, record); const resources = formatResources({ spec: description }, record);
const accelerator = record.spec?.resources?.accelerator; const accelerator = record.spec?.resources?.accelerator;
const title = description.acceleratable const title =
? `${description.product} x ${accelerator}` options.title ??
: 'CPU Only'; (description.acceleratable
? `${description.product} x ${accelerator}`
: 'CPU Only');
const volume = (record.spec as any)?.volume; const volume = (record.spec as any)?.volume;
// Spec popover grouped by category (GPU / CPU / Memory / Disk), mirroring // Spec popover grouped by category (GPU / CPU / Memory / Disk), mirroring
// the Deployments instance info icon: dark tooltip, per-category icon, // the Deployments instance info icon: dark tooltip, per-category icon,
// instance name as the title. Rows with no value are dropped. // instance name as the title. Rows with no value are dropped.
type Section = { type Section = {
key: SpecCategory;
icon: string; icon: string;
name: string; name: string;
rows: [string | null, string | undefined][]; rows: [string | null, string | undefined][];
@@ -115,6 +127,7 @@ export const renderInstanceType = (
const sections: Section[] = []; const sections: Section[] = [];
if (description.acceleratable) { if (description.acceleratable) {
sections.push({ sections.push({
key: 'gpu',
icon: 'icon-gpu', icon: 'icon-gpu',
name: 'GPU', name: 'GPU',
rows: [ rows: [
@@ -134,16 +147,19 @@ export const renderInstanceType = (
}); });
} }
sections.push({ sections.push({
key: 'cpu',
icon: 'icon-cpu', icon: 'icon-cpu',
name: 'CPU', name: 'CPU',
rows: [[null, resources.cpu]] rows: [[null, resources.cpu]]
}); });
sections.push({ sections.push({
key: 'ram',
icon: 'icon-ram-02', icon: 'icon-ram-02',
name: intl.formatMessage({ id: 'gpuservice.instance.ram' }), name: intl.formatMessage({ id: 'gpuservice.instance.ram' }),
rows: [[null, resources.ram]] rows: [[null, resources.ram]]
}); });
sections.push({ sections.push({
key: 'disk',
icon: 'icon-hard-disk', icon: 'icon-hard-disk',
name: intl.formatMessage({ id: 'gpuservice.instance.disk' }), name: intl.formatMessage({ id: 'gpuservice.instance.disk' }),
rows: [ rows: [
@@ -170,8 +186,16 @@ export const renderInstanceType = (
] ]
}); });
const visibleSections = categories
? sections.filter((s) => categories.includes(s.key))
: sections;
return ( return (
<InstanceTypeCell title={title} name={record.name} sections={sections} /> <InstanceTypeCell
title={title}
name={record.name}
sections={visibleSections}
/>
); );
}; };
@@ -7,6 +7,7 @@ import { FormData } from '../config/types';
const NFSForm = ({ action }: { action: string }) => { const NFSForm = ({ action }: { action: string }) => {
const intl = useIntl(); const intl = useIntl();
const { getRuleMessage } = useAppUtils(); const { getRuleMessage } = useAppUtils();
const disabled = action === PageAction.EDIT;
return ( return (
<> <>
@@ -24,6 +25,7 @@ const NFSForm = ({ action }: { action: string }) => {
> >
<CInput.Input <CInput.Input
required required
disabled={disabled}
description={intl.formatMessage({ description={intl.formatMessage({
id: 'gpuservice.storageType.nfs.server.tips' id: 'gpuservice.storageType.nfs.server.tips'
})} })}
@@ -43,6 +45,7 @@ const NFSForm = ({ action }: { action: string }) => {
> >
<CInput.Input <CInput.Input
required required
disabled={disabled}
description={intl.formatMessage({ description={intl.formatMessage({
id: 'gpuservice.storageType.nfs.share.tips' id: 'gpuservice.storageType.nfs.share.tips'
})} })}
@@ -51,6 +54,7 @@ const NFSForm = ({ action }: { action: string }) => {
</Form.Item> </Form.Item>
<Form.Item<FormData> name={['spec', 'nfs', 'subDirectory']}> <Form.Item<FormData> name={['spec', 'nfs', 'subDirectory']}>
<CInput.Input <CInput.Input
disabled={disabled}
label={intl.formatMessage({ label={intl.formatMessage({
id: 'gpuservice.storageType.nfs.subDirectory' id: 'gpuservice.storageType.nfs.subDirectory'
})} })}
@@ -58,6 +62,7 @@ const NFSForm = ({ action }: { action: string }) => {
</Form.Item> </Form.Item>
<Form.Item<FormData> name={['spec', 'nfs', 'mountPermissions']}> <Form.Item<FormData> name={['spec', 'nfs', 'mountPermissions']}>
<CInput.Input <CInput.Input
disabled={disabled}
placeholder="0755,0777,..." placeholder="0755,0777,..."
description={intl.formatMessage({ description={intl.formatMessage({
id: 'gpuservice.storageType.nfs.mountPermissions.tips' id: 'gpuservice.storageType.nfs.mountPermissions.tips'
@@ -69,7 +74,7 @@ const NFSForm = ({ action }: { action: string }) => {
</Form.Item> </Form.Item>
<Form.Item<FormData> name={['spec', 'nfs', 'mountOptions']}> <Form.Item<FormData> name={['spec', 'nfs', 'mountOptions']}>
<ListInput <ListInput
disabled={action === PageAction.EDIT} disabled={disabled}
label={intl.formatMessage({ label={intl.formatMessage({
id: 'gpuservice.storageType.mountOptions' id: 'gpuservice.storageType.mountOptions'
})} })}
@@ -14,6 +14,7 @@ const S3Form = ({ action }: { action: string }) => {
const intl = useIntl(); const intl = useIntl();
const form = Form.useFormInstance<FormData>(); const form = Form.useFormInstance<FormData>();
const { getRuleMessage } = useAppUtils(); const { getRuleMessage } = useAppUtils();
const disabled = action === PageAction.EDIT;
const handleEndpointBlur = (e: any) => { const handleEndpointBlur = (e: any) => {
const value: string = e.target.value; const value: string = e.target.value;
@@ -51,6 +52,7 @@ const S3Form = ({ action }: { action: string }) => {
> >
<CInput.Input <CInput.Input
required required
disabled={disabled}
label={intl.formatMessage({ label={intl.formatMessage({
id: 'gpuservice.storageType.s3.endpoint' id: 'gpuservice.storageType.s3.endpoint'
})} })}
@@ -65,6 +67,7 @@ const S3Form = ({ action }: { action: string }) => {
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<Form.Item<FormData> name={['spec', 's3', 'region']}> <Form.Item<FormData> name={['spec', 's3', 'region']}>
<CInput.Input <CInput.Input
disabled={disabled}
label={intl.formatMessage({ label={intl.formatMessage({
id: 'gpuservice.storageType.s3.region' id: 'gpuservice.storageType.s3.region'
})} })}
@@ -86,6 +89,7 @@ const S3Form = ({ action }: { action: string }) => {
> >
<CInput.Input <CInput.Input
required required
disabled={disabled}
description={ description={
<Flex orientation="vertical" gap={4} align="start"> <Flex orientation="vertical" gap={4} align="start">
<span> <span>
@@ -111,6 +115,7 @@ const S3Form = ({ action }: { action: string }) => {
</Flex> </Flex>
<Form.Item<FormData> name={['spec', 's3', 'accessKey']}> <Form.Item<FormData> name={['spec', 's3', 'accessKey']}>
<CInput.Input <CInput.Input
disabled={disabled}
label={intl.formatMessage({ label={intl.formatMessage({
id: 'gpuservice.storageType.s3.accessKey' id: 'gpuservice.storageType.s3.accessKey'
})} })}
@@ -118,6 +123,7 @@ const S3Form = ({ action }: { action: string }) => {
</Form.Item> </Form.Item>
<Form.Item<FormData> name={['spec', 's3', 'secretKey']}> <Form.Item<FormData> name={['spec', 's3', 'secretKey']}>
<CInput.Password <CInput.Password
disabled={disabled}
label={intl.formatMessage({ label={intl.formatMessage({
id: 'gpuservice.storageType.s3.secretKey' id: 'gpuservice.storageType.s3.secretKey'
})} })}
@@ -129,6 +135,7 @@ const S3Form = ({ action }: { action: string }) => {
style={{ marginBottom: 12 }} style={{ marginBottom: 12 }}
> >
<CheckboxField <CheckboxField
disabled={disabled}
description={intl.formatMessage({ description={intl.formatMessage({
id: 'gpuservice.storageType.s3.insecure.tips' id: 'gpuservice.storageType.s3.insecure.tips'
})} })}
@@ -139,7 +146,7 @@ const S3Form = ({ action }: { action: string }) => {
</Form.Item> </Form.Item>
<Form.Item<FormData> name={['spec', 's3', 'mountOptions']}> <Form.Item<FormData> name={['spec', 's3', 'mountOptions']}>
<ListInput <ListInput
disabled={action === PageAction.EDIT} disabled={disabled}
label={intl.formatMessage({ label={intl.formatMessage({
id: 'gpuservice.storageType.mountOptions' id: 'gpuservice.storageType.mountOptions'
})} })}
@@ -36,7 +36,6 @@ import { ListItem } from '../config/types';
const imageLogoMap = { const imageLogoMap = {
vllm: vllmLogo, vllm: vllmLogo,
sglang: sgLangLogo, sglang: sgLangLogo,
cann: ascendLogo,
jupyter: jupyterLogo, jupyter: jupyterLogo,
pytorch: pytorchBlackLogo, pytorch: pytorchBlackLogo,
tensorflow: tensorflowkLogo, tensorflow: tensorflowkLogo,
@@ -1,5 +1,5 @@
import PluginExtraFields from '@/components/plugin-extra-fields'; import PluginExtraFields from '@/components/plugin-extra-fields';
import { validateLabelNameRegxFor63 } from '@/config'; import { PageAction, validateLabelNameRegxFor63 } from '@/config';
import { PageActionType } from '@/config/types'; import { PageActionType } from '@/config/types';
import { import {
Input as CInput, Input as CInput,
@@ -86,7 +86,7 @@ const Basic: React.FC<BasicProps> = ({
]} ]}
> >
<CInput.Input <CInput.Input
disabled={disabled} disabled={disabled || action === PageAction.EDIT}
label={intl.formatMessage({ id: 'common.table.name' })} label={intl.formatMessage({ id: 'common.table.name' })}
required required
/> />
+2 -1
View File
@@ -28,6 +28,7 @@ const Catalog: React.FC = () => {
dataSource, dataSource,
queryParams, queryParams,
handleSearch, handleSearch,
fetchData,
handleQueryChange, handleQueryChange,
loadMore, loadMore,
handleNameChange handleNameChange
@@ -137,7 +138,7 @@ const Catalog: React.FC = () => {
marginBottom={22} marginBottom={22}
marginTop={0} marginTop={0}
buttonText={intl.formatMessage({ id: 'models.catalog.button.explore' })} buttonText={intl.formatMessage({ id: 'models.catalog.button.explore' })}
handleSearch={handleSearch} handleSearch={() => loadMore(1)}
handleSelectChange={handleCategoryChange} handleSelectChange={handleCategoryChange}
handleClickPrimary={handleDeployFromOtherHubs} handleClickPrimary={handleDeployFromOtherHubs}
handleInputChange={handleNameChange} handleInputChange={handleNameChange}
@@ -15,7 +15,7 @@ import {
ScrollerModal ScrollerModal
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl, useNavigate } from '@umijs/max'; import { useIntl, useNavigate } from '@umijs/max';
import { Button, Tag } from 'antd'; import { Button, Flex, Tag } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
@@ -79,6 +79,7 @@ const Tips = styled.div`
const APITAG = styled(Tag)` const APITAG = styled(Tag)`
border-radius: 12px; border-radius: 12px;
margin: 0; margin: 0;
width: fit-content;
margin-left: 8px; margin-left: 8px;
`; `;
@@ -149,7 +150,7 @@ const ApiAccessInfo = ({ open, data, onClose }: ApiAccessInfoProps) => {
{intl.formatMessage({ id: 'models.table.button.apiAccessInfo' })} {intl.formatMessage({ id: 'models.table.button.apiAccessInfo' })}
</span> </span>
} }
width={600} width={660}
destroyOnHidden destroyOnHidden
closable={true} closable={true}
mask={{ mask={{
@@ -164,10 +165,15 @@ const ApiAccessInfo = ({ open, data, onClose }: ApiAccessInfoProps) => {
<span className="label"> <span className="label">
{intl.formatMessage({ id: 'models.table.apiAccessInfo.endpoint' })} {intl.formatMessage({ id: 'models.table.apiAccessInfo.endpoint' })}
</span> </span>
<span className="value"> <Flex align="center" wrap>
<AutoTooltip ghost maxWidth={data.generic_proxy ? 400 : 240}> <span
{endPoint} className="value"
</AutoTooltip> style={{ display: 'flex', flex: 1, minWidth: 0 }}
>
<AutoTooltip ghost maxWidth={'100%'}>
{endPoint}
</AutoTooltip>
</span>
{!data.generic_proxy && ( {!data.generic_proxy && (
<> <>
<APITAG color="geekblue"> <APITAG color="geekblue">
@@ -188,7 +194,7 @@ const ApiAccessInfo = ({ open, data, onClose }: ApiAccessInfoProps) => {
)} )}
</> </>
)} )}
</span> </Flex>
<span className="copy-btn"> <span className="copy-btn">
<CopyButton text={endPoint} type="link" size="small"></CopyButton> <CopyButton text={endPoint} type="link" size="small"></CopyButton>
</span> </span>
@@ -197,32 +203,36 @@ const ApiAccessInfo = ({ open, data, onClose }: ApiAccessInfoProps) => {
id: 'models.table.apiAccessInfo.modelName' id: 'models.table.apiAccessInfo.modelName'
})} })}
</span> </span>
<span className="value"> <Flex align="center">
<AutoTooltip ghost maxWidth={300}> <span className="value">
{data.name} <AutoTooltip ghost maxWidth={300}>
</AutoTooltip> {data.name}
</span> </AutoTooltip>
</span>
</Flex>
<span className="copy-btn"> <span className="copy-btn">
<CopyButton text={data.name} type="link" size="small"></CopyButton> <CopyButton text={data.name} type="link" size="small"></CopyButton>
</span> </span>
<span className="label"> <span className="label">
{intl.formatMessage({ id: 'models.table.apiAccessInfo.apikey' })} {intl.formatMessage({ id: 'models.table.apiAccessInfo.apikey' })}
</span> </span>
<span className="value"> <Flex align="center">
<CreateButton <span className="value">
type="link" <CreateButton
size="small" type="link"
onClick={() => navigate('/access-control/api-keys')} size="small"
> onClick={() => navigate('/access-control/api-keys')}
{intl.formatMessage({ >
id: 'models.table.apiAccessInfo.gotoCreate' {intl.formatMessage({
})} id: 'models.table.apiAccessInfo.gotoCreate'
<IconFont })}
type="icon-external-link" <IconFont
className="font-size-14" type="icon-external-link"
></IconFont> className="font-size-14"
</CreateButton> ></IconFont>
</span> </CreateButton>
</span>
</Flex>
</ApiAccessInfoWrapper> </ApiAccessInfoWrapper>
{data.generic_proxy && ( {data.generic_proxy && (
<div style={{ marginTop: 12 }}>{GenericProxyCommandCode}</div> <div style={{ marginTop: 12 }}>{GenericProxyCommandCode}</div>
@@ -31,4 +31,24 @@ const FileSkeleton: React.FC<{ counts: number; itemHeight?: number }> = ({
); );
}; };
export const FileSkeletonRows: React.FC<{
counts: number;
itemHeight?: number;
}> = ({ counts = 2, itemHeight }) => {
return (
<Wrapper orientation="vertical" style={{ height: itemHeight || 'auto' }}>
<Skeleton paragraph={{ rows: 1, width: '100%' }} title={false}></Skeleton>
<Flex orientation="vertical" gap={12}>
<Skeleton
paragraph={{
rows: counts,
width: Array.from({ length: counts }, () => '60%')
}}
title={false}
></Skeleton>
</Flex>
</Wrapper>
);
};
export default FileSkeleton; export default FileSkeleton;
@@ -55,6 +55,12 @@ const ViewLogsModal: React.FC<ViewModalProps> = (props) => {
); );
}, [countOptions, showPrevious]); }, [countOptions, showPrevious]);
const hasPreviousContainer = useMemo(() => {
return countOptions?.some((option) =>
option.children?.some((child) => child.previous)
);
}, [countOptions]);
const handleCancel = useCallback(() => { const handleCancel = useCallback(() => {
logsViewerRef.current?.abort(); logsViewerRef.current?.abort();
onCancel(); onCancel();
@@ -114,7 +120,7 @@ const ViewLogsModal: React.FC<ViewModalProps> = (props) => {
watch: !option.previous, watch: !option.previous,
previous: option.previous, previous: option.previous,
worker_id: option.worker_id, worker_id: option.worker_id,
container: option.container container_name: option.container
}); });
} }
setSelectedContainer(option?.parentValue || null); setSelectedContainer(option?.parentValue || null);
@@ -186,15 +192,15 @@ const ViewLogsModal: React.FC<ViewModalProps> = (props) => {
{intl.formatMessage({ id: 'common.button.viewlog' })} {intl.formatMessage({ id: 'common.button.viewlog' })}
</span> </span>
<span className="flex-center gap-8" style={{ height: 32 }}> <span className="flex-center gap-8" style={{ height: 32 }}>
{showCascader && ( <div
<div style={{
style={{ display: 'flex',
display: 'flex', alignItems: 'center',
alignItems: 'center', gap: 16,
gap: 16, marginRight: 8
marginRight: 8 }}
}} >
> {hasPreviousContainer && (
<Checkbox onChange={handleOnChecked} checked={showPrevious}> <Checkbox onChange={handleOnChecked} checked={showPrevious}>
<Tooltip <Tooltip
title={ title={
@@ -219,6 +225,9 @@ const ViewLogsModal: React.FC<ViewModalProps> = (props) => {
<QuestionCircleOutlined style={{ marginLeft: 4 }} /> <QuestionCircleOutlined style={{ marginLeft: 4 }} />
</Tooltip> </Tooltip>
</Checkbox> </Checkbox>
)}
{showCascader && (
<BaseSelect <BaseSelect
prefix={ prefix={
<span <span
@@ -240,9 +249,8 @@ const ViewLogsModal: React.FC<ViewModalProps> = (props) => {
onChange={handleContainerChange} onChange={handleContainerChange}
style={{ width: 360 }} style={{ width: 360 }}
></BaseSelect> ></BaseSelect>
</div> )}
)} </div>
<Button <Button
type="text" type="text"
color="default" color="default"
+1 -1
View File
@@ -455,7 +455,7 @@ export const getBackendParamsTips = (backend: string) => {
return { return {
backend: 'SGLang', backend: 'SGLang',
releases: '', releases: '',
link: 'https://docs.sglang.ai/advanced_features/server_arguments.html', link: 'https://docs.sglang.io/docs/advanced_features/server_arguments',
version: 'v0.5.4' version: 'v0.5.4'
}; };
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+16 -1
View File
@@ -278,6 +278,21 @@ const Models = forwardRef((props, ref) => {
fetchData({ fetchData({
loadingVal: false loadingVal: false
}); });
// re-align the instance cache with the backend as a backstop, in case a
// DELETE watch event for terminated instances was missed
getAllModelInstances();
});
// proactively drop a model's instances from the cache when it is stopped, so
// a stale (terminating) instance can't linger and show up alongside the new
// one after an immediate restart
const handleStop = useMemoizedFn(async (modelIds: number[]) => {
const idSet = new Set(modelIds);
cacheInsDataListRef.current = cacheInsDataListRef.current.filter(
(item) => !idSet.has(item.model_id)
);
setModelInstances(cacheInsDataListRef.current);
handleSearchBySilent();
}); });
const handleSearch = useMemoizedFn(async (params?: any) => { const handleSearch = useMemoizedFn(async (params?: any) => {
@@ -456,7 +471,7 @@ const Models = forwardRef((props, ref) => {
handleOnToggleExpandAll={createModelsInstanceChunkRequest} handleOnToggleExpandAll={createModelsInstanceChunkRequest}
onViewLogs={cancelRequestsOnPageInactive} onViewLogs={cancelRequestsOnPageInactive}
onCancelViewLogs={handleOnCancelViewLogs} onCancelViewLogs={handleOnCancelViewLogs}
onStop={handleSearchBySilent} onStop={handleStop}
onStart={handleSearchBySilent} onStart={handleSearchBySilent}
onTableSort={handleOnSortChange} onTableSort={handleOnSortChange}
onFilterChange={handleOnFilterChange} onFilterChange={handleOnFilterChange}
+61 -53
View File
@@ -29,21 +29,12 @@ const useStyles = createStyles(({ token, css }) => {
} }
} }
`, `,
password: css` passwordWrapper: css`
.ant-input-suffix { position: relative;
display: flex; .forgot-password {
flex-direction: row-reverse; position: absolute;
.reset-link { right: 0;
padding: 0; top: 74px;
font-size: 12px;
color: ${token.colorTextSecondary};
}
.ant-divider {
margin-left: 2px;
}
.reset-link:hover {
color: var(--ant-color-link-hover);
}
} }
` `
}; };
@@ -97,6 +88,12 @@ const LocalUserForm: React.FC<LocalUserFormProps> = (props) => {
// enterprise cover layout (272px inner). // enterprise cover layout (272px inner).
layout="vertical" layout="vertical"
requiredMark={false} requiredMark={false}
styles={{
label: {
width: '100%',
lineHeight: '24px'
}
}}
style={{ width: '360px', maxWidth: '100%', margin: '0 auto' }} style={{ width: '360px', maxWidth: '100%', margin: '0 auto' }}
onFinish={handleLogin} onFinish={handleLogin}
> >
@@ -122,54 +119,65 @@ const LocalUserForm: React.FC<LocalUserFormProps> = (props) => {
autoComplete="username" autoComplete="username"
prefix={<UserOutlined />} prefix={<UserOutlined />}
style={{ height: 44 }} style={{ height: 44 }}
placeholder={intl.formatMessage({
id: 'common.login.username.holder'
})}
/> />
</Form.Item> </Form.Item>
<Form.Item <div className={`${styles.passwordWrapper} password-wrapper`}>
style={{ <Form.Item
marginBottom: 20 style={{
}} marginBottom: 20
name="password" }}
label={intl.formatMessage({ id: 'common.form.password' })} name="password"
rules={[ label={
{ <Flex
required: true, align="center"
message: intl.formatMessage( justify="space-between"
{ id: 'common.form.rule.input' }, style={{ width: '100%' }}
{ >
name: intl.formatMessage({ id: 'common.form.password' }) <span>{intl.formatMessage({ id: 'common.form.password' })}</span>
}
)
}
]}
>
<Input.Password
className={styles.password}
autoComplete="current-password"
prefix={<LockOutlined />}
style={{ height: 44 }}
suffix={
<Flex align="center" gap={0}>
<Button
type="link"
size="small"
className="reset-link"
href={externalLinks.resetPassword}
target="_blank"
>
{intl.formatMessage({ id: 'common.button.forgotpassword' })}
</Button>
<Divider orientation="vertical" />
</Flex> </Flex>
} }
/> rules={[
</Form.Item> {
required: true,
message: intl.formatMessage(
{ id: 'common.form.rule.input' },
{
name: intl.formatMessage({ id: 'common.form.password' })
}
)
}
]}
>
<Input.Password
autoComplete="current-password"
prefix={<LockOutlined />}
style={{ height: 44 }}
placeholder={intl.formatMessage({
id: 'common.login.password.holder'
})}
/>
</Form.Item>
<Button
type="link"
size="small"
className="forgot-password"
style={{ fontSize: 12, padding: 0 }}
href={externalLinks.resetPassword}
target="_blank"
>
{intl.formatMessage({ id: 'common.button.forgotpassword' })}
</Button>
</div>
<Button <Button
htmlType="submit" htmlType="submit"
type="primary" type="primary"
block block
loading={props.loading} loading={props.loading}
icon={<IconFont type="icon-login" />} icon={<IconFont type="icon-login" />}
style={{ height: '44px', fontSize: '14px', marginTop: 16 }} style={{ height: '44px', fontSize: '14px', marginTop: 20 }}
> >
{intl.formatMessage({ id: 'common.button.login' })} {intl.formatMessage({ id: 'common.button.login' })}
</Button> </Button>
+1 -1
View File
@@ -139,7 +139,7 @@ const PasswordForm: React.FC = () => {
htmlType="submit" htmlType="submit"
type="primary" type="primary"
block block
style={{ height: '44px', fontSize: '14px', marginTop: 16 }} style={{ height: '44px', fontSize: '14px', marginTop: 8 }}
> >
{intl.formatMessage({ id: 'common.button.submit' })} {intl.formatMessage({ id: 'common.button.submit' })}
</Button> </Button>
+4
View File
@@ -74,6 +74,10 @@ const Box = styled.div`
min-height: 100vh; min-height: 100vh;
.ant-form-item-label { .ant-form-item-label {
line-height: 1; line-height: 1;
padding-bottom: 4px;
}
.ant-form-item-label > label:after {
display: none;
} }
.ant-form-item-label > label { .ant-form-item-label > label {
height: auto !important; height: auto !important;
@@ -3,6 +3,7 @@ import ai360 from '@/assets/providers-logo/ai360.svg';
import azure from '@/assets/providers-logo/azureai.svg'; import azure from '@/assets/providers-logo/azureai.svg';
import baichuan from '@/assets/providers-logo/baichuan.svg'; import baichuan from '@/assets/providers-logo/baichuan.svg';
import baiduCloud from '@/assets/providers-logo/baiducloud.svg'; import baiduCloud from '@/assets/providers-logo/baiducloud.svg';
import qwen from '@/assets/providers-logo/bailian.svg';
import bedrock from '@/assets/providers-logo/bedrock.svg'; import bedrock from '@/assets/providers-logo/bedrock.svg';
import claude from '@/assets/providers-logo/claude.svg'; import claude from '@/assets/providers-logo/claude.svg';
import cloudflare from '@/assets/providers-logo/cloudflare.svg'; import cloudflare from '@/assets/providers-logo/cloudflare.svg';
@@ -26,7 +27,6 @@ import triton from '@/assets/providers-logo/nvidia.svg';
import ollama from '@/assets/providers-logo/ollama.svg'; import ollama from '@/assets/providers-logo/ollama.svg';
import openai from '@/assets/providers-logo/openai.svg'; import openai from '@/assets/providers-logo/openai.svg';
import openrouter from '@/assets/providers-logo/openrouter.svg'; import openrouter from '@/assets/providers-logo/openrouter.svg';
import qwen from '@/assets/providers-logo/qwen.svg';
import spark from '@/assets/providers-logo/spark.svg'; import spark from '@/assets/providers-logo/spark.svg';
import stepfun from '@/assets/providers-logo/stepfun.svg'; import stepfun from '@/assets/providers-logo/stepfun.svg';
import togetherai from '@/assets/providers-logo/together.svg'; import togetherai from '@/assets/providers-logo/together.svg';
@@ -70,12 +70,17 @@ export const ProviderI18nKeyMap: Record<ProviderEnum, string> = {
[ProviderEnum.ZHIPUAI]: 'ai.provider.zhipuai' [ProviderEnum.ZHIPUAI]: 'ai.provider.zhipuai'
}; };
export const ProviderDescriptionMap: Record<ProviderEnum, string> = {
[ProviderEnum.QWEN]: 'qwen, bailian, ali,alicloud,百炼, 阿里, 阿里云百炼'
} as Record<ProviderEnum, string>;
// generate provider list: {label: string;value:string}[] // generate provider list: {label: string;value:string}[]
export const maasProviderOptions = Object.entries(ProviderEnum).map( export const maasProviderOptions = Object.entries(ProviderEnum).map(
([key, value]) => ({ ([key, value]) => ({
locale: true, locale: true,
label: ProviderI18nKeyMap[value], label: ProviderI18nKeyMap[value],
value: value, value: value,
description: ProviderDescriptionMap[value] || '',
key: value key: value
}) })
); );
+21 -2
View File
@@ -12,7 +12,9 @@ import ProviderLogo from '../components/provider-logo';
import { useFormContext } from '../config/form-context'; import { useFormContext } from '../config/form-context';
import { maasProviderOptions } from '../config/providers'; import { maasProviderOptions } from '../config/providers';
import { FormData } from '../config/types'; import { FormData } from '../config/types';
import providerTypeStyles from '../styles/provider-type.less';
import ProviderConfigs from './provider-configs'; import ProviderConfigs from './provider-configs';
const Basic: React.FC<{ const Basic: React.FC<{
onAPIKeyBlur?: (e: any) => void; onAPIKeyBlur?: (e: any) => void;
}> = ({ onAPIKeyBlur }) => { }> = ({ onAPIKeyBlur }) => {
@@ -32,12 +34,28 @@ const Basic: React.FC<{
}; };
const filterOption = (input: string, option?: any) => { const filterOption = (input: string, option?: any) => {
console.log('filterOption input, option: ', input, option);
return ( return (
option?.label?.toLowerCase().includes(input.toLowerCase()) || option?.label?.toLowerCase().includes(input.toLowerCase()) ||
option?.value?.toLowerCase().includes(input.toLowerCase()) option?.value?.toLowerCase().includes(input.toLowerCase()) ||
option?.description?.toLowerCase().includes(input.toLowerCase())
); );
}; };
const renderLogoPrefix = () => {
const hasProvider = maasProviderOptions.some(
(option) => option.value === providerType
);
if (hasProvider) {
return (
<div style={{ display: 'flex', alignItems: 'end', height: '100%' }}>
<ProviderLogo provider={providerType as string} />
</div>
);
}
return null;
};
return ( return (
<> <>
<Form.Item<FormData> <Form.Item<FormData>
@@ -73,9 +91,10 @@ const Basic: React.FC<{
filterOption: filterOption filterOption: filterOption
}} }}
required required
className={providerTypeStyles.providerType}
prefix={renderLogoPrefix()}
options={maasProviderOptions} options={maasProviderOptions}
optionRender={optionRender} optionRender={optionRender}
labelRender={optionRender}
label={intl.formatMessage({ label={intl.formatMessage({
id: 'common.table.type' id: 'common.table.type'
})} })}
@@ -0,0 +1,11 @@
.providerType {
:global {
.ant-select-prefix {
height: 100%;
display: flex;
}
}
:global(input.ant-select-input) {
padding-top: 6px !important;
}
}
@@ -1,13 +1,20 @@
import { getCurrentOrg, getOrgById } from '@/atoms/user'; import { getCurrentOrg, getOrgById } from '@/atoms/user';
import { getGPUStackPlugin } from '@/plugins';
import { useNavigate } from '@umijs/max'; import { useNavigate } from '@umijs/max';
import { modelCategoriesMap } from '../../llmodels/config'; import { modelCategoriesMap } from '../../llmodels/config';
import { categoryToPathMap } from '../../llmodels/config/button-actions'; import { categoryToPathMap } from '../../llmodels/config/button-actions';
const useOpenPlayground = () => { const useOpenPlayground = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const plugin = getGPUStackPlugin();
const generateModelName = (row: any) => { const generateModelName = (row: any) => {
// if has plugin, it means is enterpise edition, we need to generate the model name with org name prefix, otherwise, we just use the model name
if (!plugin) {
return row.name;
}
const org = getOrgById(row.owner_principal_id) ?? getCurrentOrg(); const org = getOrgById(row.owner_principal_id) ?? getCurrentOrg();
// The platform Org is always named ``default`` (backend constant // The platform Org is always named ``default`` (backend constant
// ``PLATFORM_PRINCIPAL_NAME``); its models are reported by // ``PLATFORM_PRINCIPAL_NAME``); its models are reported by
// ``/v1/models`` without a prefix. Match by name too, not just the // ``/v1/models`` without a prefix. Match by name too, not just the
@@ -27,6 +34,7 @@ const useOpenPlayground = () => {
// ``owner_principal_id`` (an org principal id); fall back to the Org the // ``owner_principal_id`` (an org principal id); fall back to the Org the
// caller is currently acting under for the admin "All" view, where the // caller is currently acting under for the admin "All" view, where the
// row's owner still resolves via the platform-wide org cache. // row's owner still resolves via the platform-wide org cache.
const rawModel = generateModelName(row); const rawModel = generateModelName(row);
const modelName = encodeURIComponent(rawModel); const modelName = encodeURIComponent(rawModel);
+2 -2
View File
@@ -392,7 +392,7 @@ export const CustomSizeConfig: ParamsSchema[] = [
max: 1024, max: 1024,
step: 64, step: 64,
labelWidth: 314, labelWidth: 314,
inputnumber: false inputnumber: true
}, },
rules: [ rules: [
{ {
@@ -413,7 +413,7 @@ export const CustomSizeConfig: ParamsSchema[] = [
max: 1024, max: 1024,
step: 64, step: 64,
labelWidth: 314, labelWidth: 314,
inputnumber: false inputnumber: true
}, },
rules: [ rules: [
{ {
+26 -14
View File
@@ -75,10 +75,32 @@ export const useInitImageMeta = (
...advancedFieldsDefaultValus ...advancedFieldsDefaultValus
}); });
const ensureSizeOption = (
options: SizeOption[],
width?: number,
height?: number
) => {
if (
width &&
height &&
!options.find((item) => item.width === width && item.height === height)
) {
options.push({
width,
height,
label: `${width}x${height}`,
value: `${width}x${height}`
});
}
};
const getNewImageSizeOptions = (metaData: any) => { const getNewImageSizeOptions = (metaData: any) => {
const { max_height, max_width } = metaData || {}; const { max_height, max_width, default_width, default_height } =
metaData || {};
if (!max_height || !max_width) { if (!max_height || !max_width) {
return imageSizeList; const newImageSizeOptions = [...imageSizeList];
ensureSizeOption(newImageSizeOptions, default_width, default_height);
return newImageSizeOptions;
} }
const newImageSizeOptions = imageSizeList.filter((item) => { const newImageSizeOptions = imageSizeList.filter((item) => {
return ( return (
@@ -86,18 +108,8 @@ export const useInitImageMeta = (
item.value === 'custom' item.value === 'custom'
); );
}); });
if ( ensureSizeOption(newImageSizeOptions, max_width, max_height);
!newImageSizeOptions.find( ensureSizeOption(newImageSizeOptions, default_width, default_height);
(item) => item.width === max_width && item.height === max_height
)
) {
newImageSizeOptions.push({
width: max_width,
height: max_height,
label: `${max_width}x${max_height}`,
value: `${max_width}x${max_height}`
});
}
return newImageSizeOptions; return newImageSizeOptions;
}; };
+1 -4
View File
@@ -437,8 +437,5 @@ export const AddWorkerDockerNotes: Record<string, string[]> = {
[GPUDriverMap.ILUVATAR]: ['clusters.addworker.corexNotes'], [GPUDriverMap.ILUVATAR]: ['clusters.addworker.corexNotes'],
[GPUDriverMap.CAMBRICON]: ['clusters.addworker.cambriconNotes'], [GPUDriverMap.CAMBRICON]: ['clusters.addworker.cambriconNotes'],
[GPUDriverMap.METAX]: ['clusters.addworker.metaxNotes'], [GPUDriverMap.METAX]: ['clusters.addworker.metaxNotes'],
[GPUDriverMap.THEAD]: [ [GPUDriverMap.THEAD]: ['clusters.addworker.theadNotes-02']
'clusters.addworker.theadNotes',
'clusters.addworker.theadNotes-02'
]
}; };
@@ -264,11 +264,15 @@ const useWorkerColumns = ({
) { ) {
return ( return (
<span className={workerCss.ipWrapper}> <span className={workerCss.ipWrapper}>
<span className="item"> <span className={workerCss.item}>
<span className="text-primary">{record.ip}</span> <span className="text-primary">{record.ip}</span>
<span className="label">{`(${intl.formatMessage({ id: 'clusters.table.ip.internal' })})`}</span> <span
className={workerCss.label}
>{`(${intl.formatMessage({ id: 'clusters.table.ip.internal' })})`}</span>
<span className="text-primary">{record.advertise_address}</span> <span className="text-primary">{record.advertise_address}</span>
<span className="label">{`(${intl.formatMessage({ id: 'clusters.table.ip.external' })})`}</span> <span
className={workerCss.label}
>{`(${intl.formatMessage({ id: 'clusters.table.ip.external' })})`}</span>
</span> </span>
</span> </span>
); );
+47 -26
View File
@@ -366,32 +366,45 @@ function toServerRequest(data: ResourceBreakdownRequest) {
async function _breakdown( async function _breakdown(
url: string, url: string,
data: ResourceBreakdownRequest data: ResourceBreakdownRequest,
options?: {
token?: any;
}
): Promise<ResourceBreakdownResponse> { ): Promise<ResourceBreakdownResponse> {
const { body, groupBy } = toServerRequest(data); const { body, groupBy } = toServerRequest(data);
const res = await request<ServerBreakdownResponse>(url, { const res = await request<ServerBreakdownResponse>(url, {
data: body, data: body,
method: 'POST' method: 'POST',
cancelToken: options?.token
}); });
return flattenResponse(groupBy, res); return flattenResponse(groupBy, res);
} }
export async function queryResourceBreakdown( export async function queryResourceBreakdown(
data: ResourceBreakdownRequest data: ResourceBreakdownRequest,
options?: {
token?: any;
}
): Promise<ResourceBreakdownResponse> { ): Promise<ResourceBreakdownResponse> {
return _breakdown(URL.RESOURCE_BREAKDOWN, data); return _breakdown(URL.RESOURCE_BREAKDOWN, data, options);
} }
export async function queryGpuInstancesBreakdown( export async function queryGpuInstancesBreakdown(
data: ResourceBreakdownRequest data: ResourceBreakdownRequest,
options?: {
token?: any;
}
): Promise<ResourceBreakdownResponse> { ): Promise<ResourceBreakdownResponse> {
return _breakdown(URL.GPU_BREAKDOWN, data); return _breakdown(URL.GPU_BREAKDOWN, data, options);
} }
export async function queryStorageBreakdown( export async function queryStorageBreakdown(
data: ResourceBreakdownRequest data: ResourceBreakdownRequest,
options?: {
token?: any;
}
): Promise<ResourceBreakdownResponse> { ): Promise<ResourceBreakdownResponse> {
return _breakdown(URL.STORAGE_BREAKDOWN, data); return _breakdown(URL.STORAGE_BREAKDOWN, data, options);
} }
export async function queryResourceEvents( export async function queryResourceEvents(
@@ -406,7 +419,7 @@ export async function queryResourceEvents(
page?: number; page?: number;
perPage?: number; perPage?: number;
}, },
options?: { skipErrorHandler?: boolean } options?: { skipErrorHandler?: boolean; token?: any }
): Promise<ResourceEventsResponse> { ): Promise<ResourceEventsResponse> {
const creatorIds = data.filters?.creator_ids; const creatorIds = data.filters?.creator_ids;
return request<ResourceEventsResponse>(URL.EVENTS, { return request<ResourceEventsResponse>(URL.EVENTS, {
@@ -426,7 +439,8 @@ export async function queryResourceEvents(
perPage: data.perPage ?? 50 perPage: data.perPage ?? 50
}, },
method: 'GET', method: 'GET',
skipErrorHandler: options?.skipErrorHandler skipErrorHandler: options?.skipErrorHandler,
cancelToken: options?.token
}); });
} }
@@ -456,12 +470,15 @@ export async function queryResourceFilterMeta(
}; };
} }
export async function queryUsageSummary(params: { export async function queryUsageSummary(
start_date: string; params: {
end_date: string; start_date: string;
scope?: 'self' | 'all'; end_date: string;
creator_ids?: number[]; scope?: 'self' | 'all';
}): Promise<UsageSummaryResponse> { creator_ids?: number[];
},
options?: { token?: any }
): Promise<UsageSummaryResponse> {
const { creator_ids, ...rest } = params; const { creator_ids, ...rest } = params;
const res = await request<{ const res = await request<{
total_tokens: number; total_tokens: number;
@@ -478,7 +495,8 @@ export async function queryUsageSummary(params: {
scope: params.scope ?? 'all', scope: params.scope ?? 'all',
...(creator_ids?.length ? { creator_ids: creator_ids.join(',') } : {}) ...(creator_ids?.length ? { creator_ids: creator_ids.join(',') } : {})
}, },
method: 'GET' method: 'GET',
cancelToken: options?.token
}); });
// Resource Distribution donut — by GPU type, using GPU-Hours (a single, // Resource Distribution donut — by GPU type, using GPU-Hours (a single,
@@ -486,15 +504,18 @@ export async function queryUsageSummary(params: {
// instance type. (A true cross-resource split needs a common unit.) // instance type. (A true cross-resource split needs a common unit.)
let distribution: SummaryResourceDistributionItem[] = []; let distribution: SummaryResourceDistributionItem[] = [];
try { try {
const byType = await queryGpuInstancesBreakdown({ const byType = await queryGpuInstancesBreakdown(
start_date: params.start_date, {
end_date: params.end_date, start_date: params.start_date,
scope: params.scope ?? 'all', end_date: params.end_date,
group_by: ['gpu_type'], scope: params.scope ?? 'all',
...(creator_ids?.length ? { filters: { creator_ids } } : {}), group_by: ['gpu_type'],
page: 1, ...(creator_ids?.length ? { filters: { creator_ids } } : {}),
perPage: 100 page: 1,
}); perPage: 100
},
{ token: options?.token }
);
const total = byType.items.reduce((s, i) => s + (i.gpu_hours || 0), 0); const total = byType.items.reduce((s, i) => s + (i.gpu_hours || 0), 0);
distribution = byType.items distribution = byType.items
.filter((i) => (i.gpu_hours || 0) > 0) .filter((i) => (i.gpu_hours || 0) > 0)
@@ -40,6 +40,7 @@ interface MetricOption {
} }
interface MetricChartCardProps { interface MetricChartCardProps {
loading?: boolean;
metric: string; metric: string;
metricOptions: MetricOption[]; metricOptions: MetricOption[];
granularity: string; granularity: string;
@@ -55,6 +56,7 @@ interface MetricChartCardProps {
} }
const MetricChartCard: React.FC<MetricChartCardProps> = ({ const MetricChartCard: React.FC<MetricChartCardProps> = ({
loading,
metric, metric,
metricOptions, metricOptions,
granularity, granularity,
@@ -129,6 +131,7 @@ const MetricChartCard: React.FC<MetricChartCardProps> = ({
/> />
</ControlsWrapper> </ControlsWrapper>
<BarChart <BarChart
loading={loading}
seriesData={seriesData} seriesData={seriesData}
xAxisData={xAxisData} xAxisData={xAxisData}
height={280} height={280}
@@ -9,23 +9,15 @@
* event-type selects ride in the bar's ``extra`` slot. * event-type selects ride in the bar's ``extra`` slot.
*/ */
import { useAccess, useIntl } from '@umijs/max'; import { useAccess, useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks';
import { Input, Select, Table, Tag } from 'antd'; import { Input, Select, Table, Tag } from 'antd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import _ from 'lodash'; import _ from 'lodash';
import React, { import React, { useEffect, useMemo, useRef, useState } from 'react';
useCallback, import { ResourceEventItem } from '../apis/resource';
useEffect, import ResourceFilterBar from '../components/resource-filter-bar';
useMemo,
useRef,
useState
} from 'react';
import {
queryResourceEvents,
ResourceEventItem,
ResourceEventsResponse
} from '../apis/resource';
import { parseRollup } from '../utils/time-buckets'; import { parseRollup } from '../utils/time-buckets';
import ResourceFilterBar from './resource-filter-bar'; import useQueryResourceEvents from './services/use-query-resource-events';
// Only these four are ever emitted (see resource_event_logger): create/delete // Only these four are ever emitted (see resource_event_logger): create/delete
// + the metering-window pair. updated/attached/detached exist as enum values // + the metering-window pair. updated/attached/detached exist as enum values
@@ -115,15 +107,17 @@ const ResourceEvents: React.FC = () => {
eventTypes: string[]; eventTypes: string[];
nameQuery: string; nameQuery: string;
page: number; page: number;
perPage: number;
}; };
const [queryParams, setQueryParams] = useState<QueryParams>({ const [queryParams, setQueryParams] = useState<QueryParams>({
dateRange: [dayjs().subtract(29, 'day'), dayjs()], dateRange: [dayjs().subtract(29, 'day'), dayjs()],
resourceType: undefined, resourceType: undefined,
eventTypes: [], eventTypes: [],
nameQuery: '', nameQuery: '',
perPage: 50,
page: 1 page: 1
}); });
const [data, setData] = useState<ResourceEventsResponse | null>(null); const { detailData: data, loading, fetchData } = useQueryResourceEvents();
// Latest params, so the stable debounced name handler reads current values. // Latest params, so the stable debounced name handler reads current values.
const queryRef = useRef(queryParams); const queryRef = useRef(queryParams);
@@ -132,30 +126,20 @@ const ResourceEvents: React.FC = () => {
// Single fetch entry point: merge the patch into the current params, persist // Single fetch entry point: merge the patch into the current params, persist
// them, then request — triggered from each handler rather than from effect // them, then request — triggered from each handler rather than from effect
// dependencies, so there's exactly one request per user action. // dependencies, so there's exactly one request per user action.
const fetchEvents = useCallback( const fetchEvents = useMemoizedFn((patch: Partial<QueryParams>) => {
async (patch: Partial<QueryParams>) => { const params = { ...queryRef.current, ...patch };
const params = { ...queryRef.current, ...patch }; setQueryParams(params);
setQueryParams(params); return fetchData({
try { start_date: params.dateRange[0].format('YYYY-MM-DD'),
const res = await queryResourceEvents({ end_date: params.dateRange[1].format('YYYY-MM-DD'),
start_date: params.dateRange[0].format('YYYY-MM-DD'), scope,
end_date: params.dateRange[1].format('YYYY-MM-DD'), resource_types: params.resourceType ? [params.resourceType] : undefined,
scope, resource_name: params.nameQuery || undefined,
resource_types: params.resourceType event_types: params.eventTypes,
? [params.resourceType] page: params.page,
: undefined, perPage: 50
resource_name: params.nameQuery || undefined, });
event_types: params.eventTypes, });
page: params.page,
perPage: 50
});
setData(res);
} catch {
// Keep last response on failure.
}
},
[scope]
);
// First load only — subsequent fetches are driven by the handlers below. // First load only — subsequent fetches are driven by the handlers below.
useEffect(() => { useEffect(() => {
@@ -278,10 +262,17 @@ const ResourceEvents: React.FC = () => {
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
columns={columns as any} columns={columns as any}
style={{ marginTop: 24 }} style={{ marginTop: 24 }}
loading={{
spinning: loading,
size: 'middle'
}}
pagination={{ pagination={{
size: 'middle',
current: queryParams.page, current: queryParams.page,
pageSize: data?.pagination.perPage ?? 50, pageSize: data?.pagination?.perPage ?? 50,
total: data?.pagination.total ?? 0, total: data?.pagination?.total ?? 0,
showSizeChanger: false,
hideOnSinglePage: queryParams.perPage === 50,
onChange: (p) => fetchEvents({ page: p }) onChange: (p) => fetchEvents({ page: p })
}} }}
/> />
@@ -0,0 +1,30 @@
import { useQueryData } from '@/hooks/use-query-data-list';
import {
queryResourceEvents,
ResourceEventsResponse
} from '../../apis/resource';
type ResourceEventsParams = Parameters<typeof queryResourceEvents>[0];
/**
* Wraps the `queryResourceEvents` request with shared loading state and
* in-flight cancellation (the previous request is cancelled on each new fetch
* and on unmount), so rapid filter/name-search/page changes can't race a stale
* response onto the table.
*/
export default function useQueryResourceEvents(option?: { key?: string }) {
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
ResourceEventsResponse,
ResourceEventsParams
>({
fetchDetail: queryResourceEvents,
key: option?.key || 'resourceEvents'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
+20 -17
View File
@@ -16,11 +16,11 @@
import { useAccess, useIntl } from '@umijs/max'; import { useAccess, useIntl } from '@umijs/max';
import { Tabs, TabsProps } from 'antd'; import { Tabs, TabsProps } from 'antd';
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import GpuInstancesTab from './components/gpu-instances-tab'; import ResourceEvents from './events-tab';
import ResourceEvents from './components/resource-events'; import GpuInstancesTab from './instances-tab';
import StorageTab from './components/storage-tab'; import StorageTab from './storage-tab';
import SummaryTab from './components/summary-tab'; import SummaryTab from './summary-tab';
import TokenTab from './components/token-tab'; import TokenTab from './token-tab';
const Usage: React.FC = () => { const Usage: React.FC = () => {
const access = useAccess(); const access = useAccess();
@@ -28,11 +28,15 @@ const Usage: React.FC = () => {
// Land on the cross-resource Summary by default. // Land on the cross-resource Summary by default.
const [activeKey, setActiveKey] = useState<string>('summary'); const [activeKey, setActiveKey] = useState<string>('summary');
const items: TabsProps['items'] = useMemo( const items: TabsProps['items'] = useMemo(() => {
() => [ type AccessTab = NonNullable<TabsProps['items']>[number] & {
access?: string;
};
const tabs: AccessTab[] = [
{ {
key: 'summary', key: 'summary',
label: intl.formatMessage({ id: 'usage.tabs.summary' }), label: intl.formatMessage({ id: 'usage.tabs.summary' }),
access: 'canSeeGpuService',
children: <SummaryTab /> children: <SummaryTab />
}, },
{ {
@@ -43,34 +47,33 @@ const Usage: React.FC = () => {
{ {
key: 'gpu-instances', key: 'gpu-instances',
label: intl.formatMessage({ id: 'usage.tabs.gpuInstances' }), label: intl.formatMessage({ id: 'usage.tabs.gpuInstances' }),
access: 'canSeeGpuService',
children: <GpuInstancesTab /> children: <GpuInstancesTab />
}, },
{ {
key: 'storage', key: 'storage',
label: intl.formatMessage({ id: 'usage.tabs.storage' }), label: intl.formatMessage({ id: 'usage.tabs.storage' }),
access: 'canSeeGpuService',
children: <StorageTab /> children: <StorageTab />
}, },
{ {
key: 'resource-events', key: 'resource-events',
label: intl.formatMessage({ id: 'usage.tabs.resourceEvents' }), label: intl.formatMessage({ id: 'usage.tabs.resourceEvents' }),
access: 'canSeeGpuService',
children: <ResourceEvents /> children: <ResourceEvents />
} }
], ];
[intl] return tabs.filter((item) => {
); if (!item.access) return true;
return (access as Record<string, boolean>)[item.access];
// Users who can't see GPU Service (MaaS-only: no cluster, no resource usage) });
// only have token usage — drop the tab shell and show Tokens directly. }, [intl, access]);
if (!access.canSeeGpuService) {
return <TokenTab />;
}
return ( return (
<Tabs <Tabs
activeKey={activeKey} activeKey={activeKey}
onChange={setActiveKey} onChange={setActiveKey}
items={items} items={items}
destroyOnHidden
// The content area adds 24px top padding; on table pages that space is // The content area adds 24px top padding; on table pages that space is
// filled immediately, but here it sits empty above the tab bar and reads // filled immediately, but here it sits empty above the tab bar and reads
// as too tall. Pull the tab bar up to ~flush with the header divider. // as too tall. Pull the tab bar up to ~flush with the header divider.
@@ -10,35 +10,34 @@
* Talks to the new ``/usage/gpu-instances/{meta,breakdown}`` endpoints. * Talks to the new ``/usage/gpu-instances/{meta,breakdown}`` endpoints.
*/ */
import useCoolColors from '@/hooks/use-cool-colors'; import useCoolColors from '@/hooks/use-cool-colors';
import {
buildInstanceTypeRecordFromMiB,
renderInstanceType
} from '@/pages/gpu-service/instances/utils/render-instance-type';
import { formatLargeNumber } from '@/utils'; import { formatLargeNumber } from '@/utils';
import { SimpleCard } from '@gpustack/core-ui'; import { SimpleCard } from '@gpustack/core-ui';
import { useAccess, useIntl } from '@umijs/max'; import { useAccess, useIntl } from '@umijs/max';
import { Table, Tabs } from 'antd'; import { Tabs } from 'antd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { import {
queryGpuInstancesBreakdown, queryGpuInstancesBreakdown,
ResourceBreakdownItem, ResourceBreakdownRequest
ResourceBreakdownRequest,
ResourceBreakdownResponse
} from '../apis/resource'; } from '../apis/resource';
import MetricChartCard from '../components/metric-chart-card';
import MetricLabel from '../components/metric-label';
import ResourceExportData from '../components/resource-export-data';
import ResourceFilterBar from '../components/resource-filter-bar';
import useResourceMeta from '../hooks/use-resource-meta'; import useResourceMeta from '../hooks/use-resource-meta';
import { instanceTypeLabel } from '../utils/format-instance-type'; import {
exportBreakdownSheets,
toExportColumns
} from '../utils/export-breakdown';
import { import {
bucketKey, bucketKey,
generateBucketRange, generateBucketRange,
Granularity, Granularity
parseRollup
} from '../utils/time-buckets'; } from '../utils/time-buckets';
import { buildTrendSeries } from '../utils/trend-series'; import { buildTrendSeries } from '../utils/trend-series';
import MetricChartCard from './metric-chart-card'; import useQueryGpuInstancesBreakdown from './services/use-query-gpu-instances-breakdown';
import MetricLabel from './metric-label'; import InstancesBreakdownTable from './tables/instances-breakdown-table';
import ResourceExportData from './resource-export-data'; import useInstancesColumns from './tables/use-instances-columns';
import ResourceFilterBar from './resource-filter-bar';
type Scope = 'self' | 'all'; type Scope = 'self' | 'all';
type Metric = 'gpu_hours' | 'instance_hours'; type Metric = 'gpu_hours' | 'instance_hours';
@@ -62,20 +61,26 @@ const GpuInstancesTab: React.FC = () => {
[intl] [intl]
); );
const TABLE_TABS: { key: GroupKey; label: string }[] = useMemo( const TABLE_TABS: { key: GroupKey; label: string }[] = useMemo(() => {
() => [ const tabs = [
{ {
key: 'gpu_type', key: 'gpu_type' as GroupKey,
label: intl.formatMessage({ id: 'usage.table.instanceTypes' }) label: intl.formatMessage({ id: 'usage.table.instanceTypes' })
}, },
{ {
key: 'instance', key: 'instance' as GroupKey,
label: intl.formatMessage({ id: 'usage.table.instances' }) label: intl.formatMessage({ id: 'usage.table.instances' })
}, }
{ key: 'user', label: intl.formatMessage({ id: 'usage.table.users' }) } ];
], // Managers see the org-wide User breakdown; members only their own rows.
[intl] if (access.canSeeOrgAdmin) {
); tabs.push({
key: 'user' as GroupKey,
label: intl.formatMessage({ id: 'usage.table.users' })
});
}
return tabs;
}, [intl, access.canSeeOrgAdmin]);
// ``useCoolColors`` returns a memoized factory; resolve a fixed 5-slot // ``useCoolColors`` returns a memoized factory; resolve a fixed 5-slot
// palette for the KPI cards, and keep the factory for the grouped trend // palette for the KPI cards, and keep the factory for the grouped trend
// (sized to the group count). // (sized to the group count).
@@ -105,21 +110,16 @@ const GpuInstancesTab: React.FC = () => {
const { creators: userOptions, instances: instanceOptions } = const { creators: userOptions, instances: instanceOptions } =
useResourceMeta(scope); useResourceMeta(scope);
// Two independent fetches: one for the daily chart (group_by=date), // The daily chart fetches group_by=date here; each bottom table owns its own
// one for the table (group_by=tab key). Both reuse the same date / // fetch (group_by=tab key) inside InstancesBreakdownTable. Bumped on any
// scope filters so the views stay in sync. // filter change to snap every mounted table back to page 1.
const [chartData, setChartData] = useState<ResourceBreakdownResponse | null>( const [pageResetKey, setPageResetKey] = useState(0);
null
); const {
const [tableData, setTableData] = useState<ResourceBreakdownResponse | null>( detailData: chartData,
null loading: chartLoading,
); fetchData: fetchChartData
const [tablePage, setTablePage] = useState(1); } = useQueryGpuInstancesBreakdown({ key: 'gpuInstancesBreakdownChart' });
// Server-side sort for the bottom tables; default GPU Hours, descending.
const [tableSort, setTableSort] = useState<{
field: Metric;
order: 'ascend' | 'descend';
}>({ field: 'gpu_hours', order: 'descend' });
const baseRequest = (): Omit<ResourceBreakdownRequest, 'group_by'> => ({ const baseRequest = (): Omit<ResourceBreakdownRequest, 'group_by'> => ({
start_date: dateRange[0].format('YYYY-MM-DD'), start_date: dateRange[0].format('YYYY-MM-DD'),
@@ -139,39 +139,17 @@ const GpuInstancesTab: React.FC = () => {
perPage: 50 perPage: 50
}); });
const fetchChart = async () => { const fetchChart = () =>
try { fetchChartData({
const data = await queryGpuInstancesBreakdown({ ...baseRequest(),
...baseRequest(), // Split each bucket by the chosen dimension when grouping.
// Split each bucket by the chosen dimension when grouping. group_by: chartGroupBy ? ['date', chartGroupBy] : ['date'],
group_by: chartGroupBy ? ['date', chartGroupBy] : ['date'], // A trend is a time series, not a paginated table: always fetch the
// A trend is a time series, not a paginated table: always fetch the // whole range. The default order is metric-desc, so partial (current/
// whole range. The default order is metric-desc, so partial (current/ // recent) buckets have smaller values and would be pushed onto later
// recent) buckets have smaller values and would be pushed onto later // pages — dropping the newest hours from the chart under a small page.
// pages — dropping the newest hours from the chart under a small page. perPage: 10000
perPage: 10000 });
});
setChartData(data);
} catch {
// Network/auth errors surface via the global request interceptor;
// keep the previous chart so the UI doesn't flash empty.
}
};
const fetchTable = async () => {
try {
const data = await queryGpuInstancesBreakdown({
...baseRequest(),
group_by: [activeTableTab],
page: tablePage,
order_by: tableSort.field,
descending: tableSort.order === 'descend'
});
setTableData(data);
} catch {
// Same rationale as fetchChart.
}
};
useEffect(() => { useEffect(() => {
fetchChart(); fetchChart();
@@ -184,18 +162,6 @@ const GpuInstancesTab: React.FC = () => {
refreshKey refreshKey
]); ]);
useEffect(() => {
fetchTable();
}, [
dateRange,
selectedUsers,
selectedInstances,
activeTableTab,
tablePage,
tableSort,
refreshKey
]);
// KPI summary cards — pull from the chart summary since both queries // KPI summary cards — pull from the chart summary since both queries
// return the same scope-wide totals. // return the same scope-wide totals.
const summary = chartData?.summary; const summary = chartData?.summary;
@@ -273,125 +239,77 @@ const GpuInstancesTab: React.FC = () => {
); );
// Group-by options for the trend = the same dimensions as the bottom tables // Group-by options for the trend = the same dimensions as the bottom tables
// (Users only when org-wide, matching the table tabs). // (TABLE_TABS already gates Users to org admins).
const chartGroupByOptions = useMemo( const chartGroupByOptions = useMemo(
() => () =>
TABLE_TABS.filter((t) => t.key !== 'user' || scope === 'all').map( TABLE_TABS.map((t) => ({
(t) => ({ value: t.key,
value: t.key, label: t.label
label: t.label })),
}) [TABLE_TABS]
),
[TABLE_TABS, scope]
); );
// Table columns adapt to the active tab. // Columns for each bottom-table grouping — same factory the tables render
const tableColumns = useMemo(() => { // with — used to build the export sheets below.
const baseValueCols = [ const gpuTypeColumns = useInstancesColumns('gpu_type');
const instanceColumns = useInstancesColumns('instance');
const userColumns = useInstancesColumns('user');
// "Export Table Data" writes every bottom table at once — one sheet per
// grouping (Instance Types / Instances / Users) — straight to a workbook,
// no preview dialog (mirrors the Tokens tab's `useExportTable`). The User
// sheet is included only when the org-wide view is available.
const tableExportGroups = useMemo(() => {
const groups = [
{ {
title: intl.formatMessage({ id: 'usage.metric.gpuHours' }), key: 'gpu_type' as GroupKey,
dataIndex: 'gpu_hours', columns: gpuTypeColumns,
key: 'gpu_hours', sheetName: intl.formatMessage({ id: 'usage.table.instanceTypes' })
sorter: true,
sortOrder: tableSort.field === 'gpu_hours' ? tableSort.order : null,
render: (v: number) => (v ?? 0).toFixed(2)
}, },
{ {
title: intl.formatMessage({ id: 'usage.metric.instanceHours' }), key: 'instance' as GroupKey,
dataIndex: 'instance_hours', columns: instanceColumns,
key: 'instance_hours', sheetName: intl.formatMessage({ id: 'usage.table.instances' })
sorter: true,
sortOrder:
tableSort.field === 'instance_hours' ? tableSort.order : null,
render: (v: number) => (v ?? 0).toFixed(2)
} }
]; ];
// Instance Types breakdown: just the pretty product name (or flavor slug if (canManageUsers) {
// for older rows) — no spec sub-line. groups.push({
const instanceTypeColType = { key: 'user' as GroupKey,
title: intl.formatMessage({ id: 'usage.table.instanceType' }), columns: userColumns,
dataIndex: 'gpu_type', sheetName: intl.formatMessage({ id: 'usage.table.users' })
key: 'gpu_type', });
render: (_v: string, row: ResourceBreakdownItem) => instanceTypeLabel(row)
};
// Instances breakdown: render through the canonical GPU Instances list
// renderer so the label + spec popover are identical. The breakdown row
// carries flat MiB fields, so adapt it into the ListItem shape first.
const instanceTypeColInstance = {
title: intl.formatMessage({ id: 'usage.table.instanceType' }),
dataIndex: 'gpu_type',
key: 'gpu_type',
render: (_v: string, row: ResourceBreakdownItem) =>
renderInstanceType(
buildInstanceTypeRecordFromMiB({
name: row.instance_name,
product: row.product || row.gpu_type,
gpuCount: row.gpu_count,
unitCpuMilli: row.unit_cpu_milli,
unitMemoryMib: row.unit_memory_mib,
vramMib: row.vram_mib,
localStorageMib: row.local_storage_mib,
ephemeralMib: row.ephemeral_mib,
persistentMib: row.persistent_mib
}),
{ intl }
)
};
// Last Active = the last active day. The backend sends a rollup-tz instant
// with its offset; parseRollup keeps that wall clock (no browser-tz convert),
// consistent with the trend chart buckets. Shown date-only.
const lastActiveCol = {
title: intl.formatMessage({ id: 'usage.table.lastActive' }),
dataIndex: 'last_active',
key: 'last_active',
render: (v?: string) => (v ? parseRollup(v).format('YYYY-MM-DD') : '-')
};
if (activeTableTab === 'gpu_type') {
return [
instanceTypeColType,
...baseValueCols,
{
title: intl.formatMessage({ id: 'usage.metric.activeInstances' }),
dataIndex: 'active_instances',
key: 'active_instances'
},
lastActiveCol
];
} }
if (activeTableTab === 'instance') { return groups;
return [ }, [gpuTypeColumns, instanceColumns, userColumns, canManageUsers, intl]);
{
title: intl.formatMessage({ id: 'usage.table.instance' }),
dataIndex: 'instance_name',
key: 'instance_name'
},
instanceTypeColInstance,
...baseValueCols,
lastActiveCol
];
}
// user tab
return [
{
title: intl.formatMessage({ id: 'usage.table.user' }),
dataIndex: 'user_name',
key: 'user_name'
},
...baseValueCols,
lastActiveCol
];
}, [activeTableTab, tableSort, intl]);
const tableRows: ResourceBreakdownItem[] = tableData?.items ?? []; // Chart export still opens the preview modal (matches the Tokens tab); the
// table export is direct, so this only ever holds 'chart'.
// Export opens a preview modal (matches the Tokens tab): re-filter + preview const [exportMode, setExportMode] = useState<'chart' | null>(null);
// the rows, then download. "Chart" = the by-date trend, "Table" = the active
// bottom-table grouping.
const [exportMode, setExportMode] = useState<'chart' | 'table' | null>(null);
const dateSuffix = `${dateRange[0].format('YYYY-MM-DD')}_${dateRange[1].format( const dateSuffix = `${dateRange[0].format('YYYY-MM-DD')}_${dateRange[1].format(
'YYYY-MM-DD' 'YYYY-MM-DD'
)}`; )}`;
const handleExportTable = async () => {
const results = await Promise.all(
tableExportGroups.map((g) =>
queryGpuInstancesBreakdown({
...baseRequest(),
group_by: [g.key],
// A breakdown export is the full filtered set, not a page.
perPage: 10000
})
)
);
exportBreakdownSheets(
tableExportGroups.map((g, i) => ({
rows: results[i]?.items ?? [],
columns: toExportColumns(g.columns),
sheetName: g.sheetName
})),
`gpu-instances_tables_${dateSuffix}.xlsx`
);
};
const chartExportColumns = [ const chartExportColumns = [
{ {
title: intl.formatMessage({ id: 'usage.table.date' }), title: intl.formatMessage({ id: 'usage.table.date' }),
@@ -422,21 +340,13 @@ const GpuInstancesTab: React.FC = () => {
} }
]; ];
const tabLabel = TABLE_TABS.find((t) => t.key === activeTableTab)?.label; // The preview modal now only backs the by-date chart export.
const exportConfig = const exportConfig = {
exportMode === 'chart' groupBy: ['date'],
? { columns: chartExportColumns,
groupBy: ['date'], fileName: `gpu-instances_chart_${dateSuffix}.xlsx`,
columns: chartExportColumns, sheetName: intl.formatMessage({ id: 'usage.tabs.gpuInstances' })
fileName: `gpu-instances_chart_${dateSuffix}.xlsx`, };
sheetName: intl.formatMessage({ id: 'usage.tabs.gpuInstances' })
}
: {
groupBy: [activeTableTab],
columns: tableColumns,
fileName: `gpu-instances_${activeTableTab}_${dateSuffix}.xlsx`,
sheetName: tabLabel || 'gpu-instances'
};
return ( return (
<div> <div>
@@ -445,27 +355,27 @@ const GpuInstancesTab: React.FC = () => {
value={dateRange} value={dateRange}
onChange={(dates) => { onChange={(dates) => {
setDateRange(dates); setDateRange(dates);
setTablePage(1); setPageResetKey((k) => k + 1);
}} }}
canManageUsers={canManageUsers} canManageUsers={canManageUsers}
userOptions={userOptions} userOptions={userOptions}
selectedUsers={selectedUsers} selectedUsers={selectedUsers}
onUsersChange={(ids) => { onUsersChange={(ids) => {
setSelectedUsers(ids); setSelectedUsers(ids);
setTablePage(1); setPageResetKey((k) => k + 1);
}} }}
resourceFilter={{ resourceFilter={{
options: instanceOptions, options: instanceOptions,
value: selectedInstances, value: selectedInstances,
onChange: (ids) => { onChange: (ids) => {
setSelectedInstances(ids); setSelectedInstances(ids);
setTablePage(1); setPageResetKey((k) => k + 1);
}, },
placeholder: intl.formatMessage({ id: 'usage.filter.instance' }) placeholder: intl.formatMessage({ id: 'usage.filter.instance' })
}} }}
onRefresh={() => setRefreshKey((k) => k + 1)} onRefresh={() => setRefreshKey((k) => k + 1)}
onExportChart={() => setExportMode('chart')} onExportChart={() => setExportMode('chart')}
onExportTable={() => setExportMode('table')} onExportTable={handleExportTable}
/> />
{/* KPI cards */} {/* KPI cards */}
@@ -484,7 +394,7 @@ const GpuInstancesTab: React.FC = () => {
</div> </div>
{/* Daily trend chart */} {/* Daily trend chart */}
<div style={{ marginBottom: 24 }}> <div style={{ marginBottom: 16 }}>
<MetricChartCard <MetricChartCard
metric={metric} metric={metric}
metricOptions={METRIC_OPTIONS} metricOptions={METRIC_OPTIONS}
@@ -496,54 +406,30 @@ const GpuInstancesTab: React.FC = () => {
groupBy={chartGroupBy} groupBy={chartGroupBy}
groupByOptions={chartGroupByOptions} groupByOptions={chartGroupByOptions}
onGroupByChange={(v) => setChartGroupBy(v as GroupKey | null)} onGroupByChange={(v) => setChartGroupBy(v as GroupKey | null)}
loading={chartLoading}
/> />
</div> </div>
{/* Bottom tabs + table */} {/* Bottom tabs + table */}
<Tabs <Tabs
activeKey={activeTableTab} activeKey={activeTableTab}
onChange={(k) => { onChange={(k) => setActiveTableTab(k as GroupKey)}
setActiveTableTab(k as GroupKey); items={TABLE_TABS.map((t) => ({
setTablePage(1);
}}
items={TABLE_TABS.filter(
(t) => t.key !== 'user' || scope === 'all'
).map((t) => ({
key: t.key, key: t.key,
label: t.label, 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: ( children: (
<Table <InstancesBreakdownTable
rowKey={(row) =>
`${row.gpu_type ?? ''}|${row.instance_id ?? ''}|${row.user_id ?? ''}`
}
key={t.key} key={t.key}
dataSource={tableRows} groupKey={t.key}
columns={tableColumns as any} dateRange={dateRange}
onChange={(_pagination, _filters, sorter: any) => { scope={scope}
const s = Array.isArray(sorter) ? sorter[0] : sorter; selectedUsers={selectedUsers}
// Sort changed: reset to page 1. Cleared (3rd click) → default selectedInstances={selectedInstances}
// back to GPU Hours descending. pageResetKey={pageResetKey}
const next = s?.order refreshKey={refreshKey}
? {
field: (s.columnKey as Metric) ?? 'gpu_hours',
order: s.order as 'ascend' | 'descend'
}
: { field: 'gpu_hours' as Metric, order: 'descend' as const };
if (
next.field !== tableSort.field ||
next.order !== tableSort.order
) {
setTableSort(next);
setTablePage(1);
}
}}
pagination={{
size: 'middle',
current: tablePage,
pageSize: tableData?.pagination.perPage ?? 50,
total: tableData?.pagination.total ?? 0,
onChange: (p) => setTablePage(p)
}}
/> />
) )
}))} }))}
@@ -552,14 +438,7 @@ const GpuInstancesTab: React.FC = () => {
<ResourceExportData <ResourceExportData
open={exportMode !== null} open={exportMode !== null}
onCancel={() => setExportMode(null)} onCancel={() => setExportMode(null)}
title={ title={intl.formatMessage({ id: 'usage.export.chart' })}
exportMode === 'chart'
? intl.formatMessage({ id: 'usage.export.chart' })
: intl.formatMessage(
{ id: 'usage.export.tableNamed' },
{ name: tabLabel }
)
}
queryFn={queryGpuInstancesBreakdown} queryFn={queryGpuInstancesBreakdown}
groupBy={exportConfig.groupBy} groupBy={exportConfig.groupBy}
columns={exportConfig.columns} columns={exportConfig.columns}
@@ -0,0 +1,31 @@
import { useQueryData } from '@/hooks/use-query-data-list';
import {
queryGpuInstancesBreakdown,
ResourceBreakdownRequest,
ResourceBreakdownResponse
} from '../../apis/resource';
/**
* Wraps the `queryGpuInstancesBreakdown` request with shared loading state and
* in-flight cancellation (the previous request is cancelled on each new fetch
* and on unmount), so rapid filter/page/sort changes can't race a stale
* response onto the chart or a table.
*/
export default function useQueryGpuInstancesBreakdown(option?: {
key?: string;
}) {
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
ResourceBreakdownResponse,
ResourceBreakdownRequest
>({
fetchDetail: queryGpuInstancesBreakdown,
key: option?.key || 'gpuInstancesBreakdown'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
@@ -0,0 +1,156 @@
import { TABLE_SORT_DIRECTIONS } from '@/config/settings';
import { Table } from 'antd';
import dayjs from 'dayjs';
import React, { useEffect, useRef, useState } from 'react';
import { ResourceBreakdownItem } from '../../apis/resource';
import useQueryGpuInstancesBreakdown from '../services/use-query-gpu-instances-breakdown';
import useInstancesColumns from './use-instances-columns';
type Scope = 'self' | 'all';
type Metric = 'gpu_hours' | 'instance_hours';
type GroupKey = 'gpu_type' | 'instance' | 'user';
const SORTABLE: Metric[] = ['gpu_hours', 'instance_hours'];
const PER_PAGE = 50;
// sort_by encodes order as a string (`-` prefix = descending), so the fetch
// effect dedupes naturally on the primitive. The breakdown order_by takes the
// metric key directly. Default: GPU Hours descending.
const DEFAULT_SORT = '-gpu_hours';
interface Props {
groupKey: GroupKey;
dateRange: [dayjs.Dayjs, dayjs.Dayjs];
scope: Scope;
selectedUsers: number[];
selectedInstances: number[];
// Bumped by the parent when a filter changes, so each mounted table snaps
// back to page 1 independently.
pageResetKey?: number;
refreshKey?: number;
}
/**
* One GPU-instances breakdown table, owning its own page/sort/data. Each tab
* (GPU Type / Instance / User) mounts an instance and keeps it alive (Tabs
* `forceRender`), so each keeps its own pagination + sort and switching tabs
* neither refetches nor resets the others.
*/
const InstancesBreakdownTable: React.FC<Props> = ({
groupKey,
dateRange,
scope,
selectedUsers,
selectedInstances,
pageResetKey = 0,
refreshKey = 0
}) => {
const [queryParams, setQueryParams] = useState<{
page: number;
perPage: number;
sort_by: string;
}>({ page: 1, perPage: PER_PAGE, sort_by: DEFAULT_SORT });
const pendingPageResetRef = useRef(false);
const { detailData, loading, fetchData } = useQueryGpuInstancesBreakdown({
key: `gpuInstancesBreakdown-${groupKey}`
});
const columns = useInstancesColumns(groupKey);
// Sort only — paging goes through handlePageChange, so this stays
// page-agnostic and the two compose (matches the Tokens tab tables).
const handleTableChange = (_pagination: any, _filters: any, sorter: any) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
const field = s?.field as Metric;
// Cleared (3rd click) or an unsortable column → default GPU Hours desc.
const sort_by =
!s?.order || !SORTABLE.includes(field)
? DEFAULT_SORT
: s.order === 'ascend'
? field
: `-${field}`;
// Only a real sort change snaps back to page 1; an unchanged sort_by
// (e.g. the onChange that fires alongside a page click) is a no-op, so it
// doesn't fight handlePageChange.
setQueryParams((prev) =>
prev.sort_by === sort_by ? prev : { ...prev, sort_by, page: 1 }
);
};
const handlePageChange = (page: number, pageSize: number) => {
setQueryParams((prev) => ({ ...prev, page, perPage: pageSize }));
};
// Filters changed upstream → snap back to page 1. Defer the fetch until the
// page-1 render so we don't fire a stale page-N request first.
useEffect(() => {
if (queryParams.page !== 1) {
pendingPageResetRef.current = true;
setQueryParams((prev) => ({ ...prev, page: 1 }));
}
}, [pageResetKey]);
useEffect(() => {
if (pendingPageResetRef.current && queryParams.page !== 1) return;
pendingPageResetRef.current = false;
const descending = queryParams.sort_by.startsWith('-');
fetchData({
start_date: dateRange[0].format('YYYY-MM-DD'),
end_date: dateRange[1].format('YYYY-MM-DD'),
scope,
filters:
selectedUsers.length || selectedInstances.length
? {
...(selectedUsers.length ? { creator_ids: selectedUsers } : {}),
...(selectedInstances.length
? { instance_ids: selectedInstances }
: {})
}
: undefined,
group_by: [groupKey],
page: queryParams.page,
perPage: queryParams.perPage,
order_by: descending ? queryParams.sort_by.slice(1) : queryParams.sort_by,
descending
});
}, [
dateRange,
scope,
selectedUsers,
selectedInstances,
queryParams.page,
queryParams.perPage,
queryParams.sort_by,
refreshKey,
fetchData
]);
const rows: ResourceBreakdownItem[] = detailData?.items ?? [];
return (
<Table
rowKey={(row) =>
`${row.gpu_type ?? ''}|${row.instance_id ?? ''}|${row.user_id ?? ''}`
}
dataSource={rows}
columns={columns as any}
loading={{ spinning: loading, size: 'middle' }}
sortDirections={TABLE_SORT_DIRECTIONS}
showSorterTooltip={false}
onChange={handleTableChange}
pagination={{
size: 'middle',
current: queryParams.page,
pageSize: detailData?.pagination?.perPage ?? queryParams.perPage,
total: detailData?.pagination?.total ?? 0,
showSizeChanger: true,
hideOnSinglePage: queryParams.perPage === PER_PAGE,
onChange: handlePageChange
}}
/>
);
};
export default InstancesBreakdownTable;
@@ -0,0 +1,134 @@
import {
buildInstanceTypeRecordFromMiB,
renderInstanceType
} from '@/pages/gpu-service/instances/utils/render-instance-type';
import { useIntl } from '@umijs/max';
import { useMemo } from 'react';
import { ResourceBreakdownItem } from '../../apis/resource';
import { instanceTypeLabel } from '../../utils/format-instance-type';
import { parseRollup } from '../../utils/time-buckets';
type GroupKey = 'gpu_type' | 'instance' | 'user';
/**
* Column factory for the GPU-instances breakdown tables, shared by the in-tab
* table and the export preview. Columns adapt to the active grouping. Sort
* indicators are uncontrolled (antd manages the header arrows); the table
* reports changes through its `onChange`.
*/
const useInstancesColumns = (groupKey: GroupKey) => {
const intl = useIntl();
return useMemo(() => {
const baseValueCols = [
{
title: intl.formatMessage({ id: 'usage.metric.gpuHours' }),
dataIndex: 'gpu_hours',
key: 'gpu_hours',
sorter: true,
render: (v: number) => (v ?? 0).toFixed(2)
},
{
title: intl.formatMessage({ id: 'usage.metric.instanceHours' }),
dataIndex: 'instance_hours',
key: 'instance_hours',
sorter: true,
render: (v: number) => (v ?? 0).toFixed(2)
}
];
// Instance Types breakdown: the pretty product name (or flavor slug for
// older rows; "CPU Only" when no GPU cards) plus a CPU + RAM spec popover —
// rendered through the canonical renderer so the formatting matches the GPU
// Instances list, but limited to the CPU/RAM categories.
const instanceTypeColType = {
title: intl.formatMessage({ id: 'usage.table.instanceType' }),
dataIndex: 'gpu_type',
key: 'gpu_type',
render: (_v: string, row: ResourceBreakdownItem) =>
renderInstanceType(
buildInstanceTypeRecordFromMiB({
name: row.instance_name,
product: row.product || row.gpu_type,
gpuCount: row.gpu_count,
unitCpuMilli: row.unit_cpu_milli,
unitMemoryMib: row.unit_memory_mib,
vramMib: row.vram_mib
}),
{
intl,
categories: ['cpu', 'ram'],
title:
(row.gpu_count ?? 0) > 0 ? instanceTypeLabel(row) : 'CPU Only'
}
)
};
// Instances breakdown: render through the canonical GPU Instances list
// renderer so the label + spec popover are identical. The breakdown row
// carries flat MiB fields, so adapt it into the ListItem shape first.
const instanceTypeColInstance = {
title: intl.formatMessage({ id: 'usage.table.instanceType' }),
dataIndex: 'gpu_type',
key: 'gpu_type',
render: (_v: string, row: ResourceBreakdownItem) =>
renderInstanceType(
buildInstanceTypeRecordFromMiB({
name: row.instance_name,
product: row.product || row.gpu_type,
gpuCount: row.gpu_count,
unitCpuMilli: row.unit_cpu_milli,
unitMemoryMib: row.unit_memory_mib,
vramMib: row.vram_mib,
localStorageMib: row.local_storage_mib,
ephemeralMib: row.ephemeral_mib,
persistentMib: row.persistent_mib
}),
{ intl }
)
};
// Last Active = the last active day. The backend sends a rollup-tz instant
// with its offset; parseRollup keeps that wall clock (no browser-tz convert),
// consistent with the trend chart buckets. Shown date-only.
const lastActiveCol = {
title: intl.formatMessage({ id: 'usage.table.lastActive' }),
dataIndex: 'last_active',
key: 'last_active',
render: (v?: string) => (v ? parseRollup(v).format('YYYY-MM-DD') : '-')
};
if (groupKey === 'gpu_type') {
return [
instanceTypeColType,
...baseValueCols,
{
title: intl.formatMessage({ id: 'usage.metric.activeInstances' }),
dataIndex: 'active_instances',
key: 'active_instances'
},
lastActiveCol
];
}
if (groupKey === 'instance') {
return [
{
title: intl.formatMessage({ id: 'usage.table.instance' }),
dataIndex: 'instance_name',
key: 'instance_name'
},
instanceTypeColInstance,
...baseValueCols,
lastActiveCol
];
}
// user tab
return [
{
title: intl.formatMessage({ id: 'usage.table.user' }),
dataIndex: 'user_name',
key: 'user_name'
},
...baseValueCols,
lastActiveCol
];
}, [groupKey, intl]);
};
export default useInstancesColumns;
@@ -15,27 +15,32 @@ import useCoolColors from '@/hooks/use-cool-colors';
import { formatLargeNumber } from '@/utils'; import { formatLargeNumber } from '@/utils';
import { SimpleCard } from '@gpustack/core-ui'; import { SimpleCard } from '@gpustack/core-ui';
import { useAccess, useIntl } from '@umijs/max'; import { useAccess, useIntl } from '@umijs/max';
import { Table, Tabs } from 'antd'; import { useMemoizedFn } from 'ahooks';
import { Tabs } from 'antd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { import {
queryStorageBreakdown, queryStorageBreakdown,
ResourceBreakdownItem, ResourceBreakdownRequest
ResourceBreakdownRequest,
ResourceBreakdownResponse
} from '../apis/resource'; } from '../apis/resource';
import MetricChartCard from '../components/metric-chart-card';
import MetricLabel from '../components/metric-label';
import ResourceExportData from '../components/resource-export-data';
import ResourceFilterBar from '../components/resource-filter-bar';
import useResourceMeta from '../hooks/use-resource-meta'; import useResourceMeta from '../hooks/use-resource-meta';
import {
exportBreakdownSheets,
toExportColumns
} from '../utils/export-breakdown';
import { import {
bucketKey, bucketKey,
generateBucketRange, generateBucketRange,
Granularity, Granularity
parseRollup
} from '../utils/time-buckets'; } from '../utils/time-buckets';
import { buildTrendSeries } from '../utils/trend-series'; import { buildTrendSeries } from '../utils/trend-series';
import MetricChartCard from './metric-chart-card'; import useQueryStorageBreakdown from './services/use-query-storage-breakdown';
import MetricLabel from './metric-label'; import StorageBreakdownTable from './tables/storage-breakdown-table';
import ResourceExportData from './resource-export-data'; import useStorageColumns from './tables/use-storage-columns';
import ResourceFilterBar from './resource-filter-bar';
type Scope = 'self' | 'all'; type Scope = 'self' | 'all';
type Metric = 'storage_gb_days' | 'storage_gb_hours'; type Metric = 'storage_gb_days' | 'storage_gb_hours';
@@ -63,16 +68,21 @@ const StorageTab: React.FC = () => {
[intl] [intl]
); );
const TABLE_TABS: { key: GroupKey; label: string }[] = useMemo( const TABLE_TABS: { key: GroupKey; label: string }[] = useMemo(() => {
() => [ const tabs = [
{ {
key: 'volume', key: 'volume' as GroupKey,
label: intl.formatMessage({ id: 'usage.tabs.storage' }) label: intl.formatMessage({ id: 'usage.tabs.storage' })
}, }
{ key: 'user', label: intl.formatMessage({ id: 'usage.table.users' }) } ];
], if (access.canSeeOrgAdmin) {
[intl] tabs.push({
); key: 'user' as GroupKey,
label: intl.formatMessage({ id: 'usage.table.users' })
});
}
return tabs;
}, [intl, access.canSeeOrgAdmin]);
// No All/My dropdown (matches the Tokens tab): managers see the org-wide // No All/My dropdown (matches the Tokens tab): managers see the org-wide
// view and narrow it with the user filter, others only their own rows. // view and narrow it with the user filter, others only their own rows.
@@ -95,18 +105,15 @@ const StorageTab: React.FC = () => {
const { creators: userOptions, volumes: volumeOptions } = const { creators: userOptions, volumes: volumeOptions } =
useResourceMeta(scope); useResourceMeta(scope);
const [chartData, setChartData] = useState<ResourceBreakdownResponse | null>( // Bumped on any filter change to snap every mounted table back to page 1;
null // each table owns its own page/sort state otherwise.
); const [pageResetKey, setPageResetKey] = useState(0);
const [tableData, setTableData] = useState<ResourceBreakdownResponse | null>(
null const {
); detailData: chartData,
const [tablePage, setTablePage] = useState(1); loading: chartLoading,
// Server-side sort for the bottom tables; default GB-Days, descending. fetchData: fetchChartData
const [tableSort, setTableSort] = useState<{ } = useQueryStorageBreakdown({ key: 'storageBreakdownChart' });
field: Metric;
order: 'ascend' | 'descend';
}>({ field: 'storage_gb_days', order: 'descend' });
const baseRequest = (): Omit<ResourceBreakdownRequest, 'group_by'> => ({ const baseRequest = (): Omit<ResourceBreakdownRequest, 'group_by'> => ({
start_date: dateRange[0].format('YYYY-MM-DD'), start_date: dateRange[0].format('YYYY-MM-DD'),
@@ -124,45 +131,18 @@ const StorageTab: React.FC = () => {
perPage: 50 perPage: 50
}); });
const fetchChart = async () => { const fetchChart = useMemoizedFn(() =>
try { fetchChartData({
const data = await queryStorageBreakdown({ ...baseRequest(),
...baseRequest(), // Split each bucket by the chosen dimension when grouping.
// Split each bucket by the chosen dimension when grouping. group_by: chartGroupBy ? ['date', chartGroupBy] : ['date'],
group_by: chartGroupBy ? ['date', chartGroupBy] : ['date'], // A trend is a time series, not a paginated table: always fetch the
// A trend is a time series, not a paginated table: always fetch the // whole range. The default order is metric-desc, so partial (current/
// whole range. The default order is metric-desc, so partial (current/ // recent) buckets have smaller values and would be pushed onto later
// recent) buckets have smaller values and would be pushed onto later // pages — dropping the newest hours from the chart under a small page.
// pages — dropping the newest hours from the chart under a small page. perPage: 10000
perPage: 10000 })
}); );
setChartData(data);
} catch {
// Surfacing handled by global interceptor; keep last data.
}
};
// The frontend metric keys (storage_gb_days/hours) map to the server's
// breakdown metric keys (gb_days/gb_hours) for order_by.
const ORDER_BY_KEY: Record<Metric, string> = {
storage_gb_days: 'gb_days',
storage_gb_hours: 'gb_hours'
};
const fetchTable = async () => {
try {
const data = await queryStorageBreakdown({
...baseRequest(),
group_by: [activeTableTab],
page: tablePage,
order_by: ORDER_BY_KEY[tableSort.field],
descending: tableSort.order === 'descend'
});
setTableData(data);
} catch {
// Same rationale.
}
};
useEffect(() => { useEffect(() => {
fetchChart(); fetchChart();
@@ -175,18 +155,6 @@ const StorageTab: React.FC = () => {
refreshKey refreshKey
]); ]);
useEffect(() => {
fetchTable();
}, [
dateRange,
selectedUsers,
selectedVolumes,
activeTableTab,
tablePage,
tableSort,
refreshKey
]);
const summary = chartData?.summary; const summary = chartData?.summary;
const summaryCards = useMemo( const summaryCards = useMemo(
() => [ () => [
@@ -267,95 +235,68 @@ const StorageTab: React.FC = () => {
const chartGroupByOptions = useMemo( const chartGroupByOptions = useMemo(
() => () =>
TABLE_TABS.filter((t) => t.key !== 'user' || scope === 'all').map( TABLE_TABS.map((t) => ({
(t) => ({ value: t.key,
value: t.key, label: t.label
label: t.label })),
})
),
[TABLE_TABS, scope] [TABLE_TABS, scope]
); );
const tableColumns = useMemo(() => { // Columns for each bottom-table grouping — same factory the tables render
const valueCols = [ // with — used to build the export sheets below.
const volumeColumns = useStorageColumns('volume');
const userColumns = useStorageColumns('user');
// "Export Table Data" writes every bottom table at once — one sheet per
// grouping (Storage / Users) — straight to a workbook, no preview dialog
// (mirrors the Tokens tab's `useExportTable`). The User sheet is included
// only when the org-wide view is available.
const tableExportGroups = useMemo(() => {
const groups = [
{ {
title: intl.formatMessage({ id: 'usage.metric.gbDays' }), key: 'volume' as GroupKey,
dataIndex: 'storage_gb_days', columns: volumeColumns,
key: 'storage_gb_days', sheetName: intl.formatMessage({ id: 'usage.tabs.storage' })
sorter: true,
sortOrder:
tableSort.field === 'storage_gb_days' ? tableSort.order : null,
render: (v: number) => (v ?? 0).toFixed(2)
},
{
title: intl.formatMessage({ id: 'usage.metric.gbHours' }),
dataIndex: 'storage_gb_hours',
key: 'storage_gb_hours',
sorter: true,
sortOrder:
tableSort.field === 'storage_gb_hours' ? tableSort.order : null,
render: (v: number) => (v ?? 0).toFixed(2)
} }
]; ];
// Last Active = the last active day. The backend sends a rollup-tz instant if (canManageUsers) {
// with its offset; parseRollup keeps that wall clock (no browser-tz convert), groups.push({
// consistent with the trend chart buckets. Shown date-only. key: 'user' as GroupKey,
const lastActiveCol = { columns: userColumns,
title: intl.formatMessage({ id: 'usage.table.lastActive' }), sheetName: intl.formatMessage({ id: 'usage.table.users' })
dataIndex: 'last_active', });
key: 'last_active',
render: (v?: string) => (v ? parseRollup(v).format('YYYY-MM-DD') : '-')
};
if (activeTableTab === 'volume') {
return [
{
title: intl.formatMessage({ id: 'usage.tabs.storage' }),
dataIndex: 'volume_name',
key: 'volume_name'
},
{
title: intl.formatMessage({ id: 'usage.table.type' }),
dataIndex: 'storage_type',
key: 'storage_type',
render: (_v: string, row: ResourceBreakdownItem) =>
row.storage_type || row.gpu_type || '-'
},
{
title: intl.formatMessage({ id: 'usage.table.capacity' }),
dataIndex: 'capacity_mib',
key: 'capacity_mib',
render: (v?: number) => (v ? `${Math.round(v / 1024)}GB` : '-')
},
...valueCols,
lastActiveCol
];
} }
return [ return groups;
{ }, [volumeColumns, userColumns, canManageUsers, intl]);
title: intl.formatMessage({ id: 'usage.table.user' }),
dataIndex: 'user_name',
key: 'user_name'
},
...valueCols,
{
title: intl.formatMessage({ id: 'usage.metric.activeStorage' }),
dataIndex: 'active_volumes',
key: 'active_volumes'
},
lastActiveCol
];
}, [activeTableTab, tableSort, intl]);
const tableRows: ResourceBreakdownItem[] = tableData?.items ?? []; // Chart export still opens the preview modal (matches the Tokens tab); the
// table export is direct, so this only ever holds 'chart'.
// Export opens a preview modal (matches the Tokens tab): re-filter + preview const [exportMode, setExportMode] = useState<'chart' | null>(null);
// the rows, then download. "Chart" = the by-date trend, "Table" = the active
// bottom-table grouping.
const [exportMode, setExportMode] = useState<'chart' | 'table' | null>(null);
const dateSuffix = `${dateRange[0].format('YYYY-MM-DD')}_${dateRange[1].format( const dateSuffix = `${dateRange[0].format('YYYY-MM-DD')}_${dateRange[1].format(
'YYYY-MM-DD' 'YYYY-MM-DD'
)}`; )}`;
const handleExportTable = async () => {
const results = await Promise.all(
tableExportGroups.map((g) =>
queryStorageBreakdown({
...baseRequest(),
group_by: [g.key],
// A breakdown export is the full filtered set, not a page.
perPage: 10000
})
)
);
exportBreakdownSheets(
tableExportGroups.map((g, i) => ({
rows: results[i]?.items ?? [],
columns: toExportColumns(g.columns),
sheetName: g.sheetName
})),
`storage_tables_${dateSuffix}.xlsx`
);
};
const chartExportColumns = [ const chartExportColumns = [
{ {
title: intl.formatMessage({ id: 'usage.table.date' }), title: intl.formatMessage({ id: 'usage.table.date' }),
@@ -386,21 +327,13 @@ const StorageTab: React.FC = () => {
} }
]; ];
const tabLabel = TABLE_TABS.find((t) => t.key === activeTableTab)?.label; // The preview modal now only backs the by-date chart export.
const exportConfig = const exportConfig = {
exportMode === 'chart' groupBy: ['date'],
? { columns: chartExportColumns,
groupBy: ['date'], fileName: `storage_chart_${dateSuffix}.xlsx`,
columns: chartExportColumns, sheetName: intl.formatMessage({ id: 'usage.tabs.storage' })
fileName: `storage_chart_${dateSuffix}.xlsx`, };
sheetName: intl.formatMessage({ id: 'usage.tabs.storage' })
}
: {
groupBy: [activeTableTab],
columns: tableColumns,
fileName: `storage_${activeTableTab}_${dateSuffix}.xlsx`,
sheetName: tabLabel || 'storage'
};
return ( return (
<div> <div>
@@ -408,27 +341,27 @@ const StorageTab: React.FC = () => {
value={dateRange} value={dateRange}
onChange={(dates) => { onChange={(dates) => {
setDateRange(dates); setDateRange(dates);
setTablePage(1); setPageResetKey((k) => k + 1);
}} }}
canManageUsers={canManageUsers} canManageUsers={canManageUsers}
userOptions={userOptions} userOptions={userOptions}
selectedUsers={selectedUsers} selectedUsers={selectedUsers}
onUsersChange={(ids) => { onUsersChange={(ids) => {
setSelectedUsers(ids); setSelectedUsers(ids);
setTablePage(1); setPageResetKey((k) => k + 1);
}} }}
resourceFilter={{ resourceFilter={{
options: volumeOptions, options: volumeOptions,
value: selectedVolumes, value: selectedVolumes,
onChange: (ids) => { onChange: (ids) => {
setSelectedVolumes(ids); setSelectedVolumes(ids);
setTablePage(1); setPageResetKey((k) => k + 1);
}, },
placeholder: intl.formatMessage({ id: 'usage.filter.storage' }) placeholder: intl.formatMessage({ id: 'usage.filter.storage' })
}} }}
onRefresh={() => setRefreshKey((k) => k + 1)} onRefresh={() => setRefreshKey((k) => k + 1)}
onExportChart={() => setExportMode('chart')} onExportChart={() => setExportMode('chart')}
onExportTable={() => setExportMode('table')} onExportTable={handleExportTable}
/> />
<div style={{ height: 24 }} /> <div style={{ height: 24 }} />
@@ -457,54 +390,29 @@ const StorageTab: React.FC = () => {
groupBy={chartGroupBy} groupBy={chartGroupBy}
groupByOptions={chartGroupByOptions} groupByOptions={chartGroupByOptions}
onGroupByChange={(v) => setChartGroupBy(v as GroupKey | null)} onGroupByChange={(v) => setChartGroupBy(v as GroupKey | null)}
loading={chartLoading}
/> />
</div> </div>
<Tabs <Tabs
activeKey={activeTableTab} activeKey={activeTableTab}
onChange={(k) => { onChange={(k) => setActiveTableTab(k as GroupKey)}
setActiveTableTab(k as GroupKey); items={TABLE_TABS.map((t) => ({
setTablePage(1);
}}
items={TABLE_TABS.filter(
(t) => t.key !== 'user' || scope === 'all'
).map((t) => ({
key: t.key, key: t.key,
label: t.label, 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: ( children: (
<Table <StorageBreakdownTable
rowKey={(row) => key={t.key}
`${row.volume_id ?? ''}|${row.user_id ?? ''}|${row.volume_name ?? ''}` groupKey={t.key}
} dateRange={dateRange}
dataSource={tableRows} scope={scope}
columns={tableColumns as any} selectedUsers={selectedUsers}
onChange={(_pagination, _filters, sorter: any) => { selectedVolumes={selectedVolumes}
const s = Array.isArray(sorter) ? sorter[0] : sorter; pageResetKey={pageResetKey}
// Sort changed → page 1; cleared (3rd click) → default GB-Days refreshKey={refreshKey}
// descending.
const next = s?.order
? {
field: (s.columnKey as Metric) ?? 'storage_gb_days',
order: s.order as 'ascend' | 'descend'
}
: {
field: 'storage_gb_days' as Metric,
order: 'descend' as const
};
if (
next.field !== tableSort.field ||
next.order !== tableSort.order
) {
setTableSort(next);
setTablePage(1);
}
}}
pagination={{
current: tablePage,
pageSize: tableData?.pagination.perPage ?? 50,
total: tableData?.pagination.total ?? 0,
onChange: (p) => setTablePage(p)
}}
/> />
) )
}))} }))}
@@ -513,14 +421,7 @@ const StorageTab: React.FC = () => {
<ResourceExportData <ResourceExportData
open={exportMode !== null} open={exportMode !== null}
onCancel={() => setExportMode(null)} onCancel={() => setExportMode(null)}
title={ title={intl.formatMessage({ id: 'usage.export.chart' })}
exportMode === 'chart'
? intl.formatMessage({ id: 'usage.export.chart' })
: intl.formatMessage(
{ id: 'usage.export.tableNamed' },
{ name: tabLabel }
)
}
queryFn={queryStorageBreakdown} queryFn={queryStorageBreakdown}
groupBy={exportConfig.groupBy} groupBy={exportConfig.groupBy}
columns={exportConfig.columns} columns={exportConfig.columns}
@@ -0,0 +1,29 @@
import { useQueryData } from '@/hooks/use-query-data-list';
import {
queryStorageBreakdown,
ResourceBreakdownRequest,
ResourceBreakdownResponse
} from '../../apis/resource';
/**
* Wraps the `queryStorageBreakdown` request with shared loading state and
* in-flight cancellation (previous request is cancelled on each new fetch and
* on unmount), so rapid filter/page/sort changes can't race a stale response
* onto the table.
*/
export default function useQueryStorageBreakdown(option?: { key?: string }) {
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
ResourceBreakdownResponse,
ResourceBreakdownRequest
>({
fetchDetail: queryStorageBreakdown,
key: option?.key || 'storageBreakdownTable'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
@@ -0,0 +1,158 @@
import { TABLE_SORT_DIRECTIONS } from '@/config/settings';
import { Table } from 'antd';
import dayjs from 'dayjs';
import React, { useEffect, useRef, useState } from 'react';
import { ResourceBreakdownItem } from '../../apis/resource';
import useQueryStorageBreakdown from '../services/use-query-storage-breakdown';
import useStorageColumns from './use-storage-columns';
type Scope = 'self' | 'all';
type Metric = 'storage_gb_days' | 'storage_gb_hours';
type GroupKey = 'volume' | 'user';
// The frontend metric keys (storage_gb_days/hours) map to the server's
// breakdown metric keys (gb_days/gb_hours).
const ORDER_BY_KEY: Record<Metric, string> = {
storage_gb_days: 'gb_days',
storage_gb_hours: 'gb_hours'
};
const PER_PAGE = 50;
// sort_by encodes order as a string (`-` prefix = descending), so the fetch
// effect dedupes naturally on the primitive. Default: GB-Days descending.
const DEFAULT_SORT = '-gb_days';
interface Props {
groupKey: GroupKey;
dateRange: [dayjs.Dayjs, dayjs.Dayjs];
scope: Scope;
selectedUsers: number[];
selectedVolumes: number[];
// Bumped by the parent when a filter changes, so each mounted table snaps
// back to page 1 independently.
pageResetKey?: number;
refreshKey?: number;
}
/**
* One storage breakdown table, owning its own page/sort/data. Both tabs mount
* an instance (volume / user) and keep it alive (Tabs `forceRender`), so each
* keeps its own pagination + sort and switching tabs neither refetches nor
* resets the other.
*/
const StorageBreakdownTable: React.FC<Props> = ({
groupKey,
dateRange,
scope,
selectedUsers,
selectedVolumes,
pageResetKey = 0,
refreshKey = 0
}) => {
const [queryParams, setQueryParams] = useState<{
page: number;
perPage: number;
sort_by: string;
}>({ page: 1, perPage: PER_PAGE, sort_by: DEFAULT_SORT });
const pendingPageResetRef = useRef(false);
const { detailData, loading, fetchData } = useQueryStorageBreakdown({
key: `storageBreakdown-${groupKey}`
});
const columns = useStorageColumns(groupKey);
// Sort only — paging goes through handlePageChange, so this stays
// page-agnostic and the two compose (matches the Tokens tab tables).
const handleTableChange = (_pagination: any, _filters: any, sorter: any) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
const field = ORDER_BY_KEY[s?.field as Metric];
// Cleared (3rd click) or an unknown column → default GB-Days descending.
const sort_by =
!field || !s?.order
? DEFAULT_SORT
: s.order === 'ascend'
? field
: `-${field}`;
// Only a real sort change snaps back to page 1; an unchanged sort_by
// (e.g. the onChange that fires alongside a page click) is a no-op, so it
// doesn't fight handlePageChange.
setQueryParams((prev) =>
prev.sort_by === sort_by ? prev : { ...prev, sort_by, page: 1 }
);
};
const handlePageChange = (page: number, pageSize: number) => {
setQueryParams((prev) => ({ ...prev, page, perPage: pageSize }));
};
// Filters changed upstream → snap back to page 1. Defer the fetch until the
// page-1 render so we don't fire a stale page-N request first.
useEffect(() => {
if (queryParams.page !== 1) {
pendingPageResetRef.current = true;
setQueryParams((prev) => ({ ...prev, page: 1 }));
}
}, [pageResetKey]);
useEffect(() => {
if (pendingPageResetRef.current && queryParams.page !== 1) return;
pendingPageResetRef.current = false;
const descending = queryParams.sort_by.startsWith('-');
fetchData({
start_date: dateRange[0].format('YYYY-MM-DD'),
end_date: dateRange[1].format('YYYY-MM-DD'),
scope,
filters:
selectedUsers.length || selectedVolumes.length
? {
...(selectedUsers.length ? { creator_ids: selectedUsers } : {}),
...(selectedVolumes.length ? { volume_ids: selectedVolumes } : {})
}
: undefined,
group_by: [groupKey],
page: queryParams.page,
perPage: queryParams.perPage,
order_by: descending ? queryParams.sort_by.slice(1) : queryParams.sort_by,
descending
});
}, [
dateRange,
scope,
selectedUsers,
selectedVolumes,
queryParams.page,
queryParams.perPage,
queryParams.sort_by,
refreshKey,
fetchData
]);
const rows: ResourceBreakdownItem[] = detailData?.items ?? [];
return (
<Table
rowKey={(row) =>
`${row.volume_id ?? ''}|${row.user_id ?? ''}|${row.volume_name ?? ''}`
}
dataSource={rows}
columns={columns as any}
loading={{ spinning: loading, size: 'middle' }}
sortDirections={TABLE_SORT_DIRECTIONS}
showSorterTooltip={false}
onChange={handleTableChange}
pagination={{
size: 'middle',
current: queryParams.page,
pageSize: detailData?.pagination?.perPage ?? queryParams.perPage,
total: detailData?.pagination?.total ?? 0,
showSizeChanger: true,
hideOnSinglePage: queryParams.perPage === PER_PAGE,
onChange: handlePageChange
}}
/>
);
};
export default StorageBreakdownTable;
@@ -0,0 +1,83 @@
import { useIntl } from '@umijs/max';
import { useMemo } from 'react';
import { ResourceBreakdownItem } from '../../apis/resource';
import { parseRollup } from '../../utils/time-buckets';
type GroupKey = 'volume' | 'user';
/**
* Column factory for the storage breakdown tables, shared by the in-tab table
* and the export preview. Sort indicators are uncontrolled (antd manages the
* header arrows); the table reports changes through its `onChange`.
*/
const useStorageColumns = (groupKey: GroupKey) => {
const intl = useIntl();
return useMemo(() => {
const valueCols = [
{
title: intl.formatMessage({ id: 'usage.metric.gbDays' }),
dataIndex: 'storage_gb_days',
key: 'storage_gb_days',
sorter: true,
render: (v: number) => (v ?? 0).toFixed(2)
},
{
title: intl.formatMessage({ id: 'usage.metric.gbHours' }),
dataIndex: 'storage_gb_hours',
key: 'storage_gb_hours',
sorter: true,
render: (v: number) => (v ?? 0).toFixed(2)
}
];
// Last Active = the last active day. The backend sends a rollup-tz instant
// with its offset; parseRollup keeps that wall clock (no browser-tz convert),
// consistent with the trend chart buckets. Shown date-only.
const lastActiveCol = {
title: intl.formatMessage({ id: 'usage.table.lastActive' }),
dataIndex: 'last_active',
key: 'last_active',
render: (v?: string) => (v ? parseRollup(v).format('YYYY-MM-DD') : '-')
};
if (groupKey === 'volume') {
return [
{
title: intl.formatMessage({ id: 'usage.tabs.storage' }),
dataIndex: 'volume_name',
key: 'volume_name'
},
{
title: intl.formatMessage({ id: 'usage.table.type' }),
dataIndex: 'storage_type',
key: 'storage_type',
render: (_v: string, row: ResourceBreakdownItem) =>
row.storage_type || row.gpu_type || '-'
},
{
title: intl.formatMessage({ id: 'usage.table.capacity' }),
dataIndex: 'capacity_mib',
key: 'capacity_mib',
render: (v?: number) => (v ? `${Math.round(v / 1024)}GB` : '-')
},
...valueCols,
lastActiveCol
];
}
return [
{
title: intl.formatMessage({ id: 'usage.table.user' }),
dataIndex: 'user_name',
key: 'user_name'
},
...valueCols,
{
title: intl.formatMessage({ id: 'usage.metric.activeStorage' }),
dataIndex: 'active_volumes',
key: 'active_volumes'
},
lastActiveCol
];
}, [groupKey, intl]);
};
export default useStorageColumns;
@@ -27,27 +27,29 @@ import PieChart from '@/pages/_components/pie-chart';
import { formatLargeNumber } from '@/utils'; import { formatLargeNumber } from '@/utils';
import { CardWrapper } from '@gpustack/core-ui'; import { CardWrapper } from '@gpustack/core-ui';
import { useAccess, useIntl } from '@umijs/max'; import { useAccess, useIntl } from '@umijs/max';
import { Col, Empty, Row } from 'antd'; import { Col, Row } from 'antd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { queryUsageTimeSeriesData } from '../apis'; import ResourceFilterBar from '../components/resource-filter-bar';
import {
queryResourceBreakdown,
queryStorageBreakdown,
queryUsageSummary,
ResourceBreakdownResponse,
UsageSummaryResponse
} from '../apis/resource';
import useResourceMeta from '../hooks/use-resource-meta'; import useResourceMeta from '../hooks/use-resource-meta';
import { import {
bucketKey, bucketKey,
generateBucketRange, generateBucketRange,
Granularity Granularity
} from '../utils/time-buckets'; } from '../utils/time-buckets';
import ResourceFilterBar from './resource-filter-bar'; import useQueryResourceBreakdown from './services/use-query-resource-breakdown';
import useQueryStorageBreakdown from './services/use-query-storage-breakdown';
import useQueryTimeSeriesData from './services/use-query-timeseries-data';
import useQueryUsageSummary from './services/use-query-usage-summary';
type Scope = 'self' | 'all'; type Scope = 'self' | 'all';
type QueryParams = {
start: string;
end: string;
selectedUsers: number[];
};
// Round to at most 2 decimals everywhere (avoid 1.60999999… in the donut center). // Round to at most 2 decimals everywhere (avoid 1.60999999… in the donut center).
const round2 = (n?: number) => Math.round((Number(n) || 0) * 100) / 100; const round2 = (n?: number) => Math.round((Number(n) || 0) * 100) / 100;
const fmt = (n?: number) => formatLargeNumber(round2(n)); const fmt = (n?: number) => formatLargeNumber(round2(n));
@@ -73,9 +75,15 @@ const buildTrend = (
const k = bucketKey(r.date, gran); const k = bucketKey(r.date, gran);
map.set(k, (map.get(k) ?? 0) + r.value); map.set(k, (map.get(k) ?? 0) + r.value);
}); });
const keys = new Set(generateBucketRange(start, end, gran)); // The x-axis is strictly the selected [start, end] range. We deliberately do
map.forEach((_v, k) => keys.add(k)); // NOT union in the data's own bucket keys: on a date-range change the new
const xAxis = Array.from(keys).sort(); // start/end render a frame before the in-flight request resolves, so `map`
// still holds the previous range's dates — merging them would briefly show a
// union of both ranges (a gap + doubled axis). Dropping out-of-range points is
// safe for the `day` granularity used here, since generateBucketRange already
// emits every in-range day; revisit this if week/month granularity is added
// (their bucket keys can fall outside the stepped range).
const xAxis = generateBucketRange(start, end, gran);
return { xAxis, data: xAxis.map((k) => map.get(k) ?? 0) }; return { xAxis, data: xAxis.map((k) => map.get(k) ?? 0) };
}; };
@@ -96,6 +104,8 @@ const Stat: React.FC<{ value: React.ReactNode; label: string }> = ({
// row, then a donut (left, legend hugging it) beside a trend (right) that // row, then a donut (left, legend hugging it) beside a trend (right) that
// carries its own chart title — no wasteful caption rows. // carries its own chart title — no wasteful caption rows.
const DomainSection: React.FC<{ const DomainSection: React.FC<{
pieLoading?: boolean;
barLoading?: boolean;
title: string; title: string;
accent: string; accent: string;
headline: React.ReactNode; headline: React.ReactNode;
@@ -107,6 +117,8 @@ const DomainSection: React.FC<{
trendColor: string; trendColor: string;
trendGran: Granularity; trendGran: Granularity;
}> = ({ }> = ({
pieLoading,
barLoading,
title, title,
accent, accent,
headline, headline,
@@ -118,16 +130,14 @@ const DomainSection: React.FC<{
trendColor, trendColor,
trendGran trendGran
}) => { }) => {
const intl = useIntl();
const donutTotal = round2(donutData.reduce((s, d) => s + (d.value || 0), 0)); const donutTotal = round2(donutData.reduce((s, d) => s + (d.value || 0), 0));
const trendEmpty = trendData.every((v) => !v);
const empty = ( const seriesData = useMemo(() => {
<Empty return [{ name: trendTitle, data: trendData, color: trendColor }].filter(
image={Empty.PRESENTED_IMAGE_SIMPLE} (s) => s.data.some((v) => !!v)
description={intl.formatMessage({ id: 'usage.common.noData' })} );
style={{ margin: '32px 0' }} }, [trendData, trendColor]);
/>
);
return ( return (
<CardWrapper style={{ paddingBlock: 16 }}> <CardWrapper style={{ paddingBlock: 16 }}>
<div <div
@@ -166,37 +176,29 @@ const DomainSection: React.FC<{
}} }}
> >
<div style={{ width: 340, maxWidth: '100%', flexShrink: 0 }}> <div style={{ width: 340, maxWidth: '100%', flexShrink: 0 }}>
{donutTotal <= 0 ? ( <PieChart
empty loading={pieLoading}
) : ( data={donutData}
<PieChart height={180}
data={donutData} total={donutTotal}
height={180} totalLabel={donutTotalLabel}
total={donutTotal} />
totalLabel={donutTotalLabel}
/>
)}
</div> </div>
<div style={{ flex: 1, minWidth: 260 }}> <div style={{ flex: 1, minWidth: 260 }}>
{trendEmpty ? ( <BarChart
empty loading={barLoading}
) : ( seriesData={seriesData}
<BarChart xAxisData={trendXAxis}
seriesData={[ height={180}
{ name: trendTitle, data: trendData, color: trendColor } grid={{
]} bottom: 0
xAxisData={trendXAxis} }}
height={180} title={trendTitle}
grid={{ labelFormatter={trendLabel(trendGran)}
bottom: 0 tooltipValueFormatter={(v) =>
}} formatLargeNumber(round2(Number(v))) as string
title={trendTitle} }
labelFormatter={trendLabel(trendGran)} />
tooltipValueFormatter={(v) =>
formatLargeNumber(round2(Number(v))) as string
}
/>
)}
</div> </div>
</div> </div>
</CardWrapper> </CardWrapper>
@@ -216,95 +218,109 @@ const SummaryTab: React.FC = () => {
// Summary trends are fixed to a daily granularity (no granularity control). // Summary trends are fixed to a daily granularity (no granularity control).
const granularity: Granularity = 'day'; const granularity: Granularity = 'day';
const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs]>([ // Date range + user filter live together: every fetch keys off all three, so
dayjs().subtract(29, 'day'), // a single object keeps them in sync and trims the dependency arrays.
dayjs() const [queryParams, setQueryParams] = useState<{
]); start: string;
const [selectedUsers, setSelectedUsers] = useState<number[]>([]); end: string;
const [refreshKey, setRefreshKey] = useState(0); selectedUsers: number[];
}>({
start: dayjs().subtract(29, 'day').format('YYYY-MM-DD'),
end: dayjs().format('YYYY-MM-DD'),
selectedUsers: []
});
const { start, end, selectedUsers } = queryParams;
const { creators: userOptions } = useResourceMeta(scope); const { creators: userOptions } = useResourceMeta(scope);
const [summary, setSummary] = useState<UsageSummaryResponse | null>(null); const {
const [tokenSeries, setTokenSeries] = useState<any[]>([]); detailData: summary,
const [computeByDate, setComputeByDate] = loading: summaryLoading,
useState<ResourceBreakdownResponse | null>(null); fetchData: fetchSummary
const [storageByDate, setStorageByDate] = } = useQueryUsageSummary();
useState<ResourceBreakdownResponse | null>(null); const {
const [storageByType, setStorageByType] = detailData: tokenSeriesData,
useState<ResourceBreakdownResponse | null>(null); loading: tokenSeriesLoading,
fetchData: fetchTokenSeries
} = useQueryTimeSeriesData();
const {
detailData: computeByDate,
loading: computeLoading,
fetchData: fetchComputeBreakdown
} = useQueryResourceBreakdown();
const {
detailData: storageByDate,
loading: storageByDateLoading,
fetchData: fetchStorageByDate
} = useQueryStorageBreakdown({ key: 'storageByDate' });
const {
detailData: storageByType,
loading: storageByTypeLoading,
fetchData: fetchStorageByType
} = useQueryStorageBreakdown({ key: 'storageByType' });
const start = dateRange[0].format('YYYY-MM-DD'); const fetchAll = async (params?: Partial<QueryParams>) => {
const end = dateRange[1].format('YYYY-MM-DD'); const currentParams = {
// Token usage (model_usages) is a daily rollup; the Summary trends are fixed ...queryParams,
// to ``day`` anyway, so the token trend shares the same granularity. ...params
const tokenGran: Granularity = granularity; };
// "filter by user" — restricts every resource fetch to these creator ids. const commonParams = {
const creatorFilter = selectedUsers.length start_date: currentParams.start,
? { creator_ids: selectedUsers } end_date: currentParams.end,
: undefined; scope
};
// Date+scope scoped fetches (granularity-independent). const paginationParams = {
useEffect(() => { ...commonParams,
queryUsageSummary({
start_date: start,
end_date: end,
scope,
creator_ids: selectedUsers.length ? selectedUsers : undefined
})
.then(setSummary)
.catch(() => {});
queryStorageBreakdown({
start_date: start,
end_date: end,
scope,
group_by: ['type'],
filters: creatorFilter,
page: 1, page: 1,
perPage: 100 perPage: 100
}) };
.then(setStorageByType)
.catch(() => {});
}, [start, end, scope, selectedUsers, refreshKey]);
// Trend fetches (depend on granularity too). if (params) {
useEffect(() => { setQueryParams(currentParams);
queryUsageTimeSeriesData({ }
start_date: start,
end_date: end, // "filter by user" — restricts every resource fetch to these creator ids.
scope, const creatorFilter = currentParams.selectedUsers.length
metric: 'total_tokens', ? { creator_ids: currentParams.selectedUsers }
group_by: ['date'], : undefined;
granularity: tokenGran,
filters: {} await Promise.all([
}) fetchSummary({
.then((res) => setTokenSeries(res.items || [])) ...commonParams,
.catch(() => {}); creator_ids: currentParams.selectedUsers.length
queryResourceBreakdown({ ? currentParams.selectedUsers
start_date: start, : undefined
end_date: end, }),
scope,
group_by: ['date'], fetchStorageByType({
granularity, ...paginationParams,
filters: creatorFilter, group_by: ['type'],
page: 1, filters: creatorFilter
perPage: 100 }),
})
.then(setComputeByDate) fetchTokenSeries({
.catch(() => {}); ...commonParams,
queryStorageBreakdown({ metric: 'total_tokens',
start_date: start, group_by: ['date'],
end_date: end, granularity,
scope, filters: {}
group_by: ['date'], }),
granularity,
filters: creatorFilter, fetchComputeBreakdown({
page: 1, ...paginationParams,
perPage: 100 group_by: ['date'],
}) granularity,
.then(setStorageByDate) filters: creatorFilter
.catch(() => {}); }),
}, [start, end, scope, granularity, tokenGran, selectedUsers, refreshKey]);
fetchStorageByDate({
...paginationParams,
group_by: ['date'],
granularity,
filters: creatorFilter
})
]);
};
// --- derived: donuts --- // --- derived: donuts ---
const tokenDonut = useMemo( const tokenDonut = useMemo(
@@ -337,16 +353,17 @@ const SummaryTab: React.FC = () => {
const tokenTrend = useMemo( const tokenTrend = useMemo(
() => () =>
buildTrend( buildTrend(
tokenSeries.map((it) => ({ (tokenSeriesData?.items || []).map((it: any) => ({
date: it?.date?.value, date: it?.date?.value,
value: Number(it?.total_tokens ?? 0) value: Number(it?.total_tokens ?? 0)
})), })),
tokenGran, granularity,
start, start,
end end
), ),
[tokenSeries, tokenGran, start, end] [tokenSeriesData, granularity, start, end]
); );
const computeTrend = useMemo( const computeTrend = useMemo(
() => () =>
buildTrend( buildTrend(
@@ -360,6 +377,7 @@ const SummaryTab: React.FC = () => {
), ),
[computeByDate, granularity, start, end] [computeByDate, granularity, start, end]
); );
const storageTrend = useMemo( const storageTrend = useMemo(
() => () =>
buildTrend( buildTrend(
@@ -377,16 +395,35 @@ const SummaryTab: React.FC = () => {
const computeSum = computeByDate?.summary; const computeSum = computeByDate?.summary;
const storageSum = storageByDate?.summary; const storageSum = storageByDate?.summary;
const handleDateRangeChange = (dates: [dayjs.Dayjs, dayjs.Dayjs]) => {
fetchAll({
start: dates[0].format('YYYY-MM-DD'),
end: dates[1].format('YYYY-MM-DD')
});
};
const handleUserFilterChange = (users: number[]) => {
fetchAll({ selectedUsers: users });
};
const onRefresh = () => {
fetchAll();
};
useEffect(() => {
fetchAll();
}, []);
return ( return (
<div> <div>
<ResourceFilterBar <ResourceFilterBar
value={dateRange} value={[dayjs(start), dayjs(end)]}
onChange={(dates) => setDateRange(dates)} onChange={handleDateRangeChange}
canManageUsers={canManageUsers} canManageUsers={canManageUsers}
userOptions={userOptions} userOptions={userOptions}
selectedUsers={selectedUsers} selectedUsers={selectedUsers}
onUsersChange={setSelectedUsers} onUsersChange={handleUserFilterChange}
onRefresh={() => setRefreshKey((k) => k + 1)} onRefresh={onRefresh}
/> />
<div style={{ height: 24 }} /> <div style={{ height: 24 }} />
@@ -400,8 +437,10 @@ const SummaryTab: React.FC = () => {
trendTitle={t('usage.summary.tokensOverTime')} trendTitle={t('usage.summary.tokensOverTime')}
trendXAxis={tokenTrend.xAxis} trendXAxis={tokenTrend.xAxis}
trendData={tokenTrend.data} trendData={tokenTrend.data}
pieLoading={summaryLoading}
barLoading={tokenSeriesLoading}
trendColor={coolColors[0]} trendColor={coolColors[0]}
trendGran={tokenGran} trendGran={granularity}
headline={ headline={
<> <>
<Stat <Stat
@@ -436,6 +475,8 @@ const SummaryTab: React.FC = () => {
trendData={computeTrend.data} trendData={computeTrend.data}
trendColor={coolColors[1]} trendColor={coolColors[1]}
trendGran={granularity} trendGran={granularity}
pieLoading={summaryLoading}
barLoading={computeLoading}
headline={ headline={
<> <>
<Stat <Stat
@@ -466,6 +507,8 @@ const SummaryTab: React.FC = () => {
trendData={storageTrend.data} trendData={storageTrend.data}
trendColor={coolColors[3]} trendColor={coolColors[3]}
trendGran={granularity} trendGran={granularity}
pieLoading={storageByTypeLoading}
barLoading={storageByDateLoading}
headline={ headline={
<> <>
<Stat <Stat
@@ -0,0 +1,19 @@
import { useQueryData } from '@/hooks/use-query-data-list';
import {
queryResourceBreakdown,
ResourceBreakdownResponse
} from '../../apis/resource';
export default function useQueryResourceBreakdown(option?: { key?: string }) {
const { detailData, loading, cancelRequest, fetchData } =
useQueryData<ResourceBreakdownResponse>({
fetchDetail: queryResourceBreakdown,
key: option?.key || 'resourceBreakdown'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
@@ -0,0 +1,19 @@
import { useQueryData } from '@/hooks/use-query-data-list';
import {
queryStorageBreakdown,
ResourceBreakdownResponse
} from '../../apis/resource';
export default function useQueryStorageBreakdown(option?: { key?: string }) {
const { detailData, loading, cancelRequest, fetchData } =
useQueryData<ResourceBreakdownResponse>({
fetchDetail: queryStorageBreakdown,
key: option?.key || 'storageBreakdown'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
@@ -0,0 +1,17 @@
import { useQueryData } from '@/hooks/use-query-data-list';
import { queryUsageTimeSeriesData } from '../../apis';
import { UsageBreakdownResponse } from '../../config/types';
export default function useQueryTimeSeriesData(option?: { key?: string }) {
const { detailData, loading, cancelRequest, fetchData } =
useQueryData<UsageBreakdownResponse>({
fetchDetail: queryUsageTimeSeriesData,
key: option?.key || 'tokenTimeSeries'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
@@ -0,0 +1,16 @@
import { useQueryData } from '@/hooks/use-query-data-list';
import { queryUsageSummary, UsageSummaryResponse } from '../../apis/resource';
export default function useQueryUsageSummary(option?: { key?: string }) {
const { detailData, loading, cancelRequest, fetchData } =
useQueryData<UsageSummaryResponse>({
fetchDetail: queryUsageSummary,
key: option?.key || 'usageSummary'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
@@ -1,7 +1,7 @@
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Tabs } from 'antd'; import { Tabs } from 'antd';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { UsageFilterItem } from '../config/types'; import { UsageFilterItem } from '../../config/types';
import ApiKeysTable from '../tables/apikeys-table'; import ApiKeysTable from '../tables/apikeys-table';
import ModelsTable from '../tables/models-table'; import ModelsTable from '../tables/models-table';
import UsersTable from '../tables/users-table'; import UsersTable from '../tables/users-table';
@@ -47,7 +47,7 @@ const BreakdownTabs: React.FC<{
}, },
{ {
key: 'users', key: 'users',
label: intl.formatMessage({ id: 'usage.tabs.users' }), label: intl.formatMessage({ id: 'usage.table.users' }),
forceRender: true, forceRender: true,
children: ( children: (
<UsersTable <UsersTable
@@ -3,13 +3,13 @@ import { formatLargeNumber } from '@/utils';
import { SimpleCard } from '@gpustack/core-ui'; import { SimpleCard } from '@gpustack/core-ui';
import { useAccess, useIntl } from '@umijs/max'; import { useAccess, useIntl } from '@umijs/max';
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import DailyUsage from '../components/daily-usage';
import ExportData from '../components/export-data';
import FilterBar from '../components/filter-bar';
import useExportTable from '../hooks/use-export-table'; import useExportTable from '../hooks/use-export-table';
import { useUsageFilters } from '../hooks/use-usage-filters'; import { useUsageFilters } from '../hooks/use-usage-filters';
import useQueryUsageMetaData from '../services/use-query-meta-data'; import useQueryUsageMetaData from '../services/use-query-meta-data';
import BreakdownTabs from './breakdown-tabs'; import BreakdownTabs from './components/breakdown-tabs';
import DailyUsage from './daily-usage';
import ExportData from './export-data';
import FilterBar from './filter-bar';
type DateType = 'date' | 'week' | 'month' | 'quarter' | 'year'; type DateType = 'date' | 'week' | 'month' | 'quarter' | 'year';
@@ -1,14 +1,12 @@
import { TABLE_SORT_DIRECTIONS } from '@/config/settings'; import { TABLE_SORT_DIRECTIONS } from '@/config/settings';
import PageBox from '@/pages/_components/page-box'; import PageBox from '@/pages/_components/page-box';
import { IconFont, NoResult } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Table } from 'antd'; import { Table } from 'antd';
import _ from 'lodash';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { FilterOptionType } from '../config/types'; import { FilterOptionType } from '../../config/types';
import useAPIKeys from '../hooks/use-apikeys-columns'; import useAPIKeys from '../../hooks/use-apikeys-columns';
import useQueryBreakdownList from '../services/use-query-breakdown-list'; import useQueryBreakdownList from '../../services/use-query-breakdown-list';
import getBreakdownRowKey from '../utils/get-breakdown-row-key'; import getBreakdownRowKey from '../../utils/get-breakdown-row-key';
const APIKeys: React.FC<{ const APIKeys: React.FC<{
apiKeys: FilterOptionType[]; apiKeys: FilterOptionType[];
@@ -38,6 +36,7 @@ const APIKeys: React.FC<{
sorter.order === 'descend' ? `-${sorter.field}` : sorter.field; sorter.order === 'descend' ? `-${sorter.field}` : sorter.field;
setQueryParams((prev) => ({ setQueryParams((prev) => ({
...prev, ...prev,
page: 1,
sort_by sort_by
})); }));
}; };
@@ -61,24 +60,6 @@ const APIKeys: React.FC<{
} }
}, [pageResetKey]); }, [pageResetKey]);
const renderEmpty = (type?: string) => {
if (type !== 'Table') return;
return (
<NoResult
loading={loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
image={<IconFont type="icon-key" />}
filters={_.omit(queryParams, ['sort_by'])}
noFoundText={intl.formatMessage({
id: 'noresult.keys.nofound'
})}
title={intl.formatMessage({ id: 'noresult.keys.title' })}
subTitle={intl.formatMessage({ id: 'noresult.keys.subTitle' })}
></NoResult>
);
};
useEffect(() => { useEffect(() => {
if (pendingPageResetRef.current && queryParams.page !== 1) { if (pendingPageResetRef.current && queryParams.page !== 1) {
return; return;
@@ -1,15 +1,13 @@
import PluginExtraFields from '@/components/plugin-extra-fields'; import PluginExtraFields from '@/components/plugin-extra-fields';
import { TABLE_SORT_DIRECTIONS } from '@/config/settings'; import { TABLE_SORT_DIRECTIONS } from '@/config/settings';
import PageBox from '@/pages/_components/page-box'; import PageBox from '@/pages/_components/page-box';
import { IconFont, NoResult } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Table } from 'antd'; import { Table } from 'antd';
import _ from 'lodash';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { BreakdownItem, FilterOptionType } from '../config/types'; import { BreakdownItem, FilterOptionType } from '../../config/types';
import useModelsColumns from '../hooks/use-models-columns'; import useModelsColumns from '../../hooks/use-models-columns';
import useQueryBreakdownList from '../services/use-query-breakdown-list'; import useQueryBreakdownList from '../../services/use-query-breakdown-list';
import getBreakdownRowKey from '../utils/get-breakdown-row-key'; import getBreakdownRowKey from '../../utils/get-breakdown-row-key';
const Models: React.FC<{ const Models: React.FC<{
routes: FilterOptionType[]; routes: FilterOptionType[];
@@ -40,6 +38,7 @@ const Models: React.FC<{
sorter.order === 'descend' ? `-${sorter.field}` : sorter.field; sorter.order === 'descend' ? `-${sorter.field}` : sorter.field;
setQueryParams((prev) => ({ setQueryParams((prev) => ({
...prev, ...prev,
page: 1,
sort_by sort_by
})); }));
}; };
@@ -64,24 +63,6 @@ const Models: React.FC<{
} }
}, [pageResetKey]); }, [pageResetKey]);
const renderEmpty = (type?: string) => {
if (type !== 'Table') return;
return (
<NoResult
loading={loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
image={<IconFont type="icon-resources" />}
filters={_.omit(queryParams, ['sort_by'])}
noFoundText={intl.formatMessage({
id: 'noresult.mymodels.nofound'
})}
title={intl.formatMessage({ id: 'noresult.deployments.title' })}
subTitle={intl.formatMessage({ id: 'noresult.deployments.subTitle' })}
></NoResult>
);
};
useEffect(() => { useEffect(() => {
if (pendingPageResetRef.current && queryParams.page !== 1) { if (pendingPageResetRef.current && queryParams.page !== 1) {
return; return;
@@ -1,14 +1,12 @@
import { TABLE_SORT_DIRECTIONS } from '@/config/settings'; import { TABLE_SORT_DIRECTIONS } from '@/config/settings';
import PageBox from '@/pages/_components/page-box'; import PageBox from '@/pages/_components/page-box';
import { IconFont, NoResult } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Table } from 'antd'; import { Table } from 'antd';
import _ from 'lodash';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { FilterOptionType } from '../config/types'; import { FilterOptionType } from '../../config/types';
import useUsersColumns from '../hooks/use-users-columns'; import useUsersColumns from '../../hooks/use-users-columns';
import useQueryBreakdownList from '../services/use-query-breakdown-list'; import useQueryBreakdownList from '../../services/use-query-breakdown-list';
import getBreakdownRowKey from '../utils/get-breakdown-row-key'; import getBreakdownRowKey from '../../utils/get-breakdown-row-key';
const Users: React.FC<{ const Users: React.FC<{
users: FilterOptionType[]; users: FilterOptionType[];
@@ -38,6 +36,7 @@ const Users: React.FC<{
sorter.order === 'descend' ? `-${sorter.field}` : sorter.field; sorter.order === 'descend' ? `-${sorter.field}` : sorter.field;
setQueryParams((prev) => ({ setQueryParams((prev) => ({
...prev, ...prev,
page: 1,
sort_by sort_by
})); }));
}; };
@@ -52,24 +51,6 @@ const Users: React.FC<{
const columns = useUsersColumns(); const columns = useUsersColumns();
const renderEmpty = (type?: string) => {
if (type !== 'Table') return;
return (
<NoResult
loading={loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
image={<IconFont type="icon-users" />}
filters={_.omit(queryParams, ['sort_by'])}
noFoundText={intl.formatMessage({
id: 'noresult.users.nofound'
})}
title={intl.formatMessage({ id: 'noresult.users.title' })}
subTitle={intl.formatMessage({ id: 'noresult.users.subTitle' })}
></NoResult>
);
};
useEffect(() => { useEffect(() => {
if (queryParams.page !== 1) { if (queryParams.page !== 1) {
pendingPageResetRef.current = true; pendingPageResetRef.current = true;
+25 -10
View File
@@ -26,12 +26,13 @@ export const toExportColumns = (columns: any[]): ExportColumn[] =>
dataIndex: c.dataIndex as string dataIndex: c.dataIndex as string
})); }));
export const exportBreakdownRows = ( export interface ExportSheet {
rows: any[], rows: any[];
columns: ExportColumn[], columns: ExportColumn[];
fileName: string, sheetName: string;
sheetName = 'usage' }
): void => {
const buildSheet = ({ rows, columns, sheetName }: ExportSheet) => {
const fields = columns.map((c) => c.dataIndex); const fields = columns.map((c) => c.dataIndex);
const fieldLabels = Object.fromEntries( const fieldLabels = Object.fromEntries(
columns.map((c) => [c.dataIndex, c.title]) columns.map((c) => [c.dataIndex, c.title])
@@ -43,8 +44,22 @@ export const exportBreakdownRows = (
}); });
return o; return o;
}); });
exportJsonToExcel({ return { jsonData, sheetName, fields, fieldLabels, formatMap: {} };
fileName, };
sheets: [{ jsonData, sheetName, fields, fieldLabels, formatMap: {} }]
}); // Write one or more breakdown result sets to a single workbook, one sheet each.
export const exportBreakdownSheets = (
sheets: ExportSheet[],
fileName: string
): void => {
exportJsonToExcel({ fileName, sheets: sheets.map(buildSheet) });
};
export const exportBreakdownRows = (
rows: any[],
columns: ExportColumn[],
fileName: string,
sheetName = 'usage'
): void => {
exportBreakdownSheets([{ rows, columns, sheetName }], fileName);
}; };