Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2767f883fc | ||
|
|
e98651da2b | ||
|
|
84304eadb7 | ||
|
|
ab3739219e | ||
|
|
7cf4932f3a | ||
|
|
032f1eefef | ||
|
|
e35be94f7c | ||
|
|
e76e2ad85a | ||
|
|
cb19cd2476 | ||
|
|
c532732ce9 | ||
|
|
2bc10dbb2e | ||
|
|
b634a03ef9 | ||
|
|
ac64bb98ad | ||
|
|
aa49539210 | ||
|
|
1f5678f781 | ||
|
|
7adc089e94 | ||
|
|
6e1e9c07ee | ||
|
|
1d55382f3b | ||
|
|
e68bb0bc4e | ||
|
|
c8e14dade5 | ||
|
|
e4c1a3f5c2 | ||
|
|
cdd6ef9cb3 | ||
|
|
737d43fd16 | ||
|
|
9f5163531c | ||
|
|
df5cf0f4e5 | ||
|
|
e160b64697 | ||
|
|
7d36c6ac4a | ||
|
|
0820edf55f | ||
|
|
12e324893f | ||
|
|
4017c4dd33 | ||
|
|
17b65d80e1 | ||
|
|
47cb3321ab | ||
|
|
547b9f617b | ||
|
|
9e7bd78693 | ||
|
|
c53d60b628 | ||
|
|
9566e0cdb2 | ||
|
|
ed3e779d22 | ||
|
|
3e2133753d | ||
|
|
736a853192 | ||
|
|
08ef2ffa9a | ||
|
|
6cf512cccd | ||
|
|
6f56e614f3 |
@@ -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 |
@@ -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 |
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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': '容器内路径',
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ const UsageByModel: React.FC<UsageByModelProps> = ({
|
|||||||
.fetchData({
|
.fetchData({
|
||||||
...commonParams,
|
...commonParams,
|
||||||
metric: 'total_tokens',
|
metric: 'total_tokens',
|
||||||
group_by: ['date', 'route'],
|
group_by: ['route'],
|
||||||
page: 1,
|
page: 1,
|
||||||
perPage: 10,
|
perPage: 10,
|
||||||
sort_by: '-total_tokens'
|
sort_by: '-total_tokens'
|
||||||
@@ -67,7 +67,7 @@ const UsageByModel: React.FC<UsageByModelProps> = ({
|
|||||||
.fetchData({
|
.fetchData({
|
||||||
...commonParams,
|
...commonParams,
|
||||||
metric: 'api_requests',
|
metric: 'api_requests',
|
||||||
group_by: ['date', 'route'],
|
group_by: ['route'],
|
||||||
page: 1,
|
page: 1,
|
||||||
perPage: 10,
|
perPage: 10,
|
||||||
sort_by: '-api_requests'
|
sort_by: '-api_requests'
|
||||||
|
|||||||
@@ -225,29 +225,77 @@ export const toUsagePieData = (
|
|||||||
return aggregateUsageByGroup(data, groupBy, metric);
|
return aggregateUsageByGroup(data, groupBy, metric);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const toUsageRankData = (
|
export type UsageTokenMetric = 'input_tokens' | 'output_tokens';
|
||||||
|
|
||||||
|
export interface UsageTokenSeriesDef {
|
||||||
|
name: string;
|
||||||
|
key: UsageTokenMetric;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregates each group's input/output tokens into a stacked HBarChart shape,
|
||||||
|
// so a single bar shows prompt (input) and completion (output) tokens side by
|
||||||
|
// side. Rows sharing a group (e.g. the same user across dates) are summed, then
|
||||||
|
// ranked by combined tokens and capped at the top 10.
|
||||||
|
export const toUsageTokenBreakdownData = (
|
||||||
data: UsageBreakdownResponse | null | undefined,
|
data: UsageBreakdownResponse | null | undefined,
|
||||||
groupBy: UsageGroupBy,
|
groupBy: UsageGroupBy,
|
||||||
seriesName: string,
|
seriesDefs: UsageTokenSeriesDef[]
|
||||||
color: string
|
|
||||||
) => {
|
) => {
|
||||||
const items = aggregateUsageByGroup(data, groupBy, 'total_tokens');
|
const itemMap = new Map<string, { total: number; values: number[] }>();
|
||||||
const names = items.map((item) => item.name);
|
const items = getUsageResponseItems(data);
|
||||||
|
|
||||||
|
items.forEach((item: BreakdownItem) => {
|
||||||
|
const name = buildUsageLabel(item, groupBy);
|
||||||
|
const entry = itemMap.get(name) || {
|
||||||
|
total: 0,
|
||||||
|
values: seriesDefs.map(() => 0)
|
||||||
|
};
|
||||||
|
|
||||||
|
seriesDefs.forEach((def, index) => {
|
||||||
|
const value = Number((item as any)?.[def.key] ?? 0);
|
||||||
|
entry.values[index] += value;
|
||||||
|
entry.total += value;
|
||||||
|
});
|
||||||
|
|
||||||
|
itemMap.set(name, entry);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ranked = Array.from(itemMap.entries())
|
||||||
|
.filter(([, entry]) => entry.total > 0)
|
||||||
|
.sort((a, b) => b[1].total - a[1].total)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
const names = ranked.map(([name]) => name);
|
||||||
|
|
||||||
|
// Round only the outer ends of the stacked bar; the seam where the input and
|
||||||
|
// output segments meet stays square ([topLeft, topRight, bottomRight, bottomLeft]).
|
||||||
|
const radius = 2;
|
||||||
|
const getBorderRadius = (index: number, count: number) => {
|
||||||
|
if (count <= 1) {
|
||||||
|
return [radius, radius, radius, radius];
|
||||||
|
}
|
||||||
|
if (index === 0) {
|
||||||
|
return [radius, 0, 0, radius];
|
||||||
|
}
|
||||||
|
if (index === count - 1) {
|
||||||
|
return [0, radius, radius, 0];
|
||||||
|
}
|
||||||
|
return [0, 0, 0, 0];
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
names,
|
names,
|
||||||
series: [
|
series: seriesDefs.map((def, index) => ({
|
||||||
{
|
name: def.name,
|
||||||
name: seriesName,
|
color: def.color,
|
||||||
color,
|
data: ranked.map(([name, entry]) => ({
|
||||||
data: items.map((item) => ({
|
name,
|
||||||
name: item.name,
|
value: entry.values[index],
|
||||||
value: item.value,
|
itemStyle: {
|
||||||
itemStyle: {
|
borderRadius: getBorderRadius(index, seriesDefs.length)
|
||||||
borderRadius: [2, 2, 2, 2]
|
}
|
||||||
}
|
}))
|
||||||
}))
|
}))
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,27 +1,24 @@
|
|||||||
import useQueryTimeSeriesData from '@/pages/usage/services/use-query-timeseries-data';
|
import useQueryTimeSeriesData from '@/pages/usage/services/use-query-timeseries-data';
|
||||||
import { useIntl } from '@umijs/max';
|
|
||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
baseColorMap,
|
baseColorMap,
|
||||||
DashboardUsageCommonParams,
|
DashboardUsageCommonParams,
|
||||||
toUsageRankData
|
toUsageTokenBreakdownData
|
||||||
} from '../config';
|
} from '../config';
|
||||||
|
|
||||||
export default function useTopTokenUsageByUser(
|
export default function useTopTokenUsageByUser(
|
||||||
commonParams: DashboardUsageCommonParams
|
commonParams: DashboardUsageCommonParams
|
||||||
) {
|
) {
|
||||||
const intl = useIntl();
|
|
||||||
const query = useQueryTimeSeriesData({
|
const query = useQueryTimeSeriesData({
|
||||||
key: 'topTokenUsageByUserData'
|
key: 'topTokenUsageByUserData'
|
||||||
});
|
});
|
||||||
const tokenUsageText = intl.formatMessage({ id: 'dashboard.tokens' });
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
query
|
query
|
||||||
.fetchData({
|
.fetchData({
|
||||||
...commonParams,
|
...commonParams,
|
||||||
metric: 'total_tokens',
|
metric: 'total_tokens',
|
||||||
group_by: ['date', 'user'],
|
group_by: ['user'],
|
||||||
page: 1,
|
page: 1,
|
||||||
perPage: 10,
|
perPage: 10,
|
||||||
sort_by: '-total_tokens'
|
sort_by: '-total_tokens'
|
||||||
@@ -31,13 +28,19 @@ export default function useTopTokenUsageByUser(
|
|||||||
|
|
||||||
const rankData = useMemo(
|
const rankData = useMemo(
|
||||||
() =>
|
() =>
|
||||||
toUsageRankData(
|
toUsageTokenBreakdownData(query.detailData, 'user', [
|
||||||
query.detailData,
|
{
|
||||||
'user',
|
name: 'Prompt Tokens',
|
||||||
tokenUsageText,
|
key: 'input_tokens',
|
||||||
baseColorMap.base
|
color: baseColorMap.base
|
||||||
),
|
},
|
||||||
[query.detailData, tokenUsageText]
|
{
|
||||||
|
name: 'Completion Tokens',
|
||||||
|
key: 'output_tokens',
|
||||||
|
color: baseColorMap.baseR3
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
[query.detailData]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
+31
-40
@@ -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
@@ -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.
|
||||||
|
|||||||
+133
-254
@@ -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;
|
||||||
+126
-225
@@ -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;
|
||||||
+184
-141
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
+2
-2
@@ -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';
|
||||||
|
|
||||||
+5
-24
@@ -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;
|
||||||
+5
-24
@@ -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;
|
||||||
+5
-24
@@ -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;
|
||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user