Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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',
|
||||
selectedIcon: 'icon-billing-filled',
|
||||
defaultIcon: 'icon-billing-outlined',
|
||||
hideInMenu: process.env.ENABLE_ENTERPRISE === 'true',
|
||||
// OSS exposes the menu as a teaser for the enterprise billing
|
||||
// module. The page itself just renders an upsell notice — the real
|
||||
// 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<{
|
||||
firstAddWorker: boolean;
|
||||
firstAddCluster: boolean;
|
||||
presetClusterType?: 'model' | 'gpu';
|
||||
// Provider to preselect when the create flow opens — set by the
|
||||
// empty-state CTA on feature pages that need a specific provider
|
||||
// (e.g. GPU Service can only schedule on Kubernetes, so its
|
||||
|
||||
@@ -276,7 +276,6 @@ body {
|
||||
border-radius: var(--table-td-radius);
|
||||
|
||||
> td {
|
||||
background-color: unset !important;
|
||||
border-bottom: 1px solid var(--ant-color-split);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export function useQueryDataList<
|
||||
Response = Array<ListItem>
|
||||
>(option: {
|
||||
key: string;
|
||||
manual?: boolean;
|
||||
responseType?: 'array' | 'object';
|
||||
fetchList: (
|
||||
params: Params,
|
||||
@@ -38,6 +39,7 @@ export function useQueryDataList<
|
||||
fetchList,
|
||||
getLabel,
|
||||
getValue,
|
||||
manual = true,
|
||||
responseType = 'array',
|
||||
errorMsg
|
||||
} = option;
|
||||
@@ -70,7 +72,7 @@ export function useQueryDataList<
|
||||
return responseType === 'array' ? res.items || [] : res;
|
||||
},
|
||||
{
|
||||
manual: true,
|
||||
manual: manual,
|
||||
debounceWait: option.debounceWait || 300,
|
||||
onSuccess: () => {},
|
||||
onError: (error) => {
|
||||
@@ -105,16 +107,18 @@ export function useQueryDataList<
|
||||
export function useQueryData<Detail, Params = any>(option: {
|
||||
key: string;
|
||||
delay?: number;
|
||||
manual?: boolean;
|
||||
fetchDetail: (params: Params, options?: any) => Promise<Detail>;
|
||||
getData?: (response: Detail, params?: any) => any;
|
||||
errorMsg?: string;
|
||||
}): {
|
||||
loading: boolean;
|
||||
detailData: Detail;
|
||||
manual?: boolean;
|
||||
cancelRequest: () => void;
|
||||
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 [detailData, setDetailData] = useState<Detail>({} as Detail);
|
||||
|
||||
@@ -142,7 +146,7 @@ export function useQueryData<Detail, Params = any>(option: {
|
||||
return res;
|
||||
},
|
||||
{
|
||||
manual: true,
|
||||
manual: manual,
|
||||
onSuccess: () => {},
|
||||
onError: (error) => {
|
||||
message.error(
|
||||
|
||||
@@ -25,7 +25,7 @@ export default {
|
||||
'ai.provider.ollama': 'Ollama',
|
||||
'ai.provider.openai': 'OpenAI',
|
||||
'ai.provider.openrouter': 'OpenRouter',
|
||||
'ai.provider.qwen': 'Qwen',
|
||||
'ai.provider.qwen': 'Alibaba Cloud Model Studio',
|
||||
'ai.provider.spark': 'Spark',
|
||||
'ai.provider.stepfun': 'StepFun',
|
||||
'ai.provider.together-ai': 'TogetherAI',
|
||||
|
||||
@@ -144,7 +144,7 @@ export default {
|
||||
'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.',
|
||||
'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.name': 'Volume Name',
|
||||
'clusters.volume.mountPath': 'Container Path',
|
||||
|
||||
@@ -25,7 +25,7 @@ export default {
|
||||
'ai.provider.ollama': 'Ollama',
|
||||
'ai.provider.openai': 'OpenAI',
|
||||
'ai.provider.openrouter': 'OpenRouter',
|
||||
'ai.provider.qwen': 'Qwen',
|
||||
'ai.provider.qwen': 'Alibaba Cloud Model Studio',
|
||||
'ai.provider.spark': 'Spark',
|
||||
'ai.provider.stepfun': 'StepFun',
|
||||
'ai.provider.together-ai': 'TogetherAI',
|
||||
|
||||
@@ -144,7 +144,7 @@ export default {
|
||||
'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.',
|
||||
'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.name': 'Volume Name',
|
||||
'clusters.volume.mountPath': 'Container Path',
|
||||
|
||||
@@ -25,7 +25,7 @@ export default {
|
||||
'ai.provider.ollama': 'Ollama',
|
||||
'ai.provider.openai': 'OpenAI',
|
||||
'ai.provider.openrouter': 'OpenRouter',
|
||||
'ai.provider.qwen': 'Qwen',
|
||||
'ai.provider.qwen': 'Alibaba Cloud Model Studio',
|
||||
'ai.provider.spark': 'Spark',
|
||||
'ai.provider.stepfun': 'StepFun',
|
||||
'ai.provider.together-ai': 'TogetherAI',
|
||||
|
||||
@@ -145,7 +145,7 @@ export default {
|
||||
'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.',
|
||||
'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.name': 'Volume Name',
|
||||
'clusters.volume.mountPath': 'Container Path',
|
||||
|
||||
@@ -25,7 +25,7 @@ export default {
|
||||
'ai.provider.ollama': 'Ollama',
|
||||
'ai.provider.openai': 'OpenAI',
|
||||
'ai.provider.openrouter': 'OpenRouter',
|
||||
'ai.provider.qwen': 'Qwen',
|
||||
'ai.provider.qwen': 'Alibaba Cloud Model Studio',
|
||||
'ai.provider.spark': 'Spark',
|
||||
'ai.provider.stepfun': 'StepFun',
|
||||
'ai.provider.together-ai': 'TogetherAI',
|
||||
|
||||
@@ -145,7 +145,7 @@ export default {
|
||||
'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.',
|
||||
'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.name': 'Volume Name',
|
||||
'clusters.volume.mountPath': 'Container Path',
|
||||
|
||||
@@ -25,7 +25,7 @@ export default {
|
||||
'ai.provider.ollama': 'Ollama',
|
||||
'ai.provider.openai': 'OpenAI',
|
||||
'ai.provider.openrouter': 'OpenRouter',
|
||||
'ai.provider.qwen': '通义千问',
|
||||
'ai.provider.qwen': '阿里云百炼',
|
||||
'ai.provider.spark': '讯飞星火',
|
||||
'ai.provider.stepfun': '阶跃星辰',
|
||||
'ai.provider.together-ai': 'Together AI',
|
||||
|
||||
@@ -138,7 +138,7 @@ export default {
|
||||
'clusters.addworker.theadNotes-02':
|
||||
'平头哥(T-Head)PPU 使用容器设备接口(CDI)进行设备注入,因此需要确保 <span class="bold-text">/var/run/cdi</span> 目录可用以生成 CDI。',
|
||||
'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.name': '卷名称',
|
||||
'clusters.volume.mountPath': '容器内路径',
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import useCoolColors from '@/hooks/use-cool-colors';
|
||||
import { Chart } from '@gpustack/core-ui';
|
||||
import { formatLargeNumber } from '@gpustack/core-ui/utils';
|
||||
import { Empty, theme } from 'antd';
|
||||
import { Empty, Spin, theme } from 'antd';
|
||||
import _ from 'lodash';
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react';
|
||||
|
||||
export interface BarSeriesItem {
|
||||
name: string;
|
||||
@@ -17,6 +23,7 @@ export interface BarChartProps {
|
||||
xAxisData: string[];
|
||||
height: number | string;
|
||||
width?: number | string;
|
||||
loading?: boolean;
|
||||
legendData?: { name: string; icon?: string }[];
|
||||
labelFormatter?: (val: any, index?: number) => string;
|
||||
tooltipValueFormatter?: (val: any) => string;
|
||||
@@ -38,6 +45,7 @@ const BarChart: React.FC<BarChartProps> = (props) => {
|
||||
height,
|
||||
width,
|
||||
legendData,
|
||||
loading,
|
||||
labelFormatter,
|
||||
tooltipValueFormatter,
|
||||
title,
|
||||
@@ -48,6 +56,27 @@ const BarChart: React.FC<BarChartProps> = (props) => {
|
||||
const chartRef = useRef<{ chart: any } | null>(null);
|
||||
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(
|
||||
() => generateCoolColors(seriesData.length),
|
||||
[seriesData.length, generateCoolColors]
|
||||
@@ -277,18 +306,43 @@ const BarChart: React.FC<BarChartProps> = (props) => {
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
{loading ? (
|
||||
<Spin size="middle" />
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Chart
|
||||
ref={chartRef as any}
|
||||
options={options as any}
|
||||
height={height}
|
||||
width={width || '100%'}
|
||||
/>
|
||||
<div
|
||||
ref={measureRef}
|
||||
style={{ width: width || '100%', height, position: 'relative' }}
|
||||
>
|
||||
<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 { formatLargeNumber } from '@gpustack/core-ui/utils';
|
||||
import { Empty, Spin, theme } from 'antd';
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react';
|
||||
|
||||
export interface PieChartItem {
|
||||
name: string;
|
||||
@@ -37,6 +43,26 @@ const PieChart: React.FC<PieChartProps> = ({
|
||||
const chartRef = useRef<{ chart: any } | null>(null);
|
||||
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 generatedColors = generateCoolColors(data.length + 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);
|
||||
|
||||
return (
|
||||
<div style={{ width, height, position: 'relative' }}>
|
||||
<div ref={measureRef} style={{ width, height, position: 'relative' }}>
|
||||
<Chart
|
||||
ref={chartRef as any}
|
||||
options={options as any}
|
||||
height={height}
|
||||
width={width}
|
||||
width={measuredWidth || width}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -251,7 +251,7 @@ const BackendList = () => {
|
||||
selectHolder={intl.formatMessage({ id: 'backend.filter.source' })}
|
||||
buttonText={intl.formatMessage({ id: 'backend.button.add' })}
|
||||
handleClickPrimary={handleAddBackend}
|
||||
handleSearch={handleSearch}
|
||||
handleSearch={() => loadMore(1)}
|
||||
handleSelectChange={handleFilterBySource}
|
||||
handleInputChange={handleNameChange}
|
||||
rowSelection={rowSelection}
|
||||
|
||||
@@ -17,6 +17,9 @@ const ViewLogsModal: React.FC<ViewModalProps> = (props) => {
|
||||
const logsViewerRef = React.useRef<any>(null);
|
||||
const requestRef = React.useRef<any>(null);
|
||||
const contentRef = React.useRef<any>(null);
|
||||
const [params, setParams] = React.useState<any>({
|
||||
follow: true
|
||||
});
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
logsViewerRef.current?.abort();
|
||||
@@ -94,9 +97,7 @@ const ViewLogsModal: React.FC<ViewModalProps> = (props) => {
|
||||
tail={undefined}
|
||||
enableScorllLoad={true}
|
||||
isDownloading={false}
|
||||
params={{
|
||||
follow: true
|
||||
}}
|
||||
params={params}
|
||||
></LogsViewer>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -59,7 +59,7 @@ const Billing: React.FC = () => {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ textAlign: 'center', marginTop: 56 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
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
|
||||
// is heading for (e.g. GPU Service's "Add a Kubernetes Cluster").
|
||||
providerHint?: string;
|
||||
presetClusterType?: 'model' | 'gpu';
|
||||
setCurrentTitle?: (title: string) => void;
|
||||
onClose?: () => void;
|
||||
}> = ({ onClose, action, providerHint, setCurrentTitle }) => {
|
||||
}> = ({
|
||||
onClose,
|
||||
action,
|
||||
providerHint,
|
||||
presetClusterType,
|
||||
setCurrentTitle
|
||||
}) => {
|
||||
const stepList = useStepList();
|
||||
const [systemConfigState] = useAtom(systemConfigAtom);
|
||||
const intl = useIntl();
|
||||
@@ -377,6 +384,7 @@ const ClusterCreate: React.FC<{
|
||||
)}
|
||||
<StepsContext.Provider
|
||||
value={{
|
||||
presetClusterType: presetClusterType,
|
||||
formValues: formValues,
|
||||
systemConfig: systemConfigState
|
||||
}}
|
||||
|
||||
@@ -6,10 +6,10 @@ import ClusterCreate from './cluster-create';
|
||||
interface ClusterModalProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
// When set, ClusterCreate preselects this provider and skips the
|
||||
// provider-catalog step. Used by feature pages (e.g. GPU Service)
|
||||
// whose empty state already implies which kind of cluster is needed.
|
||||
providerHint?: string;
|
||||
pendingProviderHint?: {
|
||||
providerHint?: string;
|
||||
presetClusterType?: 'model' | 'gpu';
|
||||
};
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ const ClusterModal: React.FC<ClusterModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
providerHint
|
||||
pendingProviderHint
|
||||
}) => {
|
||||
const [currentTitle, setCurrentTitle] = React.useState<string>(title);
|
||||
const handleCancel = () => {
|
||||
@@ -46,7 +46,8 @@ const ClusterModal: React.FC<ClusterModalProps> = ({
|
||||
<ClusterCreate
|
||||
onClose={handleCancel}
|
||||
action={PageAction.CREATE}
|
||||
providerHint={providerHint}
|
||||
providerHint={pendingProviderHint?.providerHint}
|
||||
presetClusterType={pendingProviderHint?.presetClusterType}
|
||||
setCurrentTitle={setCurrentTitle}
|
||||
></ClusterCreate>
|
||||
</GSDrawer>
|
||||
|
||||
@@ -321,13 +321,20 @@ const Clusters: React.FC = () => {
|
||||
// the session atom is cleared right after we open the modal, but
|
||||
// ClusterCreate mounts a tick later and needs the value to skip the
|
||||
// provider-catalog step. Cache it locally and clear on close.
|
||||
const [pendingProviderHint, setPendingProviderHint] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
const [pendingProviderHint, setPendingProviderHint] = useState<{
|
||||
providerHint?: string;
|
||||
presetClusterType?: 'model' | 'gpu';
|
||||
}>({
|
||||
providerHint: undefined,
|
||||
presetClusterType: undefined
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (clusterSession?.firstAddCluster && dataSource.loadend) {
|
||||
setPendingProviderHint(clusterSession.providerHint);
|
||||
setPendingProviderHint({
|
||||
providerHint: clusterSession.providerHint,
|
||||
presetClusterType: clusterSession.presetClusterType
|
||||
});
|
||||
openClusterModal();
|
||||
// reset session
|
||||
setClusterSession(null);
|
||||
@@ -335,7 +342,10 @@ const Clusters: React.FC = () => {
|
||||
}, [clusterSession, dataSource.loadend]);
|
||||
|
||||
const handleClusterModalClose = () => {
|
||||
setPendingProviderHint(undefined);
|
||||
setPendingProviderHint({
|
||||
providerHint: undefined,
|
||||
presetClusterType: undefined
|
||||
});
|
||||
closeClusterModal();
|
||||
};
|
||||
|
||||
@@ -464,7 +474,7 @@ const Clusters: React.FC = () => {
|
||||
id: 'menu.resources.clusterCreate'
|
||||
})}
|
||||
open={clusterModalStatus.open}
|
||||
providerHint={pendingProviderHint}
|
||||
pendingProviderHint={pendingProviderHint}
|
||||
onClose={handleClusterModalClose}
|
||||
></ClusterModal>
|
||||
{AddWorkerModal}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Form } from 'antd';
|
||||
import _ from 'lodash';
|
||||
import React, { useEffect, useId, useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { useStepsContext } from '../config/steps-context';
|
||||
import { ClusterListItem as ListItem } from '../config/types';
|
||||
import ImageCredential from './image-credential';
|
||||
import K8SVolumeMount from './k8s-volume-mount';
|
||||
@@ -175,6 +176,7 @@ const RadioDot = styled.span<{ $active: boolean }>`
|
||||
export const ClusterTypeSelector: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const form = Form.useFormInstance();
|
||||
const { presetClusterType } = useStepsContext();
|
||||
const labelId = useId();
|
||||
const gpuInstanceOptions = Form.useWatch(GPU_INSTANCE_OPTIONS_PATH, {
|
||||
form,
|
||||
@@ -211,6 +213,12 @@ export const ClusterTypeSelector: React.FC = () => {
|
||||
}
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (presetClusterType) {
|
||||
handleSelect(presetClusterType);
|
||||
}
|
||||
}, [presetClusterType]);
|
||||
|
||||
return (
|
||||
<ClusterTypeWrap>
|
||||
<ClusterTypeLabel id={labelId}>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export interface StepsContextProps {
|
||||
presetClusterType?: 'model' | 'gpu';
|
||||
formValues: Record<string, any>;
|
||||
systemConfig?: Record<string, any>;
|
||||
}
|
||||
|
||||
export const StepsContext = createContext<StepsContextProps>({
|
||||
formValues: {},
|
||||
systemConfig: {}
|
||||
systemConfig: {},
|
||||
presetClusterType: undefined
|
||||
});
|
||||
|
||||
export const useStepsContext = () => useContext(StepsContext);
|
||||
|
||||
@@ -14,7 +14,7 @@ export const useQueryClusterList = (options?: { useStateData?: boolean }) => {
|
||||
const { useStateData = true } = options || {};
|
||||
const axiosTokenRef = useRef<CancelTokenSource | null>(null);
|
||||
const [dataList, setDataList] = useState<
|
||||
Array<Partial<ClusterListItem> & { label: string; value: number }>
|
||||
Array<ClusterListItem & { label: string; value: number }>
|
||||
>([]);
|
||||
|
||||
const {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { PageAction } from '@/config';
|
||||
import { PageActionType } from '@/config/types';
|
||||
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 Separator from '@/pages/llmodels/components/separator';
|
||||
import { getGPUStackPlugin } from '@/plugins';
|
||||
@@ -13,7 +12,7 @@ import {
|
||||
ModalFooter
|
||||
} from '@gpustack/core-ui';
|
||||
import { useIntl, useModel } from '@umijs/max';
|
||||
import { Empty, Input, Typography } from 'antd';
|
||||
import { Input, Typography } from 'antd';
|
||||
import _ from 'lodash';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ListItem as TemplateItem } from '../../templates/config/types';
|
||||
@@ -31,6 +30,12 @@ type AddModalProps = {
|
||||
open: boolean;
|
||||
width?: number | string;
|
||||
realAction?: string;
|
||||
clusterList?: Array<{
|
||||
label: string;
|
||||
value: number;
|
||||
id: number;
|
||||
owner_principal_id?: number;
|
||||
}>;
|
||||
onOk: (values: FormData) => void;
|
||||
data?: ListItem | null;
|
||||
onCancel: () => void;
|
||||
@@ -78,6 +83,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
data,
|
||||
onCancel,
|
||||
width,
|
||||
clusterList = [],
|
||||
realAction
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
@@ -100,16 +106,18 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
const [instanceKeyword, setInstanceKeyword] = useState('');
|
||||
const [templateKeyword, setTemplateKeyword] = useState('');
|
||||
const { loading, guard, run, release } = useSubmitLock();
|
||||
const initializedRef = useRef(false);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
const {
|
||||
detailData: instanceTypeList,
|
||||
loading: instanceTypesLoading,
|
||||
fetchData
|
||||
} = useQueryInstanceTypes();
|
||||
const { detailData: templatesData, fetchData: fetchTemplates } =
|
||||
useQueryTemplates();
|
||||
const { clusterList, fetchClusterList } = useQueryClusterList();
|
||||
const {
|
||||
detailData: templatesData,
|
||||
loading: templateLoading,
|
||||
fetchData: fetchTemplates
|
||||
} = useQueryTemplates();
|
||||
// Set by the create-scope picker (admin "All" view) via onScopeChange.
|
||||
// undefined = no picker (org context) → no client-side scoping.
|
||||
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.
|
||||
const filterTypesByOwner = (
|
||||
types: InstanceTypeItem[],
|
||||
clusters: Array<{
|
||||
id?: number;
|
||||
value?: number;
|
||||
owner_principal_id?: number;
|
||||
}>,
|
||||
orgId?: number | null
|
||||
): InstanceTypeItem[] => {
|
||||
if (orgId == null) return types;
|
||||
const owned = new Set(
|
||||
(clusters || [])
|
||||
(clusterList || [])
|
||||
.filter((c) => c.owner_principal_id === orgId)
|
||||
.map((c) => c.id ?? c.value)
|
||||
.map((c) => c.id || c.value)
|
||||
);
|
||||
return types
|
||||
.map((it) => ({
|
||||
@@ -157,7 +160,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
};
|
||||
|
||||
const ownedInstanceTypes = useMemo(
|
||||
() => filterTypesByOwner(instanceTypeList, clusterList as any, scopeOrgId),
|
||||
() => filterTypesByOwner(instanceTypeList, scopeOrgId),
|
||||
|
||||
[instanceTypeList, clusterList, scopeOrgId]
|
||||
);
|
||||
@@ -279,11 +282,6 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
const applyAutoSelection = (
|
||||
instanceTypes: InstanceTypeItem[],
|
||||
templates: TemplateItem[],
|
||||
clusters?: Array<{
|
||||
id?: number;
|
||||
value?: number;
|
||||
owner_principal_id?: number;
|
||||
}>,
|
||||
orgId?: number | null
|
||||
) => {
|
||||
// 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).
|
||||
const owned = filterTypesByOwner(instanceTypes, clusters || [], orgId);
|
||||
const owned = filterTypesByOwner(instanceTypes, orgId);
|
||||
|
||||
// On create, auto-select the first available instance type (clears the
|
||||
// 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
|
||||
// this runs twice in quick succession (drawer open, then the scope
|
||||
// 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;
|
||||
try {
|
||||
Promise.all([
|
||||
const [instanceResItems, templatesRes] = await Promise.all([
|
||||
fetchData({ page: -1 }),
|
||||
fetchTemplates({ page: -1 }),
|
||||
fetchClusterList({ page: -1 })
|
||||
]).then(([instanceResItems, templatesRes, clusters]) => {
|
||||
if (sessionRef.current !== session) return;
|
||||
applyAutoSelection(
|
||||
instanceResItems || [],
|
||||
templatesRes?.items || [],
|
||||
(Array.isArray(clusters) ? clusters : (clusters as any)?.items) || [],
|
||||
orgId
|
||||
);
|
||||
initializedRef.current = true;
|
||||
});
|
||||
fetchTemplates({ page: -1 })
|
||||
]);
|
||||
if (sessionRef.current !== session) return;
|
||||
applyAutoSelection(
|
||||
instanceResItems || [],
|
||||
templatesRes?.items || [],
|
||||
orgId
|
||||
);
|
||||
setInitialized(true);
|
||||
} catch (error) {
|
||||
} finally {
|
||||
initializedRef.current = true;
|
||||
setInitialized(true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -352,13 +346,13 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
// owner-scoped auto-selection re-fills them from the new org, or leaves
|
||||
// them empty (blocking submit) when the chosen org has no clusters.
|
||||
clearSelection();
|
||||
initializedRef.current = false;
|
||||
setInitialized(false);
|
||||
loadCreateResources(orgId);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
initializedRef.current = false;
|
||||
setInitialized(false);
|
||||
sessionRef.current += 1;
|
||||
setInstanceTypeSelection({
|
||||
instanceType: undefined,
|
||||
@@ -619,15 +613,12 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
onChange={(e) => setTemplateKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{filteredTemplates.length > 0 && initializedRef.current ? (
|
||||
<TemplateSelector
|
||||
value={templateId}
|
||||
groups={templateGroups}
|
||||
onChange={handleTemplateChange}
|
||||
/>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
<TemplateSelector
|
||||
value={templateId}
|
||||
loading={templateLoading || !initialized}
|
||||
groups={templateGroups}
|
||||
onChange={handleTemplateChange}
|
||||
/>
|
||||
</div>
|
||||
</ColumnWrapper>
|
||||
<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 { Empty, Spin } from 'antd';
|
||||
import { Empty, Flex, Spin } from 'antd';
|
||||
import _ from 'lodash';
|
||||
import styled from 'styled-components';
|
||||
import { InstanceTypeItem as InstanceTypeItemModel } from '../config/types';
|
||||
import InstanceTypeItem from './instance-type-item';
|
||||
|
||||
const TypeGrid = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
interface InstanceTypeListProps {
|
||||
value?: string;
|
||||
onChange?: (item: InstanceTypeItemModel) => void;
|
||||
@@ -34,11 +27,11 @@ const InstanceTypeList: React.FC<InstanceTypeListProps> = ({
|
||||
if (loading) {
|
||||
return (
|
||||
<Spin spinning size="middle">
|
||||
<TypeGrid style={{ minHeight: 200 }}>
|
||||
<Flex orientation="vertical" gap={16} style={{ minHeight: 200 }}>
|
||||
{_.times(6, (index: number) => (
|
||||
<FileSkeleton key={index} counts={3} itemHeight={106} />
|
||||
<FileSkeletonRows key={index} counts={2} itemHeight={106} />
|
||||
))}
|
||||
</TypeGrid>
|
||||
</Flex>
|
||||
</Spin>
|
||||
);
|
||||
}
|
||||
@@ -46,7 +39,7 @@ const InstanceTypeList: React.FC<InstanceTypeListProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<TypeGrid>
|
||||
<Flex orientation="vertical" gap={16}>
|
||||
{dataList.map((item) => {
|
||||
const name = item.name;
|
||||
return (
|
||||
@@ -64,7 +57,7 @@ const InstanceTypeList: React.FC<InstanceTypeListProps> = ({
|
||||
</TemplateCard>
|
||||
);
|
||||
})}
|
||||
</TypeGrid>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -70,9 +70,9 @@ export const status: Record<string, StatusType> = {
|
||||
[InstanceStatusValueMap.NotReady]: StatusMaps.error,
|
||||
[InstanceStatusValueMap.Ready]: StatusMaps.success,
|
||||
[InstanceStatusValueMap.Starting]: StatusMaps.transitioning,
|
||||
[InstanceStatusValueMap.Deleting]: StatusMaps.error,
|
||||
[InstanceStatusValueMap.Stopping]: StatusMaps.error,
|
||||
[InstanceStatusValueMap.Stopped]: StatusMaps.warning,
|
||||
[InstanceStatusValueMap.Deleting]: StatusMaps.warning,
|
||||
[InstanceStatusValueMap.Stopping]: StatusMaps.transitioning,
|
||||
[InstanceStatusValueMap.Stopped]: StatusMaps.inactive,
|
||||
[InstanceStatusValueMap.CreateFailed]: StatusMaps.error,
|
||||
[InstanceStatusValueMap.SSHPublicKeyCreateFailed]: 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
|
||||
// so the parent can re-scope offerings (see onScopeChange).
|
||||
const scopeOrgId = Form.useWatch('organization_id', form);
|
||||
const ports = Form.useWatch(['spec', 'ports'], form) || [];
|
||||
const scopeInitRef = useRef(true);
|
||||
// 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).
|
||||
@@ -151,17 +152,31 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
||||
: [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 spec = parseJsonSafe(description || '{}', {} as any)?.spec;
|
||||
console.log('derived spec from description', spec);
|
||||
return spec?.acceleratable;
|
||||
}, [description]);
|
||||
|
||||
useEffect(() => {
|
||||
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
|
||||
// parent whenever the chosen create scope changes so it can reload
|
||||
@@ -174,16 +189,6 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
||||
onScopeChangeRef.current?.(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(() => {
|
||||
if (
|
||||
hasSSHPort &&
|
||||
@@ -554,7 +559,13 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
||||
image: '',
|
||||
imagePullPolicy: DefaultImagePullPolicy,
|
||||
command: [],
|
||||
ports: [],
|
||||
ports: [
|
||||
{
|
||||
protocol: 'TCP',
|
||||
port: SSH_PORT,
|
||||
name: 'SSH'
|
||||
}
|
||||
],
|
||||
env: [],
|
||||
volumeMount: '',
|
||||
resources: {
|
||||
|
||||
@@ -36,7 +36,13 @@ const StorageVolume = ({
|
||||
const [overlayOpen, setOverlayOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStorage({ page: 1, perPage: 100 });
|
||||
const initStorage = async () => {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 200);
|
||||
});
|
||||
fetchStorage({ page: -1 });
|
||||
};
|
||||
initStorage();
|
||||
}, []);
|
||||
|
||||
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 { useIntl } from '@umijs/max';
|
||||
import { Empty, Flex, Spin } from 'antd';
|
||||
import _ from 'lodash';
|
||||
import { Fragment } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { ListItem as TemplateItem } from '../../templates/config/types';
|
||||
|
||||
const TemplateGrid = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
const GroupTitle = styled.div`
|
||||
font-size: 12px;
|
||||
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 {
|
||||
key: string;
|
||||
label: React.ReactNode;
|
||||
@@ -57,6 +60,7 @@ export interface TemplateGroup {
|
||||
|
||||
interface TemplateSelectorProps {
|
||||
value?: number;
|
||||
loading?: boolean;
|
||||
onChange?: (value: number, item: TemplateItem) => void;
|
||||
groups?: TemplateGroup[];
|
||||
}
|
||||
@@ -64,6 +68,7 @@ interface TemplateSelectorProps {
|
||||
const TemplateSelector: React.FC<TemplateSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
loading,
|
||||
groups = []
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
@@ -73,6 +78,22 @@ const TemplateSelector: React.FC<TemplateSelectorProps> = ({
|
||||
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) => (
|
||||
<TemplateCard
|
||||
key={item.id}
|
||||
@@ -120,14 +141,14 @@ const TemplateSelector: React.FC<TemplateSelectorProps> = ({
|
||||
const showGroupTitles = groups.length > 1;
|
||||
|
||||
return (
|
||||
<TemplateGrid>
|
||||
<Flex orientation="vertical" gap={16}>
|
||||
{groups.map((group) => (
|
||||
<Fragment key={group.key}>
|
||||
{showGroupTitles && <GroupTitle>{group.label}</GroupTitle>}
|
||||
{group.items.map(renderItem)}
|
||||
</Fragment>
|
||||
))}
|
||||
</TemplateGrid>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ const GPUService: React.FC = () => {
|
||||
setClusterSession({
|
||||
firstAddWorker: false,
|
||||
firstAddCluster: true,
|
||||
presetClusterType: 'gpu',
|
||||
providerHint: ProviderValueMap.Kubernetes
|
||||
});
|
||||
navigate('/resources/clusters/list');
|
||||
@@ -111,7 +112,7 @@ const GPUService: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
fetchClusterList({ page: -1 });
|
||||
(async () => {
|
||||
const fetchPVCapacities = async () => {
|
||||
try {
|
||||
const res = await queryGPUServiceStorage({ page: -1 } as any);
|
||||
const map: Record<string, string> = {};
|
||||
@@ -124,7 +125,8 @@ const GPUService: React.FC = () => {
|
||||
} catch {
|
||||
// best-effort; the popover falls back to the PV name
|
||||
}
|
||||
})();
|
||||
};
|
||||
fetchPVCapacities();
|
||||
}, []);
|
||||
|
||||
const hasK8sCluster = useMemo(
|
||||
@@ -387,6 +389,7 @@ const GPUService: React.FC = () => {
|
||||
data={openInstanceModalStatus.currentData}
|
||||
width={openInstanceModalStatus.width}
|
||||
realAction={openInstanceModalStatus.realAction}
|
||||
clusterList={clusterList}
|
||||
onCancel={closeInstanceModal}
|
||||
onOk={handleModalOk}
|
||||
/>
|
||||
|
||||
@@ -85,6 +85,9 @@ const formatResources = (
|
||||
};
|
||||
};
|
||||
|
||||
// Spec popover categories, in render order.
|
||||
type SpecCategory = 'gpu' | 'cpu' | 'ram' | 'disk';
|
||||
|
||||
export const renderInstanceType = (
|
||||
record: ListItem,
|
||||
options: {
|
||||
@@ -92,22 +95,31 @@ export const renderInstanceType = (
|
||||
// name → capacity (e.g. "20Gi") for referenced persistent volumes, so the
|
||||
// Disk → Persistent row can show the size instead of just the PV name.
|
||||
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 =
|
||||
parseJsonSafe<any>(record?.description || '{}', {}).spec || {};
|
||||
const resources = formatResources({ spec: description }, record);
|
||||
const accelerator = record.spec?.resources?.accelerator;
|
||||
const title = description.acceleratable
|
||||
? `${description.product} x ${accelerator}`
|
||||
: 'CPU Only';
|
||||
const title =
|
||||
options.title ??
|
||||
(description.acceleratable
|
||||
? `${description.product} x ${accelerator}`
|
||||
: 'CPU Only');
|
||||
|
||||
const volume = (record.spec as any)?.volume;
|
||||
// Spec popover grouped by category (GPU / CPU / Memory / Disk), mirroring
|
||||
// the Deployments instance info icon: dark tooltip, per-category icon,
|
||||
// instance name as the title. Rows with no value are dropped.
|
||||
type Section = {
|
||||
key: SpecCategory;
|
||||
icon: string;
|
||||
name: string;
|
||||
rows: [string | null, string | undefined][];
|
||||
@@ -115,6 +127,7 @@ export const renderInstanceType = (
|
||||
const sections: Section[] = [];
|
||||
if (description.acceleratable) {
|
||||
sections.push({
|
||||
key: 'gpu',
|
||||
icon: 'icon-gpu',
|
||||
name: 'GPU',
|
||||
rows: [
|
||||
@@ -134,16 +147,19 @@ export const renderInstanceType = (
|
||||
});
|
||||
}
|
||||
sections.push({
|
||||
key: 'cpu',
|
||||
icon: 'icon-cpu',
|
||||
name: 'CPU',
|
||||
rows: [[null, resources.cpu]]
|
||||
});
|
||||
sections.push({
|
||||
key: 'ram',
|
||||
icon: 'icon-ram-02',
|
||||
name: intl.formatMessage({ id: 'gpuservice.instance.ram' }),
|
||||
rows: [[null, resources.ram]]
|
||||
});
|
||||
sections.push({
|
||||
key: 'disk',
|
||||
icon: 'icon-hard-disk',
|
||||
name: intl.formatMessage({ id: 'gpuservice.instance.disk' }),
|
||||
rows: [
|
||||
@@ -170,8 +186,16 @@ export const renderInstanceType = (
|
||||
]
|
||||
});
|
||||
|
||||
const visibleSections = categories
|
||||
? sections.filter((s) => categories.includes(s.key))
|
||||
: sections;
|
||||
|
||||
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 intl = useIntl();
|
||||
const { getRuleMessage } = useAppUtils();
|
||||
const disabled = action === PageAction.EDIT;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -24,6 +25,7 @@ const NFSForm = ({ action }: { action: string }) => {
|
||||
>
|
||||
<CInput.Input
|
||||
required
|
||||
disabled={disabled}
|
||||
description={intl.formatMessage({
|
||||
id: 'gpuservice.storageType.nfs.server.tips'
|
||||
})}
|
||||
@@ -43,6 +45,7 @@ const NFSForm = ({ action }: { action: string }) => {
|
||||
>
|
||||
<CInput.Input
|
||||
required
|
||||
disabled={disabled}
|
||||
description={intl.formatMessage({
|
||||
id: 'gpuservice.storageType.nfs.share.tips'
|
||||
})}
|
||||
@@ -51,6 +54,7 @@ const NFSForm = ({ action }: { action: string }) => {
|
||||
</Form.Item>
|
||||
<Form.Item<FormData> name={['spec', 'nfs', 'subDirectory']}>
|
||||
<CInput.Input
|
||||
disabled={disabled}
|
||||
label={intl.formatMessage({
|
||||
id: 'gpuservice.storageType.nfs.subDirectory'
|
||||
})}
|
||||
@@ -58,6 +62,7 @@ const NFSForm = ({ action }: { action: string }) => {
|
||||
</Form.Item>
|
||||
<Form.Item<FormData> name={['spec', 'nfs', 'mountPermissions']}>
|
||||
<CInput.Input
|
||||
disabled={disabled}
|
||||
placeholder="0755,0777,..."
|
||||
description={intl.formatMessage({
|
||||
id: 'gpuservice.storageType.nfs.mountPermissions.tips'
|
||||
@@ -69,7 +74,7 @@ const NFSForm = ({ action }: { action: string }) => {
|
||||
</Form.Item>
|
||||
<Form.Item<FormData> name={['spec', 'nfs', 'mountOptions']}>
|
||||
<ListInput
|
||||
disabled={action === PageAction.EDIT}
|
||||
disabled={disabled}
|
||||
label={intl.formatMessage({
|
||||
id: 'gpuservice.storageType.mountOptions'
|
||||
})}
|
||||
|
||||
@@ -14,6 +14,7 @@ const S3Form = ({ action }: { action: string }) => {
|
||||
const intl = useIntl();
|
||||
const form = Form.useFormInstance<FormData>();
|
||||
const { getRuleMessage } = useAppUtils();
|
||||
const disabled = action === PageAction.EDIT;
|
||||
|
||||
const handleEndpointBlur = (e: any) => {
|
||||
const value: string = e.target.value;
|
||||
@@ -51,6 +52,7 @@ const S3Form = ({ action }: { action: string }) => {
|
||||
>
|
||||
<CInput.Input
|
||||
required
|
||||
disabled={disabled}
|
||||
label={intl.formatMessage({
|
||||
id: 'gpuservice.storageType.s3.endpoint'
|
||||
})}
|
||||
@@ -65,6 +67,7 @@ const S3Form = ({ action }: { action: string }) => {
|
||||
<div style={{ flex: 1 }}>
|
||||
<Form.Item<FormData> name={['spec', 's3', 'region']}>
|
||||
<CInput.Input
|
||||
disabled={disabled}
|
||||
label={intl.formatMessage({
|
||||
id: 'gpuservice.storageType.s3.region'
|
||||
})}
|
||||
@@ -86,6 +89,7 @@ const S3Form = ({ action }: { action: string }) => {
|
||||
>
|
||||
<CInput.Input
|
||||
required
|
||||
disabled={disabled}
|
||||
description={
|
||||
<Flex orientation="vertical" gap={4} align="start">
|
||||
<span>
|
||||
@@ -111,6 +115,7 @@ const S3Form = ({ action }: { action: string }) => {
|
||||
</Flex>
|
||||
<Form.Item<FormData> name={['spec', 's3', 'accessKey']}>
|
||||
<CInput.Input
|
||||
disabled={disabled}
|
||||
label={intl.formatMessage({
|
||||
id: 'gpuservice.storageType.s3.accessKey'
|
||||
})}
|
||||
@@ -118,6 +123,7 @@ const S3Form = ({ action }: { action: string }) => {
|
||||
</Form.Item>
|
||||
<Form.Item<FormData> name={['spec', 's3', 'secretKey']}>
|
||||
<CInput.Password
|
||||
disabled={disabled}
|
||||
label={intl.formatMessage({
|
||||
id: 'gpuservice.storageType.s3.secretKey'
|
||||
})}
|
||||
@@ -129,6 +135,7 @@ const S3Form = ({ action }: { action: string }) => {
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
<CheckboxField
|
||||
disabled={disabled}
|
||||
description={intl.formatMessage({
|
||||
id: 'gpuservice.storageType.s3.insecure.tips'
|
||||
})}
|
||||
@@ -139,7 +146,7 @@ const S3Form = ({ action }: { action: string }) => {
|
||||
</Form.Item>
|
||||
<Form.Item<FormData> name={['spec', 's3', 'mountOptions']}>
|
||||
<ListInput
|
||||
disabled={action === PageAction.EDIT}
|
||||
disabled={disabled}
|
||||
label={intl.formatMessage({
|
||||
id: 'gpuservice.storageType.mountOptions'
|
||||
})}
|
||||
|
||||
@@ -36,7 +36,6 @@ import { ListItem } from '../config/types';
|
||||
const imageLogoMap = {
|
||||
vllm: vllmLogo,
|
||||
sglang: sgLangLogo,
|
||||
cann: ascendLogo,
|
||||
jupyter: jupyterLogo,
|
||||
pytorch: pytorchBlackLogo,
|
||||
tensorflow: tensorflowkLogo,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import PluginExtraFields from '@/components/plugin-extra-fields';
|
||||
import { validateLabelNameRegxFor63 } from '@/config';
|
||||
import { PageAction, validateLabelNameRegxFor63 } from '@/config';
|
||||
import { PageActionType } from '@/config/types';
|
||||
import {
|
||||
Input as CInput,
|
||||
@@ -86,7 +86,7 @@ const Basic: React.FC<BasicProps> = ({
|
||||
]}
|
||||
>
|
||||
<CInput.Input
|
||||
disabled={disabled}
|
||||
disabled={disabled || action === PageAction.EDIT}
|
||||
label={intl.formatMessage({ id: 'common.table.name' })}
|
||||
required
|
||||
/>
|
||||
|
||||
@@ -28,6 +28,7 @@ const Catalog: React.FC = () => {
|
||||
dataSource,
|
||||
queryParams,
|
||||
handleSearch,
|
||||
fetchData,
|
||||
handleQueryChange,
|
||||
loadMore,
|
||||
handleNameChange
|
||||
@@ -137,7 +138,7 @@ const Catalog: React.FC = () => {
|
||||
marginBottom={22}
|
||||
marginTop={0}
|
||||
buttonText={intl.formatMessage({ id: 'models.catalog.button.explore' })}
|
||||
handleSearch={handleSearch}
|
||||
handleSearch={() => loadMore(1)}
|
||||
handleSelectChange={handleCategoryChange}
|
||||
handleClickPrimary={handleDeployFromOtherHubs}
|
||||
handleInputChange={handleNameChange}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
ScrollerModal
|
||||
} from '@gpustack/core-ui';
|
||||
import { useIntl, useNavigate } from '@umijs/max';
|
||||
import { Button, Tag } from 'antd';
|
||||
import { Button, Flex, Tag } from 'antd';
|
||||
import _ from 'lodash';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
@@ -79,6 +79,7 @@ const Tips = styled.div`
|
||||
const APITAG = styled(Tag)`
|
||||
border-radius: 12px;
|
||||
margin: 0;
|
||||
width: fit-content;
|
||||
margin-left: 8px;
|
||||
`;
|
||||
|
||||
@@ -149,7 +150,7 @@ const ApiAccessInfo = ({ open, data, onClose }: ApiAccessInfoProps) => {
|
||||
{intl.formatMessage({ id: 'models.table.button.apiAccessInfo' })}
|
||||
</span>
|
||||
}
|
||||
width={600}
|
||||
width={660}
|
||||
destroyOnHidden
|
||||
closable={true}
|
||||
mask={{
|
||||
@@ -164,10 +165,15 @@ const ApiAccessInfo = ({ open, data, onClose }: ApiAccessInfoProps) => {
|
||||
<span className="label">
|
||||
{intl.formatMessage({ id: 'models.table.apiAccessInfo.endpoint' })}
|
||||
</span>
|
||||
<span className="value">
|
||||
<AutoTooltip ghost maxWidth={data.generic_proxy ? 400 : 240}>
|
||||
{endPoint}
|
||||
</AutoTooltip>
|
||||
<Flex align="center" wrap>
|
||||
<span
|
||||
className="value"
|
||||
style={{ display: 'flex', flex: 1, minWidth: 0 }}
|
||||
>
|
||||
<AutoTooltip ghost maxWidth={'100%'}>
|
||||
{endPoint}
|
||||
</AutoTooltip>
|
||||
</span>
|
||||
{!data.generic_proxy && (
|
||||
<>
|
||||
<APITAG color="geekblue">
|
||||
@@ -188,7 +194,7 @@ const ApiAccessInfo = ({ open, data, onClose }: ApiAccessInfoProps) => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</Flex>
|
||||
<span className="copy-btn">
|
||||
<CopyButton text={endPoint} type="link" size="small"></CopyButton>
|
||||
</span>
|
||||
@@ -197,32 +203,36 @@ const ApiAccessInfo = ({ open, data, onClose }: ApiAccessInfoProps) => {
|
||||
id: 'models.table.apiAccessInfo.modelName'
|
||||
})}
|
||||
</span>
|
||||
<span className="value">
|
||||
<AutoTooltip ghost maxWidth={300}>
|
||||
{data.name}
|
||||
</AutoTooltip>
|
||||
</span>
|
||||
<Flex align="center">
|
||||
<span className="value">
|
||||
<AutoTooltip ghost maxWidth={300}>
|
||||
{data.name}
|
||||
</AutoTooltip>
|
||||
</span>
|
||||
</Flex>
|
||||
<span className="copy-btn">
|
||||
<CopyButton text={data.name} type="link" size="small"></CopyButton>
|
||||
</span>
|
||||
<span className="label">
|
||||
{intl.formatMessage({ id: 'models.table.apiAccessInfo.apikey' })}
|
||||
</span>
|
||||
<span className="value">
|
||||
<CreateButton
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => navigate('/access-control/api-keys')}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
id: 'models.table.apiAccessInfo.gotoCreate'
|
||||
})}
|
||||
<IconFont
|
||||
type="icon-external-link"
|
||||
className="font-size-14"
|
||||
></IconFont>
|
||||
</CreateButton>
|
||||
</span>
|
||||
<Flex align="center">
|
||||
<span className="value">
|
||||
<CreateButton
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => navigate('/access-control/api-keys')}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
id: 'models.table.apiAccessInfo.gotoCreate'
|
||||
})}
|
||||
<IconFont
|
||||
type="icon-external-link"
|
||||
className="font-size-14"
|
||||
></IconFont>
|
||||
</CreateButton>
|
||||
</span>
|
||||
</Flex>
|
||||
</ApiAccessInfoWrapper>
|
||||
{data.generic_proxy && (
|
||||
<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;
|
||||
|
||||
@@ -55,6 +55,12 @@ const ViewLogsModal: React.FC<ViewModalProps> = (props) => {
|
||||
);
|
||||
}, [countOptions, showPrevious]);
|
||||
|
||||
const hasPreviousContainer = useMemo(() => {
|
||||
return countOptions?.some((option) =>
|
||||
option.children?.some((child) => child.previous)
|
||||
);
|
||||
}, [countOptions]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
logsViewerRef.current?.abort();
|
||||
onCancel();
|
||||
@@ -114,7 +120,7 @@ const ViewLogsModal: React.FC<ViewModalProps> = (props) => {
|
||||
watch: !option.previous,
|
||||
previous: option.previous,
|
||||
worker_id: option.worker_id,
|
||||
container: option.container
|
||||
container_name: option.container
|
||||
});
|
||||
}
|
||||
setSelectedContainer(option?.parentValue || null);
|
||||
@@ -186,15 +192,15 @@ const ViewLogsModal: React.FC<ViewModalProps> = (props) => {
|
||||
{intl.formatMessage({ id: 'common.button.viewlog' })}
|
||||
</span>
|
||||
<span className="flex-center gap-8" style={{ height: 32 }}>
|
||||
{showCascader && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
marginRight: 8
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
marginRight: 8
|
||||
}}
|
||||
>
|
||||
{hasPreviousContainer && (
|
||||
<Checkbox onChange={handleOnChecked} checked={showPrevious}>
|
||||
<Tooltip
|
||||
title={
|
||||
@@ -219,6 +225,9 @@ const ViewLogsModal: React.FC<ViewModalProps> = (props) => {
|
||||
<QuestionCircleOutlined style={{ marginLeft: 4 }} />
|
||||
</Tooltip>
|
||||
</Checkbox>
|
||||
)}
|
||||
|
||||
{showCascader && (
|
||||
<BaseSelect
|
||||
prefix={
|
||||
<span
|
||||
@@ -240,9 +249,8 @@ const ViewLogsModal: React.FC<ViewModalProps> = (props) => {
|
||||
onChange={handleContainerChange}
|
||||
style={{ width: 360 }}
|
||||
></BaseSelect>
|
||||
</div>
|
||||
)}
|
||||
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="text"
|
||||
color="default"
|
||||
|
||||
@@ -455,7 +455,7 @@ export const getBackendParamsTips = (backend: string) => {
|
||||
return {
|
||||
backend: 'SGLang',
|
||||
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'
|
||||
};
|
||||
}
|
||||
|
||||
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({
|
||||
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) => {
|
||||
@@ -456,7 +471,7 @@ const Models = forwardRef((props, ref) => {
|
||||
handleOnToggleExpandAll={createModelsInstanceChunkRequest}
|
||||
onViewLogs={cancelRequestsOnPageInactive}
|
||||
onCancelViewLogs={handleOnCancelViewLogs}
|
||||
onStop={handleSearchBySilent}
|
||||
onStop={handleStop}
|
||||
onStart={handleSearchBySilent}
|
||||
onTableSort={handleOnSortChange}
|
||||
onFilterChange={handleOnFilterChange}
|
||||
|
||||
@@ -29,21 +29,12 @@ const useStyles = createStyles(({ token, css }) => {
|
||||
}
|
||||
}
|
||||
`,
|
||||
password: css`
|
||||
.ant-input-suffix {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
.reset-link {
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
color: ${token.colorTextSecondary};
|
||||
}
|
||||
.ant-divider {
|
||||
margin-left: 2px;
|
||||
}
|
||||
.reset-link:hover {
|
||||
color: var(--ant-color-link-hover);
|
||||
}
|
||||
passwordWrapper: css`
|
||||
position: relative;
|
||||
.forgot-password {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 74px;
|
||||
}
|
||||
`
|
||||
};
|
||||
@@ -97,6 +88,12 @@ const LocalUserForm: React.FC<LocalUserFormProps> = (props) => {
|
||||
// enterprise cover layout (272px inner).
|
||||
layout="vertical"
|
||||
requiredMark={false}
|
||||
styles={{
|
||||
label: {
|
||||
width: '100%',
|
||||
lineHeight: '24px'
|
||||
}
|
||||
}}
|
||||
style={{ width: '360px', maxWidth: '100%', margin: '0 auto' }}
|
||||
onFinish={handleLogin}
|
||||
>
|
||||
@@ -122,54 +119,65 @@ const LocalUserForm: React.FC<LocalUserFormProps> = (props) => {
|
||||
autoComplete="username"
|
||||
prefix={<UserOutlined />}
|
||||
style={{ height: 44 }}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'common.login.username.holder'
|
||||
})}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
style={{
|
||||
marginBottom: 20
|
||||
}}
|
||||
name="password"
|
||||
label={intl.formatMessage({ id: 'common.form.password' })}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.formatMessage(
|
||||
{ id: 'common.form.rule.input' },
|
||||
{
|
||||
name: intl.formatMessage({ id: 'common.form.password' })
|
||||
}
|
||||
)
|
||||
}
|
||||
]}
|
||||
>
|
||||
<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" />
|
||||
<div className={`${styles.passwordWrapper} password-wrapper`}>
|
||||
<Form.Item
|
||||
style={{
|
||||
marginBottom: 20
|
||||
}}
|
||||
name="password"
|
||||
label={
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<span>{intl.formatMessage({ id: 'common.form.password' })}</span>
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
rules={[
|
||||
{
|
||||
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
|
||||
htmlType="submit"
|
||||
type="primary"
|
||||
block
|
||||
loading={props.loading}
|
||||
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' })}
|
||||
</Button>
|
||||
|
||||
@@ -139,7 +139,7 @@ const PasswordForm: React.FC = () => {
|
||||
htmlType="submit"
|
||||
type="primary"
|
||||
block
|
||||
style={{ height: '44px', fontSize: '14px', marginTop: 16 }}
|
||||
style={{ height: '44px', fontSize: '14px', marginTop: 8 }}
|
||||
>
|
||||
{intl.formatMessage({ id: 'common.button.submit' })}
|
||||
</Button>
|
||||
|
||||
@@ -74,6 +74,10 @@ const Box = styled.div`
|
||||
min-height: 100vh;
|
||||
.ant-form-item-label {
|
||||
line-height: 1;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.ant-form-item-label > label:after {
|
||||
display: none;
|
||||
}
|
||||
.ant-form-item-label > label {
|
||||
height: auto !important;
|
||||
|
||||
@@ -3,6 +3,7 @@ import ai360 from '@/assets/providers-logo/ai360.svg';
|
||||
import azure from '@/assets/providers-logo/azureai.svg';
|
||||
import baichuan from '@/assets/providers-logo/baichuan.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 claude from '@/assets/providers-logo/claude.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 openai from '@/assets/providers-logo/openai.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 stepfun from '@/assets/providers-logo/stepfun.svg';
|
||||
import togetherai from '@/assets/providers-logo/together.svg';
|
||||
|
||||
@@ -70,12 +70,17 @@ export const ProviderI18nKeyMap: Record<ProviderEnum, string> = {
|
||||
[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}[]
|
||||
export const maasProviderOptions = Object.entries(ProviderEnum).map(
|
||||
([key, value]) => ({
|
||||
locale: true,
|
||||
label: ProviderI18nKeyMap[value],
|
||||
value: value,
|
||||
description: ProviderDescriptionMap[value] || '',
|
||||
key: value
|
||||
})
|
||||
);
|
||||
|
||||
@@ -12,7 +12,9 @@ import ProviderLogo from '../components/provider-logo';
|
||||
import { useFormContext } from '../config/form-context';
|
||||
import { maasProviderOptions } from '../config/providers';
|
||||
import { FormData } from '../config/types';
|
||||
import providerTypeStyles from '../styles/provider-type.less';
|
||||
import ProviderConfigs from './provider-configs';
|
||||
|
||||
const Basic: React.FC<{
|
||||
onAPIKeyBlur?: (e: any) => void;
|
||||
}> = ({ onAPIKeyBlur }) => {
|
||||
@@ -32,12 +34,28 @@ const Basic: React.FC<{
|
||||
};
|
||||
|
||||
const filterOption = (input: string, option?: any) => {
|
||||
console.log('filterOption input, option: ', input, option);
|
||||
return (
|
||||
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 (
|
||||
<>
|
||||
<Form.Item<FormData>
|
||||
@@ -73,9 +91,10 @@ const Basic: React.FC<{
|
||||
filterOption: filterOption
|
||||
}}
|
||||
required
|
||||
className={providerTypeStyles.providerType}
|
||||
prefix={renderLogoPrefix()}
|
||||
options={maasProviderOptions}
|
||||
optionRender={optionRender}
|
||||
labelRender={optionRender}
|
||||
label={intl.formatMessage({
|
||||
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 { getGPUStackPlugin } from '@/plugins';
|
||||
import { useNavigate } from '@umijs/max';
|
||||
import { modelCategoriesMap } from '../../llmodels/config';
|
||||
import { categoryToPathMap } from '../../llmodels/config/button-actions';
|
||||
|
||||
const useOpenPlayground = () => {
|
||||
const navigate = useNavigate();
|
||||
const plugin = getGPUStackPlugin();
|
||||
|
||||
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();
|
||||
|
||||
// The platform Org is always named ``default`` (backend constant
|
||||
// ``PLATFORM_PRINCIPAL_NAME``); its models are reported by
|
||||
// ``/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
|
||||
// caller is currently acting under for the admin "All" view, where the
|
||||
// row's owner still resolves via the platform-wide org cache.
|
||||
|
||||
const rawModel = generateModelName(row);
|
||||
const modelName = encodeURIComponent(rawModel);
|
||||
|
||||
|
||||
@@ -392,7 +392,7 @@ export const CustomSizeConfig: ParamsSchema[] = [
|
||||
max: 1024,
|
||||
step: 64,
|
||||
labelWidth: 314,
|
||||
inputnumber: false
|
||||
inputnumber: true
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
@@ -413,7 +413,7 @@ export const CustomSizeConfig: ParamsSchema[] = [
|
||||
max: 1024,
|
||||
step: 64,
|
||||
labelWidth: 314,
|
||||
inputnumber: false
|
||||
inputnumber: true
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
|
||||
@@ -75,10 +75,32 @@ export const useInitImageMeta = (
|
||||
...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 { max_height, max_width } = metaData || {};
|
||||
const { max_height, max_width, default_width, default_height } =
|
||||
metaData || {};
|
||||
if (!max_height || !max_width) {
|
||||
return imageSizeList;
|
||||
const newImageSizeOptions = [...imageSizeList];
|
||||
ensureSizeOption(newImageSizeOptions, default_width, default_height);
|
||||
return newImageSizeOptions;
|
||||
}
|
||||
const newImageSizeOptions = imageSizeList.filter((item) => {
|
||||
return (
|
||||
@@ -86,18 +108,8 @@ export const useInitImageMeta = (
|
||||
item.value === 'custom'
|
||||
);
|
||||
});
|
||||
if (
|
||||
!newImageSizeOptions.find(
|
||||
(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}`
|
||||
});
|
||||
}
|
||||
ensureSizeOption(newImageSizeOptions, max_width, max_height);
|
||||
ensureSizeOption(newImageSizeOptions, default_width, default_height);
|
||||
return newImageSizeOptions;
|
||||
};
|
||||
|
||||
|
||||
@@ -437,8 +437,5 @@ export const AddWorkerDockerNotes: Record<string, string[]> = {
|
||||
[GPUDriverMap.ILUVATAR]: ['clusters.addworker.corexNotes'],
|
||||
[GPUDriverMap.CAMBRICON]: ['clusters.addworker.cambriconNotes'],
|
||||
[GPUDriverMap.METAX]: ['clusters.addworker.metaxNotes'],
|
||||
[GPUDriverMap.THEAD]: [
|
||||
'clusters.addworker.theadNotes',
|
||||
'clusters.addworker.theadNotes-02'
|
||||
]
|
||||
[GPUDriverMap.THEAD]: ['clusters.addworker.theadNotes-02']
|
||||
};
|
||||
|
||||
@@ -264,11 +264,15 @@ const useWorkerColumns = ({
|
||||
) {
|
||||
return (
|
||||
<span className={workerCss.ipWrapper}>
|
||||
<span className="item">
|
||||
<span className={workerCss.item}>
|
||||
<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="label">{`(${intl.formatMessage({ id: 'clusters.table.ip.external' })})`}</span>
|
||||
<span
|
||||
className={workerCss.label}
|
||||
>{`(${intl.formatMessage({ id: 'clusters.table.ip.external' })})`}</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -366,32 +366,45 @@ function toServerRequest(data: ResourceBreakdownRequest) {
|
||||
|
||||
async function _breakdown(
|
||||
url: string,
|
||||
data: ResourceBreakdownRequest
|
||||
data: ResourceBreakdownRequest,
|
||||
options?: {
|
||||
token?: any;
|
||||
}
|
||||
): Promise<ResourceBreakdownResponse> {
|
||||
const { body, groupBy } = toServerRequest(data);
|
||||
const res = await request<ServerBreakdownResponse>(url, {
|
||||
data: body,
|
||||
method: 'POST'
|
||||
method: 'POST',
|
||||
cancelToken: options?.token
|
||||
});
|
||||
return flattenResponse(groupBy, res);
|
||||
}
|
||||
|
||||
export async function queryResourceBreakdown(
|
||||
data: ResourceBreakdownRequest
|
||||
data: ResourceBreakdownRequest,
|
||||
options?: {
|
||||
token?: any;
|
||||
}
|
||||
): Promise<ResourceBreakdownResponse> {
|
||||
return _breakdown(URL.RESOURCE_BREAKDOWN, data);
|
||||
return _breakdown(URL.RESOURCE_BREAKDOWN, data, options);
|
||||
}
|
||||
|
||||
export async function queryGpuInstancesBreakdown(
|
||||
data: ResourceBreakdownRequest
|
||||
data: ResourceBreakdownRequest,
|
||||
options?: {
|
||||
token?: any;
|
||||
}
|
||||
): Promise<ResourceBreakdownResponse> {
|
||||
return _breakdown(URL.GPU_BREAKDOWN, data);
|
||||
return _breakdown(URL.GPU_BREAKDOWN, data, options);
|
||||
}
|
||||
|
||||
export async function queryStorageBreakdown(
|
||||
data: ResourceBreakdownRequest
|
||||
data: ResourceBreakdownRequest,
|
||||
options?: {
|
||||
token?: any;
|
||||
}
|
||||
): Promise<ResourceBreakdownResponse> {
|
||||
return _breakdown(URL.STORAGE_BREAKDOWN, data);
|
||||
return _breakdown(URL.STORAGE_BREAKDOWN, data, options);
|
||||
}
|
||||
|
||||
export async function queryResourceEvents(
|
||||
@@ -406,7 +419,7 @@ export async function queryResourceEvents(
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
},
|
||||
options?: { skipErrorHandler?: boolean }
|
||||
options?: { skipErrorHandler?: boolean; token?: any }
|
||||
): Promise<ResourceEventsResponse> {
|
||||
const creatorIds = data.filters?.creator_ids;
|
||||
return request<ResourceEventsResponse>(URL.EVENTS, {
|
||||
@@ -426,7 +439,8 @@ export async function queryResourceEvents(
|
||||
perPage: data.perPage ?? 50
|
||||
},
|
||||
method: 'GET',
|
||||
skipErrorHandler: options?.skipErrorHandler
|
||||
skipErrorHandler: options?.skipErrorHandler,
|
||||
cancelToken: options?.token
|
||||
});
|
||||
}
|
||||
|
||||
@@ -456,12 +470,15 @@ export async function queryResourceFilterMeta(
|
||||
};
|
||||
}
|
||||
|
||||
export async function queryUsageSummary(params: {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
scope?: 'self' | 'all';
|
||||
creator_ids?: number[];
|
||||
}): Promise<UsageSummaryResponse> {
|
||||
export async function queryUsageSummary(
|
||||
params: {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
scope?: 'self' | 'all';
|
||||
creator_ids?: number[];
|
||||
},
|
||||
options?: { token?: any }
|
||||
): Promise<UsageSummaryResponse> {
|
||||
const { creator_ids, ...rest } = params;
|
||||
const res = await request<{
|
||||
total_tokens: number;
|
||||
@@ -478,7 +495,8 @@ export async function queryUsageSummary(params: {
|
||||
scope: params.scope ?? 'all',
|
||||
...(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,
|
||||
@@ -486,15 +504,18 @@ export async function queryUsageSummary(params: {
|
||||
// instance type. (A true cross-resource split needs a common unit.)
|
||||
let distribution: SummaryResourceDistributionItem[] = [];
|
||||
try {
|
||||
const byType = await queryGpuInstancesBreakdown({
|
||||
start_date: params.start_date,
|
||||
end_date: params.end_date,
|
||||
scope: params.scope ?? 'all',
|
||||
group_by: ['gpu_type'],
|
||||
...(creator_ids?.length ? { filters: { creator_ids } } : {}),
|
||||
page: 1,
|
||||
perPage: 100
|
||||
});
|
||||
const byType = await queryGpuInstancesBreakdown(
|
||||
{
|
||||
start_date: params.start_date,
|
||||
end_date: params.end_date,
|
||||
scope: params.scope ?? 'all',
|
||||
group_by: ['gpu_type'],
|
||||
...(creator_ids?.length ? { filters: { creator_ids } } : {}),
|
||||
page: 1,
|
||||
perPage: 100
|
||||
},
|
||||
{ token: options?.token }
|
||||
);
|
||||
const total = byType.items.reduce((s, i) => s + (i.gpu_hours || 0), 0);
|
||||
distribution = byType.items
|
||||
.filter((i) => (i.gpu_hours || 0) > 0)
|
||||
|
||||
@@ -40,6 +40,7 @@ interface MetricOption {
|
||||
}
|
||||
|
||||
interface MetricChartCardProps {
|
||||
loading?: boolean;
|
||||
metric: string;
|
||||
metricOptions: MetricOption[];
|
||||
granularity: string;
|
||||
@@ -55,6 +56,7 @@ interface MetricChartCardProps {
|
||||
}
|
||||
|
||||
const MetricChartCard: React.FC<MetricChartCardProps> = ({
|
||||
loading,
|
||||
metric,
|
||||
metricOptions,
|
||||
granularity,
|
||||
@@ -129,6 +131,7 @@ const MetricChartCard: React.FC<MetricChartCardProps> = ({
|
||||
/>
|
||||
</ControlsWrapper>
|
||||
<BarChart
|
||||
loading={loading}
|
||||
seriesData={seriesData}
|
||||
xAxisData={xAxisData}
|
||||
height={280}
|
||||
|
||||
+31
-40
@@ -9,23 +9,15 @@
|
||||
* event-type selects ride in the bar's ``extra`` slot.
|
||||
*/
|
||||
import { useAccess, useIntl } from '@umijs/max';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { Input, Select, Table, Tag } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import _ from 'lodash';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react';
|
||||
import {
|
||||
queryResourceEvents,
|
||||
ResourceEventItem,
|
||||
ResourceEventsResponse
|
||||
} from '../apis/resource';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ResourceEventItem } from '../apis/resource';
|
||||
import ResourceFilterBar from '../components/resource-filter-bar';
|
||||
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
|
||||
// + the metering-window pair. updated/attached/detached exist as enum values
|
||||
@@ -115,15 +107,17 @@ const ResourceEvents: React.FC = () => {
|
||||
eventTypes: string[];
|
||||
nameQuery: string;
|
||||
page: number;
|
||||
perPage: number;
|
||||
};
|
||||
const [queryParams, setQueryParams] = useState<QueryParams>({
|
||||
dateRange: [dayjs().subtract(29, 'day'), dayjs()],
|
||||
resourceType: undefined,
|
||||
eventTypes: [],
|
||||
nameQuery: '',
|
||||
perPage: 50,
|
||||
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.
|
||||
const queryRef = useRef(queryParams);
|
||||
@@ -132,30 +126,20 @@ const ResourceEvents: React.FC = () => {
|
||||
// Single fetch entry point: merge the patch into the current params, persist
|
||||
// them, then request — triggered from each handler rather than from effect
|
||||
// dependencies, so there's exactly one request per user action.
|
||||
const fetchEvents = useCallback(
|
||||
async (patch: Partial<QueryParams>) => {
|
||||
const params = { ...queryRef.current, ...patch };
|
||||
setQueryParams(params);
|
||||
try {
|
||||
const res = await queryResourceEvents({
|
||||
start_date: params.dateRange[0].format('YYYY-MM-DD'),
|
||||
end_date: params.dateRange[1].format('YYYY-MM-DD'),
|
||||
scope,
|
||||
resource_types: params.resourceType
|
||||
? [params.resourceType]
|
||||
: undefined,
|
||||
resource_name: params.nameQuery || undefined,
|
||||
event_types: params.eventTypes,
|
||||
page: params.page,
|
||||
perPage: 50
|
||||
});
|
||||
setData(res);
|
||||
} catch {
|
||||
// Keep last response on failure.
|
||||
}
|
||||
},
|
||||
[scope]
|
||||
);
|
||||
const fetchEvents = useMemoizedFn((patch: Partial<QueryParams>) => {
|
||||
const params = { ...queryRef.current, ...patch };
|
||||
setQueryParams(params);
|
||||
return fetchData({
|
||||
start_date: params.dateRange[0].format('YYYY-MM-DD'),
|
||||
end_date: params.dateRange[1].format('YYYY-MM-DD'),
|
||||
scope,
|
||||
resource_types: params.resourceType ? [params.resourceType] : undefined,
|
||||
resource_name: params.nameQuery || undefined,
|
||||
event_types: params.eventTypes,
|
||||
page: params.page,
|
||||
perPage: 50
|
||||
});
|
||||
});
|
||||
|
||||
// First load only — subsequent fetches are driven by the handlers below.
|
||||
useEffect(() => {
|
||||
@@ -278,10 +262,17 @@ const ResourceEvents: React.FC = () => {
|
||||
dataSource={data?.items ?? []}
|
||||
columns={columns as any}
|
||||
style={{ marginTop: 24 }}
|
||||
loading={{
|
||||
spinning: loading,
|
||||
size: 'middle'
|
||||
}}
|
||||
pagination={{
|
||||
size: 'middle',
|
||||
current: queryParams.page,
|
||||
pageSize: data?.pagination.perPage ?? 50,
|
||||
total: data?.pagination.total ?? 0,
|
||||
pageSize: data?.pagination?.perPage ?? 50,
|
||||
total: data?.pagination?.total ?? 0,
|
||||
showSizeChanger: false,
|
||||
hideOnSinglePage: queryParams.perPage === 50,
|
||||
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 { Tabs, TabsProps } from 'antd';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import GpuInstancesTab from './components/gpu-instances-tab';
|
||||
import ResourceEvents from './components/resource-events';
|
||||
import StorageTab from './components/storage-tab';
|
||||
import SummaryTab from './components/summary-tab';
|
||||
import TokenTab from './components/token-tab';
|
||||
import ResourceEvents from './events-tab';
|
||||
import GpuInstancesTab from './instances-tab';
|
||||
import StorageTab from './storage-tab';
|
||||
import SummaryTab from './summary-tab';
|
||||
import TokenTab from './token-tab';
|
||||
|
||||
const Usage: React.FC = () => {
|
||||
const access = useAccess();
|
||||
@@ -28,11 +28,15 @@ const Usage: React.FC = () => {
|
||||
// Land on the cross-resource Summary by default.
|
||||
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',
|
||||
label: intl.formatMessage({ id: 'usage.tabs.summary' }),
|
||||
access: 'canSeeGpuService',
|
||||
children: <SummaryTab />
|
||||
},
|
||||
{
|
||||
@@ -43,34 +47,33 @@ const Usage: React.FC = () => {
|
||||
{
|
||||
key: 'gpu-instances',
|
||||
label: intl.formatMessage({ id: 'usage.tabs.gpuInstances' }),
|
||||
access: 'canSeeGpuService',
|
||||
children: <GpuInstancesTab />
|
||||
},
|
||||
{
|
||||
key: 'storage',
|
||||
label: intl.formatMessage({ id: 'usage.tabs.storage' }),
|
||||
access: 'canSeeGpuService',
|
||||
children: <StorageTab />
|
||||
},
|
||||
{
|
||||
key: 'resource-events',
|
||||
label: intl.formatMessage({ id: 'usage.tabs.resourceEvents' }),
|
||||
access: 'canSeeGpuService',
|
||||
children: <ResourceEvents />
|
||||
}
|
||||
],
|
||||
[intl]
|
||||
);
|
||||
|
||||
// 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.
|
||||
if (!access.canSeeGpuService) {
|
||||
return <TokenTab />;
|
||||
}
|
||||
];
|
||||
return tabs.filter((item) => {
|
||||
if (!item.access) return true;
|
||||
return (access as Record<string, boolean>)[item.access];
|
||||
});
|
||||
}, [intl, access]);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
activeKey={activeKey}
|
||||
onChange={setActiveKey}
|
||||
items={items}
|
||||
destroyOnHidden
|
||||
// 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
|
||||
// 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.
|
||||
*/
|
||||
import useCoolColors from '@/hooks/use-cool-colors';
|
||||
import {
|
||||
buildInstanceTypeRecordFromMiB,
|
||||
renderInstanceType
|
||||
} from '@/pages/gpu-service/instances/utils/render-instance-type';
|
||||
import { formatLargeNumber } from '@/utils';
|
||||
import { SimpleCard } from '@gpustack/core-ui';
|
||||
import { useAccess, useIntl } from '@umijs/max';
|
||||
import { Table, Tabs } from 'antd';
|
||||
import { Tabs } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
queryGpuInstancesBreakdown,
|
||||
ResourceBreakdownItem,
|
||||
ResourceBreakdownRequest,
|
||||
ResourceBreakdownResponse
|
||||
ResourceBreakdownRequest
|
||||
} 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 { instanceTypeLabel } from '../utils/format-instance-type';
|
||||
import {
|
||||
exportBreakdownSheets,
|
||||
toExportColumns
|
||||
} from '../utils/export-breakdown';
|
||||
import {
|
||||
bucketKey,
|
||||
generateBucketRange,
|
||||
Granularity,
|
||||
parseRollup
|
||||
Granularity
|
||||
} from '../utils/time-buckets';
|
||||
import { buildTrendSeries } from '../utils/trend-series';
|
||||
import MetricChartCard from './metric-chart-card';
|
||||
import MetricLabel from './metric-label';
|
||||
import ResourceExportData from './resource-export-data';
|
||||
import ResourceFilterBar from './resource-filter-bar';
|
||||
import useQueryGpuInstancesBreakdown from './services/use-query-gpu-instances-breakdown';
|
||||
import InstancesBreakdownTable from './tables/instances-breakdown-table';
|
||||
import useInstancesColumns from './tables/use-instances-columns';
|
||||
|
||||
type Scope = 'self' | 'all';
|
||||
type Metric = 'gpu_hours' | 'instance_hours';
|
||||
@@ -62,20 +61,26 @@ const GpuInstancesTab: React.FC = () => {
|
||||
[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' })
|
||||
},
|
||||
{
|
||||
key: 'instance',
|
||||
key: 'instance' as GroupKey,
|
||||
label: intl.formatMessage({ id: 'usage.table.instances' })
|
||||
},
|
||||
{ key: 'user', label: intl.formatMessage({ id: 'usage.table.users' }) }
|
||||
],
|
||||
[intl]
|
||||
);
|
||||
}
|
||||
];
|
||||
// Managers see the org-wide User breakdown; members only their own rows.
|
||||
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
|
||||
// palette for the KPI cards, and keep the factory for the grouped trend
|
||||
// (sized to the group count).
|
||||
@@ -105,21 +110,16 @@ const GpuInstancesTab: React.FC = () => {
|
||||
const { creators: userOptions, instances: instanceOptions } =
|
||||
useResourceMeta(scope);
|
||||
|
||||
// Two independent fetches: one for the daily chart (group_by=date),
|
||||
// one for the table (group_by=tab key). Both reuse the same date /
|
||||
// scope filters so the views stay in sync.
|
||||
const [chartData, setChartData] = useState<ResourceBreakdownResponse | null>(
|
||||
null
|
||||
);
|
||||
const [tableData, setTableData] = useState<ResourceBreakdownResponse | null>(
|
||||
null
|
||||
);
|
||||
const [tablePage, setTablePage] = useState(1);
|
||||
// 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' });
|
||||
// The daily chart fetches group_by=date here; each bottom table owns its own
|
||||
// fetch (group_by=tab key) inside InstancesBreakdownTable. Bumped on any
|
||||
// filter change to snap every mounted table back to page 1.
|
||||
const [pageResetKey, setPageResetKey] = useState(0);
|
||||
|
||||
const {
|
||||
detailData: chartData,
|
||||
loading: chartLoading,
|
||||
fetchData: fetchChartData
|
||||
} = useQueryGpuInstancesBreakdown({ key: 'gpuInstancesBreakdownChart' });
|
||||
|
||||
const baseRequest = (): Omit<ResourceBreakdownRequest, 'group_by'> => ({
|
||||
start_date: dateRange[0].format('YYYY-MM-DD'),
|
||||
@@ -139,39 +139,17 @@ const GpuInstancesTab: React.FC = () => {
|
||||
perPage: 50
|
||||
});
|
||||
|
||||
const fetchChart = async () => {
|
||||
try {
|
||||
const data = await queryGpuInstancesBreakdown({
|
||||
...baseRequest(),
|
||||
// Split each bucket by the chosen dimension when grouping.
|
||||
group_by: chartGroupBy ? ['date', chartGroupBy] : ['date'],
|
||||
// A trend is a time series, not a paginated table: always fetch the
|
||||
// whole range. The default order is metric-desc, so partial (current/
|
||||
// recent) buckets have smaller values and would be pushed onto later
|
||||
// pages — dropping the newest hours from the chart under a small page.
|
||||
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.
|
||||
}
|
||||
};
|
||||
const fetchChart = () =>
|
||||
fetchChartData({
|
||||
...baseRequest(),
|
||||
// Split each bucket by the chosen dimension when grouping.
|
||||
group_by: chartGroupBy ? ['date', chartGroupBy] : ['date'],
|
||||
// A trend is a time series, not a paginated table: always fetch the
|
||||
// whole range. The default order is metric-desc, so partial (current/
|
||||
// recent) buckets have smaller values and would be pushed onto later
|
||||
// pages — dropping the newest hours from the chart under a small page.
|
||||
perPage: 10000
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchChart();
|
||||
@@ -184,18 +162,6 @@ const GpuInstancesTab: React.FC = () => {
|
||||
refreshKey
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTable();
|
||||
}, [
|
||||
dateRange,
|
||||
selectedUsers,
|
||||
selectedInstances,
|
||||
activeTableTab,
|
||||
tablePage,
|
||||
tableSort,
|
||||
refreshKey
|
||||
]);
|
||||
|
||||
// KPI summary cards — pull from the chart summary since both queries
|
||||
// return the same scope-wide totals.
|
||||
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
|
||||
// (Users only when org-wide, matching the table tabs).
|
||||
// (TABLE_TABS already gates Users to org admins).
|
||||
const chartGroupByOptions = useMemo(
|
||||
() =>
|
||||
TABLE_TABS.filter((t) => t.key !== 'user' || scope === 'all').map(
|
||||
(t) => ({
|
||||
value: t.key,
|
||||
label: t.label
|
||||
})
|
||||
),
|
||||
[TABLE_TABS, scope]
|
||||
TABLE_TABS.map((t) => ({
|
||||
value: t.key,
|
||||
label: t.label
|
||||
})),
|
||||
[TABLE_TABS]
|
||||
);
|
||||
|
||||
// Table columns adapt to the active tab.
|
||||
const tableColumns = useMemo(() => {
|
||||
const baseValueCols = [
|
||||
// Columns for each bottom-table grouping — same factory the tables render
|
||||
// with — used to build the export sheets below.
|
||||
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' }),
|
||||
dataIndex: 'gpu_hours',
|
||||
key: 'gpu_hours',
|
||||
sorter: true,
|
||||
sortOrder: tableSort.field === 'gpu_hours' ? tableSort.order : null,
|
||||
render: (v: number) => (v ?? 0).toFixed(2)
|
||||
key: 'gpu_type' as GroupKey,
|
||||
columns: gpuTypeColumns,
|
||||
sheetName: intl.formatMessage({ id: 'usage.table.instanceTypes' })
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({ id: 'usage.metric.instanceHours' }),
|
||||
dataIndex: 'instance_hours',
|
||||
key: 'instance_hours',
|
||||
sorter: true,
|
||||
sortOrder:
|
||||
tableSort.field === 'instance_hours' ? tableSort.order : null,
|
||||
render: (v: number) => (v ?? 0).toFixed(2)
|
||||
key: 'instance' as GroupKey,
|
||||
columns: instanceColumns,
|
||||
sheetName: intl.formatMessage({ id: 'usage.table.instances' })
|
||||
}
|
||||
];
|
||||
// Instance Types breakdown: just the pretty product name (or flavor slug
|
||||
// for older rows) — no spec sub-line.
|
||||
const instanceTypeColType = {
|
||||
title: intl.formatMessage({ id: 'usage.table.instanceType' }),
|
||||
dataIndex: 'gpu_type',
|
||||
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 (canManageUsers) {
|
||||
groups.push({
|
||||
key: 'user' as GroupKey,
|
||||
columns: userColumns,
|
||||
sheetName: intl.formatMessage({ id: 'usage.table.users' })
|
||||
});
|
||||
}
|
||||
if (activeTableTab === '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
|
||||
];
|
||||
}, [activeTableTab, tableSort, intl]);
|
||||
return groups;
|
||||
}, [gpuTypeColumns, instanceColumns, userColumns, canManageUsers, intl]);
|
||||
|
||||
const tableRows: ResourceBreakdownItem[] = tableData?.items ?? [];
|
||||
|
||||
// Export opens a preview modal (matches the Tokens tab): re-filter + preview
|
||||
// the rows, then download. "Chart" = the by-date trend, "Table" = the active
|
||||
// bottom-table grouping.
|
||||
const [exportMode, setExportMode] = useState<'chart' | 'table' | null>(null);
|
||||
// Chart export still opens the preview modal (matches the Tokens tab); the
|
||||
// table export is direct, so this only ever holds 'chart'.
|
||||
const [exportMode, setExportMode] = useState<'chart' | null>(null);
|
||||
const dateSuffix = `${dateRange[0].format('YYYY-MM-DD')}_${dateRange[1].format(
|
||||
'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 = [
|
||||
{
|
||||
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;
|
||||
const exportConfig =
|
||||
exportMode === 'chart'
|
||||
? {
|
||||
groupBy: ['date'],
|
||||
columns: chartExportColumns,
|
||||
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'
|
||||
};
|
||||
// The preview modal now only backs the by-date chart export.
|
||||
const exportConfig = {
|
||||
groupBy: ['date'],
|
||||
columns: chartExportColumns,
|
||||
fileName: `gpu-instances_chart_${dateSuffix}.xlsx`,
|
||||
sheetName: intl.formatMessage({ id: 'usage.tabs.gpuInstances' })
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -445,27 +355,27 @@ const GpuInstancesTab: React.FC = () => {
|
||||
value={dateRange}
|
||||
onChange={(dates) => {
|
||||
setDateRange(dates);
|
||||
setTablePage(1);
|
||||
setPageResetKey((k) => k + 1);
|
||||
}}
|
||||
canManageUsers={canManageUsers}
|
||||
userOptions={userOptions}
|
||||
selectedUsers={selectedUsers}
|
||||
onUsersChange={(ids) => {
|
||||
setSelectedUsers(ids);
|
||||
setTablePage(1);
|
||||
setPageResetKey((k) => k + 1);
|
||||
}}
|
||||
resourceFilter={{
|
||||
options: instanceOptions,
|
||||
value: selectedInstances,
|
||||
onChange: (ids) => {
|
||||
setSelectedInstances(ids);
|
||||
setTablePage(1);
|
||||
setPageResetKey((k) => k + 1);
|
||||
},
|
||||
placeholder: intl.formatMessage({ id: 'usage.filter.instance' })
|
||||
}}
|
||||
onRefresh={() => setRefreshKey((k) => k + 1)}
|
||||
onExportChart={() => setExportMode('chart')}
|
||||
onExportTable={() => setExportMode('table')}
|
||||
onExportTable={handleExportTable}
|
||||
/>
|
||||
|
||||
{/* KPI cards */}
|
||||
@@ -484,7 +394,7 @@ const GpuInstancesTab: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Daily trend chart */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<MetricChartCard
|
||||
metric={metric}
|
||||
metricOptions={METRIC_OPTIONS}
|
||||
@@ -496,54 +406,30 @@ const GpuInstancesTab: React.FC = () => {
|
||||
groupBy={chartGroupBy}
|
||||
groupByOptions={chartGroupByOptions}
|
||||
onGroupByChange={(v) => setChartGroupBy(v as GroupKey | null)}
|
||||
loading={chartLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bottom tabs + table */}
|
||||
<Tabs
|
||||
activeKey={activeTableTab}
|
||||
onChange={(k) => {
|
||||
setActiveTableTab(k as GroupKey);
|
||||
setTablePage(1);
|
||||
}}
|
||||
items={TABLE_TABS.filter(
|
||||
(t) => t.key !== 'user' || scope === 'all'
|
||||
).map((t) => ({
|
||||
onChange={(k) => setActiveTableTab(k as GroupKey)}
|
||||
items={TABLE_TABS.map((t) => ({
|
||||
key: t.key,
|
||||
label: t.label,
|
||||
// Keep every pane mounted so each table holds its own page/sort and
|
||||
// switching tabs neither refetches nor resets the others.
|
||||
forceRender: true,
|
||||
children: (
|
||||
<Table
|
||||
rowKey={(row) =>
|
||||
`${row.gpu_type ?? ''}|${row.instance_id ?? ''}|${row.user_id ?? ''}`
|
||||
}
|
||||
<InstancesBreakdownTable
|
||||
key={t.key}
|
||||
dataSource={tableRows}
|
||||
columns={tableColumns as any}
|
||||
onChange={(_pagination, _filters, sorter: any) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||
// Sort changed: reset to page 1. Cleared (3rd click) → default
|
||||
// back to GPU Hours descending.
|
||||
const next = s?.order
|
||||
? {
|
||||
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)
|
||||
}}
|
||||
groupKey={t.key}
|
||||
dateRange={dateRange}
|
||||
scope={scope}
|
||||
selectedUsers={selectedUsers}
|
||||
selectedInstances={selectedInstances}
|
||||
pageResetKey={pageResetKey}
|
||||
refreshKey={refreshKey}
|
||||
/>
|
||||
)
|
||||
}))}
|
||||
@@ -552,14 +438,7 @@ const GpuInstancesTab: React.FC = () => {
|
||||
<ResourceExportData
|
||||
open={exportMode !== null}
|
||||
onCancel={() => setExportMode(null)}
|
||||
title={
|
||||
exportMode === 'chart'
|
||||
? intl.formatMessage({ id: 'usage.export.chart' })
|
||||
: intl.formatMessage(
|
||||
{ id: 'usage.export.tableNamed' },
|
||||
{ name: tabLabel }
|
||||
)
|
||||
}
|
||||
title={intl.formatMessage({ id: 'usage.export.chart' })}
|
||||
queryFn={queryGpuInstancesBreakdown}
|
||||
groupBy={exportConfig.groupBy}
|
||||
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 { SimpleCard } from '@gpustack/core-ui';
|
||||
import { useAccess, useIntl } from '@umijs/max';
|
||||
import { Table, Tabs } from 'antd';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { Tabs } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
queryStorageBreakdown,
|
||||
ResourceBreakdownItem,
|
||||
ResourceBreakdownRequest,
|
||||
ResourceBreakdownResponse
|
||||
ResourceBreakdownRequest
|
||||
} 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 {
|
||||
exportBreakdownSheets,
|
||||
toExportColumns
|
||||
} from '../utils/export-breakdown';
|
||||
import {
|
||||
bucketKey,
|
||||
generateBucketRange,
|
||||
Granularity,
|
||||
parseRollup
|
||||
Granularity
|
||||
} from '../utils/time-buckets';
|
||||
import { buildTrendSeries } from '../utils/trend-series';
|
||||
import MetricChartCard from './metric-chart-card';
|
||||
import MetricLabel from './metric-label';
|
||||
import ResourceExportData from './resource-export-data';
|
||||
import ResourceFilterBar from './resource-filter-bar';
|
||||
import useQueryStorageBreakdown from './services/use-query-storage-breakdown';
|
||||
import StorageBreakdownTable from './tables/storage-breakdown-table';
|
||||
import useStorageColumns from './tables/use-storage-columns';
|
||||
|
||||
type Scope = 'self' | 'all';
|
||||
type Metric = 'storage_gb_days' | 'storage_gb_hours';
|
||||
@@ -63,16 +68,21 @@ const StorageTab: React.FC = () => {
|
||||
[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' })
|
||||
},
|
||||
{ key: 'user', label: intl.formatMessage({ id: 'usage.table.users' }) }
|
||||
],
|
||||
[intl]
|
||||
);
|
||||
}
|
||||
];
|
||||
if (access.canSeeOrgAdmin) {
|
||||
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
|
||||
// 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 } =
|
||||
useResourceMeta(scope);
|
||||
|
||||
const [chartData, setChartData] = useState<ResourceBreakdownResponse | null>(
|
||||
null
|
||||
);
|
||||
const [tableData, setTableData] = useState<ResourceBreakdownResponse | null>(
|
||||
null
|
||||
);
|
||||
const [tablePage, setTablePage] = useState(1);
|
||||
// Server-side sort for the bottom tables; default GB-Days, descending.
|
||||
const [tableSort, setTableSort] = useState<{
|
||||
field: Metric;
|
||||
order: 'ascend' | 'descend';
|
||||
}>({ field: 'storage_gb_days', order: 'descend' });
|
||||
// Bumped on any filter change to snap every mounted table back to page 1;
|
||||
// each table owns its own page/sort state otherwise.
|
||||
const [pageResetKey, setPageResetKey] = useState(0);
|
||||
|
||||
const {
|
||||
detailData: chartData,
|
||||
loading: chartLoading,
|
||||
fetchData: fetchChartData
|
||||
} = useQueryStorageBreakdown({ key: 'storageBreakdownChart' });
|
||||
|
||||
const baseRequest = (): Omit<ResourceBreakdownRequest, 'group_by'> => ({
|
||||
start_date: dateRange[0].format('YYYY-MM-DD'),
|
||||
@@ -124,45 +131,18 @@ const StorageTab: React.FC = () => {
|
||||
perPage: 50
|
||||
});
|
||||
|
||||
const fetchChart = async () => {
|
||||
try {
|
||||
const data = await queryStorageBreakdown({
|
||||
...baseRequest(),
|
||||
// Split each bucket by the chosen dimension when grouping.
|
||||
group_by: chartGroupBy ? ['date', chartGroupBy] : ['date'],
|
||||
// A trend is a time series, not a paginated table: always fetch the
|
||||
// whole range. The default order is metric-desc, so partial (current/
|
||||
// recent) buckets have smaller values and would be pushed onto later
|
||||
// pages — dropping the newest hours from the chart under a small page.
|
||||
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.
|
||||
}
|
||||
};
|
||||
const fetchChart = useMemoizedFn(() =>
|
||||
fetchChartData({
|
||||
...baseRequest(),
|
||||
// Split each bucket by the chosen dimension when grouping.
|
||||
group_by: chartGroupBy ? ['date', chartGroupBy] : ['date'],
|
||||
// A trend is a time series, not a paginated table: always fetch the
|
||||
// whole range. The default order is metric-desc, so partial (current/
|
||||
// recent) buckets have smaller values and would be pushed onto later
|
||||
// pages — dropping the newest hours from the chart under a small page.
|
||||
perPage: 10000
|
||||
})
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchChart();
|
||||
@@ -175,18 +155,6 @@ const StorageTab: React.FC = () => {
|
||||
refreshKey
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTable();
|
||||
}, [
|
||||
dateRange,
|
||||
selectedUsers,
|
||||
selectedVolumes,
|
||||
activeTableTab,
|
||||
tablePage,
|
||||
tableSort,
|
||||
refreshKey
|
||||
]);
|
||||
|
||||
const summary = chartData?.summary;
|
||||
const summaryCards = useMemo(
|
||||
() => [
|
||||
@@ -267,95 +235,68 @@ const StorageTab: React.FC = () => {
|
||||
|
||||
const chartGroupByOptions = useMemo(
|
||||
() =>
|
||||
TABLE_TABS.filter((t) => t.key !== 'user' || scope === 'all').map(
|
||||
(t) => ({
|
||||
value: t.key,
|
||||
label: t.label
|
||||
})
|
||||
),
|
||||
TABLE_TABS.map((t) => ({
|
||||
value: t.key,
|
||||
label: t.label
|
||||
})),
|
||||
[TABLE_TABS, scope]
|
||||
);
|
||||
|
||||
const tableColumns = useMemo(() => {
|
||||
const valueCols = [
|
||||
// Columns for each bottom-table grouping — same factory the tables render
|
||||
// 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' }),
|
||||
dataIndex: 'storage_gb_days',
|
||||
key: 'storage_gb_days',
|
||||
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)
|
||||
key: 'volume' as GroupKey,
|
||||
columns: volumeColumns,
|
||||
sheetName: intl.formatMessage({ id: 'usage.tabs.storage' })
|
||||
}
|
||||
];
|
||||
// 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 === '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
|
||||
];
|
||||
if (canManageUsers) {
|
||||
groups.push({
|
||||
key: 'user' as GroupKey,
|
||||
columns: userColumns,
|
||||
sheetName: intl.formatMessage({ id: 'usage.table.users' })
|
||||
});
|
||||
}
|
||||
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
|
||||
];
|
||||
}, [activeTableTab, tableSort, intl]);
|
||||
return groups;
|
||||
}, [volumeColumns, userColumns, canManageUsers, intl]);
|
||||
|
||||
const tableRows: ResourceBreakdownItem[] = tableData?.items ?? [];
|
||||
|
||||
// Export opens a preview modal (matches the Tokens tab): re-filter + preview
|
||||
// the rows, then download. "Chart" = the by-date trend, "Table" = the active
|
||||
// bottom-table grouping.
|
||||
const [exportMode, setExportMode] = useState<'chart' | 'table' | null>(null);
|
||||
// Chart export still opens the preview modal (matches the Tokens tab); the
|
||||
// table export is direct, so this only ever holds 'chart'.
|
||||
const [exportMode, setExportMode] = useState<'chart' | null>(null);
|
||||
const dateSuffix = `${dateRange[0].format('YYYY-MM-DD')}_${dateRange[1].format(
|
||||
'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 = [
|
||||
{
|
||||
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;
|
||||
const exportConfig =
|
||||
exportMode === 'chart'
|
||||
? {
|
||||
groupBy: ['date'],
|
||||
columns: chartExportColumns,
|
||||
fileName: `storage_chart_${dateSuffix}.xlsx`,
|
||||
sheetName: intl.formatMessage({ id: 'usage.tabs.storage' })
|
||||
}
|
||||
: {
|
||||
groupBy: [activeTableTab],
|
||||
columns: tableColumns,
|
||||
fileName: `storage_${activeTableTab}_${dateSuffix}.xlsx`,
|
||||
sheetName: tabLabel || 'storage'
|
||||
};
|
||||
// The preview modal now only backs the by-date chart export.
|
||||
const exportConfig = {
|
||||
groupBy: ['date'],
|
||||
columns: chartExportColumns,
|
||||
fileName: `storage_chart_${dateSuffix}.xlsx`,
|
||||
sheetName: intl.formatMessage({ id: 'usage.tabs.storage' })
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -408,27 +341,27 @@ const StorageTab: React.FC = () => {
|
||||
value={dateRange}
|
||||
onChange={(dates) => {
|
||||
setDateRange(dates);
|
||||
setTablePage(1);
|
||||
setPageResetKey((k) => k + 1);
|
||||
}}
|
||||
canManageUsers={canManageUsers}
|
||||
userOptions={userOptions}
|
||||
selectedUsers={selectedUsers}
|
||||
onUsersChange={(ids) => {
|
||||
setSelectedUsers(ids);
|
||||
setTablePage(1);
|
||||
setPageResetKey((k) => k + 1);
|
||||
}}
|
||||
resourceFilter={{
|
||||
options: volumeOptions,
|
||||
value: selectedVolumes,
|
||||
onChange: (ids) => {
|
||||
setSelectedVolumes(ids);
|
||||
setTablePage(1);
|
||||
setPageResetKey((k) => k + 1);
|
||||
},
|
||||
placeholder: intl.formatMessage({ id: 'usage.filter.storage' })
|
||||
}}
|
||||
onRefresh={() => setRefreshKey((k) => k + 1)}
|
||||
onExportChart={() => setExportMode('chart')}
|
||||
onExportTable={() => setExportMode('table')}
|
||||
onExportTable={handleExportTable}
|
||||
/>
|
||||
<div style={{ height: 24 }} />
|
||||
|
||||
@@ -457,54 +390,29 @@ const StorageTab: React.FC = () => {
|
||||
groupBy={chartGroupBy}
|
||||
groupByOptions={chartGroupByOptions}
|
||||
onGroupByChange={(v) => setChartGroupBy(v as GroupKey | null)}
|
||||
loading={chartLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
activeKey={activeTableTab}
|
||||
onChange={(k) => {
|
||||
setActiveTableTab(k as GroupKey);
|
||||
setTablePage(1);
|
||||
}}
|
||||
items={TABLE_TABS.filter(
|
||||
(t) => t.key !== 'user' || scope === 'all'
|
||||
).map((t) => ({
|
||||
onChange={(k) => setActiveTableTab(k as GroupKey)}
|
||||
items={TABLE_TABS.map((t) => ({
|
||||
key: t.key,
|
||||
label: t.label,
|
||||
// Keep every pane mounted so each table holds its own page/sort and
|
||||
// switching tabs neither refetches nor resets the other.
|
||||
forceRender: true,
|
||||
children: (
|
||||
<Table
|
||||
rowKey={(row) =>
|
||||
`${row.volume_id ?? ''}|${row.user_id ?? ''}|${row.volume_name ?? ''}`
|
||||
}
|
||||
dataSource={tableRows}
|
||||
columns={tableColumns as any}
|
||||
onChange={(_pagination, _filters, sorter: any) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||
// Sort changed → page 1; cleared (3rd click) → default GB-Days
|
||||
// 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)
|
||||
}}
|
||||
<StorageBreakdownTable
|
||||
key={t.key}
|
||||
groupKey={t.key}
|
||||
dateRange={dateRange}
|
||||
scope={scope}
|
||||
selectedUsers={selectedUsers}
|
||||
selectedVolumes={selectedVolumes}
|
||||
pageResetKey={pageResetKey}
|
||||
refreshKey={refreshKey}
|
||||
/>
|
||||
)
|
||||
}))}
|
||||
@@ -513,14 +421,7 @@ const StorageTab: React.FC = () => {
|
||||
<ResourceExportData
|
||||
open={exportMode !== null}
|
||||
onCancel={() => setExportMode(null)}
|
||||
title={
|
||||
exportMode === 'chart'
|
||||
? intl.formatMessage({ id: 'usage.export.chart' })
|
||||
: intl.formatMessage(
|
||||
{ id: 'usage.export.tableNamed' },
|
||||
{ name: tabLabel }
|
||||
)
|
||||
}
|
||||
title={intl.formatMessage({ id: 'usage.export.chart' })}
|
||||
queryFn={queryStorageBreakdown}
|
||||
groupBy={exportConfig.groupBy}
|
||||
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 { CardWrapper } from '@gpustack/core-ui';
|
||||
import { useAccess, useIntl } from '@umijs/max';
|
||||
import { Col, Empty, Row } from 'antd';
|
||||
import { Col, Row } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { queryUsageTimeSeriesData } from '../apis';
|
||||
import {
|
||||
queryResourceBreakdown,
|
||||
queryStorageBreakdown,
|
||||
queryUsageSummary,
|
||||
ResourceBreakdownResponse,
|
||||
UsageSummaryResponse
|
||||
} from '../apis/resource';
|
||||
import ResourceFilterBar from '../components/resource-filter-bar';
|
||||
import useResourceMeta from '../hooks/use-resource-meta';
|
||||
import {
|
||||
bucketKey,
|
||||
generateBucketRange,
|
||||
Granularity
|
||||
} 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 QueryParams = {
|
||||
start: string;
|
||||
end: string;
|
||||
selectedUsers: number[];
|
||||
};
|
||||
|
||||
// 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 fmt = (n?: number) => formatLargeNumber(round2(n));
|
||||
@@ -73,9 +75,15 @@ const buildTrend = (
|
||||
const k = bucketKey(r.date, gran);
|
||||
map.set(k, (map.get(k) ?? 0) + r.value);
|
||||
});
|
||||
const keys = new Set(generateBucketRange(start, end, gran));
|
||||
map.forEach((_v, k) => keys.add(k));
|
||||
const xAxis = Array.from(keys).sort();
|
||||
// The x-axis is strictly the selected [start, end] range. We deliberately do
|
||||
// NOT union in the data's own bucket keys: on a date-range change the new
|
||||
// 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) };
|
||||
};
|
||||
|
||||
@@ -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
|
||||
// carries its own chart title — no wasteful caption rows.
|
||||
const DomainSection: React.FC<{
|
||||
pieLoading?: boolean;
|
||||
barLoading?: boolean;
|
||||
title: string;
|
||||
accent: string;
|
||||
headline: React.ReactNode;
|
||||
@@ -107,6 +117,8 @@ const DomainSection: React.FC<{
|
||||
trendColor: string;
|
||||
trendGran: Granularity;
|
||||
}> = ({
|
||||
pieLoading,
|
||||
barLoading,
|
||||
title,
|
||||
accent,
|
||||
headline,
|
||||
@@ -118,16 +130,14 @@ const DomainSection: React.FC<{
|
||||
trendColor,
|
||||
trendGran
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const donutTotal = round2(donutData.reduce((s, d) => s + (d.value || 0), 0));
|
||||
const trendEmpty = trendData.every((v) => !v);
|
||||
const empty = (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={intl.formatMessage({ id: 'usage.common.noData' })}
|
||||
style={{ margin: '32px 0' }}
|
||||
/>
|
||||
);
|
||||
|
||||
const seriesData = useMemo(() => {
|
||||
return [{ name: trendTitle, data: trendData, color: trendColor }].filter(
|
||||
(s) => s.data.some((v) => !!v)
|
||||
);
|
||||
}, [trendData, trendColor]);
|
||||
|
||||
return (
|
||||
<CardWrapper style={{ paddingBlock: 16 }}>
|
||||
<div
|
||||
@@ -166,37 +176,29 @@ const DomainSection: React.FC<{
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 340, maxWidth: '100%', flexShrink: 0 }}>
|
||||
{donutTotal <= 0 ? (
|
||||
empty
|
||||
) : (
|
||||
<PieChart
|
||||
data={donutData}
|
||||
height={180}
|
||||
total={donutTotal}
|
||||
totalLabel={donutTotalLabel}
|
||||
/>
|
||||
)}
|
||||
<PieChart
|
||||
loading={pieLoading}
|
||||
data={donutData}
|
||||
height={180}
|
||||
total={donutTotal}
|
||||
totalLabel={donutTotalLabel}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 260 }}>
|
||||
{trendEmpty ? (
|
||||
empty
|
||||
) : (
|
||||
<BarChart
|
||||
seriesData={[
|
||||
{ name: trendTitle, data: trendData, color: trendColor }
|
||||
]}
|
||||
xAxisData={trendXAxis}
|
||||
height={180}
|
||||
grid={{
|
||||
bottom: 0
|
||||
}}
|
||||
title={trendTitle}
|
||||
labelFormatter={trendLabel(trendGran)}
|
||||
tooltipValueFormatter={(v) =>
|
||||
formatLargeNumber(round2(Number(v))) as string
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<BarChart
|
||||
loading={barLoading}
|
||||
seriesData={seriesData}
|
||||
xAxisData={trendXAxis}
|
||||
height={180}
|
||||
grid={{
|
||||
bottom: 0
|
||||
}}
|
||||
title={trendTitle}
|
||||
labelFormatter={trendLabel(trendGran)}
|
||||
tooltipValueFormatter={(v) =>
|
||||
formatLargeNumber(round2(Number(v))) as string
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardWrapper>
|
||||
@@ -216,95 +218,109 @@ const SummaryTab: React.FC = () => {
|
||||
// Summary trends are fixed to a daily granularity (no granularity control).
|
||||
const granularity: Granularity = 'day';
|
||||
|
||||
const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs]>([
|
||||
dayjs().subtract(29, 'day'),
|
||||
dayjs()
|
||||
]);
|
||||
const [selectedUsers, setSelectedUsers] = useState<number[]>([]);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
// Date range + user filter live together: every fetch keys off all three, so
|
||||
// a single object keeps them in sync and trims the dependency arrays.
|
||||
const [queryParams, setQueryParams] = useState<{
|
||||
start: string;
|
||||
end: string;
|
||||
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 [summary, setSummary] = useState<UsageSummaryResponse | null>(null);
|
||||
const [tokenSeries, setTokenSeries] = useState<any[]>([]);
|
||||
const [computeByDate, setComputeByDate] =
|
||||
useState<ResourceBreakdownResponse | null>(null);
|
||||
const [storageByDate, setStorageByDate] =
|
||||
useState<ResourceBreakdownResponse | null>(null);
|
||||
const [storageByType, setStorageByType] =
|
||||
useState<ResourceBreakdownResponse | null>(null);
|
||||
const {
|
||||
detailData: summary,
|
||||
loading: summaryLoading,
|
||||
fetchData: fetchSummary
|
||||
} = useQueryUsageSummary();
|
||||
const {
|
||||
detailData: tokenSeriesData,
|
||||
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 end = dateRange[1].format('YYYY-MM-DD');
|
||||
// Token usage (model_usages) is a daily rollup; the Summary trends are fixed
|
||||
// to ``day`` anyway, so the token trend shares the same granularity.
|
||||
const tokenGran: Granularity = granularity;
|
||||
const fetchAll = async (params?: Partial<QueryParams>) => {
|
||||
const currentParams = {
|
||||
...queryParams,
|
||||
...params
|
||||
};
|
||||
|
||||
// "filter by user" — restricts every resource fetch to these creator ids.
|
||||
const creatorFilter = selectedUsers.length
|
||||
? { creator_ids: selectedUsers }
|
||||
: undefined;
|
||||
|
||||
// Date+scope scoped fetches (granularity-independent).
|
||||
useEffect(() => {
|
||||
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,
|
||||
const commonParams = {
|
||||
start_date: currentParams.start,
|
||||
end_date: currentParams.end,
|
||||
scope
|
||||
};
|
||||
const paginationParams = {
|
||||
...commonParams,
|
||||
page: 1,
|
||||
perPage: 100
|
||||
})
|
||||
.then(setStorageByType)
|
||||
.catch(() => {});
|
||||
}, [start, end, scope, selectedUsers, refreshKey]);
|
||||
};
|
||||
|
||||
// Trend fetches (depend on granularity too).
|
||||
useEffect(() => {
|
||||
queryUsageTimeSeriesData({
|
||||
start_date: start,
|
||||
end_date: end,
|
||||
scope,
|
||||
metric: 'total_tokens',
|
||||
group_by: ['date'],
|
||||
granularity: tokenGran,
|
||||
filters: {}
|
||||
})
|
||||
.then((res) => setTokenSeries(res.items || []))
|
||||
.catch(() => {});
|
||||
queryResourceBreakdown({
|
||||
start_date: start,
|
||||
end_date: end,
|
||||
scope,
|
||||
group_by: ['date'],
|
||||
granularity,
|
||||
filters: creatorFilter,
|
||||
page: 1,
|
||||
perPage: 100
|
||||
})
|
||||
.then(setComputeByDate)
|
||||
.catch(() => {});
|
||||
queryStorageBreakdown({
|
||||
start_date: start,
|
||||
end_date: end,
|
||||
scope,
|
||||
group_by: ['date'],
|
||||
granularity,
|
||||
filters: creatorFilter,
|
||||
page: 1,
|
||||
perPage: 100
|
||||
})
|
||||
.then(setStorageByDate)
|
||||
.catch(() => {});
|
||||
}, [start, end, scope, granularity, tokenGran, selectedUsers, refreshKey]);
|
||||
if (params) {
|
||||
setQueryParams(currentParams);
|
||||
}
|
||||
|
||||
// "filter by user" — restricts every resource fetch to these creator ids.
|
||||
const creatorFilter = currentParams.selectedUsers.length
|
||||
? { creator_ids: currentParams.selectedUsers }
|
||||
: undefined;
|
||||
|
||||
await Promise.all([
|
||||
fetchSummary({
|
||||
...commonParams,
|
||||
creator_ids: currentParams.selectedUsers.length
|
||||
? currentParams.selectedUsers
|
||||
: undefined
|
||||
}),
|
||||
|
||||
fetchStorageByType({
|
||||
...paginationParams,
|
||||
group_by: ['type'],
|
||||
filters: creatorFilter
|
||||
}),
|
||||
|
||||
fetchTokenSeries({
|
||||
...commonParams,
|
||||
metric: 'total_tokens',
|
||||
group_by: ['date'],
|
||||
granularity,
|
||||
filters: {}
|
||||
}),
|
||||
|
||||
fetchComputeBreakdown({
|
||||
...paginationParams,
|
||||
group_by: ['date'],
|
||||
granularity,
|
||||
filters: creatorFilter
|
||||
}),
|
||||
|
||||
fetchStorageByDate({
|
||||
...paginationParams,
|
||||
group_by: ['date'],
|
||||
granularity,
|
||||
filters: creatorFilter
|
||||
})
|
||||
]);
|
||||
};
|
||||
|
||||
// --- derived: donuts ---
|
||||
const tokenDonut = useMemo(
|
||||
@@ -337,16 +353,17 @@ const SummaryTab: React.FC = () => {
|
||||
const tokenTrend = useMemo(
|
||||
() =>
|
||||
buildTrend(
|
||||
tokenSeries.map((it) => ({
|
||||
(tokenSeriesData?.items || []).map((it: any) => ({
|
||||
date: it?.date?.value,
|
||||
value: Number(it?.total_tokens ?? 0)
|
||||
})),
|
||||
tokenGran,
|
||||
granularity,
|
||||
start,
|
||||
end
|
||||
),
|
||||
[tokenSeries, tokenGran, start, end]
|
||||
[tokenSeriesData, granularity, start, end]
|
||||
);
|
||||
|
||||
const computeTrend = useMemo(
|
||||
() =>
|
||||
buildTrend(
|
||||
@@ -360,6 +377,7 @@ const SummaryTab: React.FC = () => {
|
||||
),
|
||||
[computeByDate, granularity, start, end]
|
||||
);
|
||||
|
||||
const storageTrend = useMemo(
|
||||
() =>
|
||||
buildTrend(
|
||||
@@ -377,16 +395,35 @@ const SummaryTab: React.FC = () => {
|
||||
const computeSum = computeByDate?.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 (
|
||||
<div>
|
||||
<ResourceFilterBar
|
||||
value={dateRange}
|
||||
onChange={(dates) => setDateRange(dates)}
|
||||
value={[dayjs(start), dayjs(end)]}
|
||||
onChange={handleDateRangeChange}
|
||||
canManageUsers={canManageUsers}
|
||||
userOptions={userOptions}
|
||||
selectedUsers={selectedUsers}
|
||||
onUsersChange={setSelectedUsers}
|
||||
onRefresh={() => setRefreshKey((k) => k + 1)}
|
||||
onUsersChange={handleUserFilterChange}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
<div style={{ height: 24 }} />
|
||||
|
||||
@@ -400,8 +437,10 @@ const SummaryTab: React.FC = () => {
|
||||
trendTitle={t('usage.summary.tokensOverTime')}
|
||||
trendXAxis={tokenTrend.xAxis}
|
||||
trendData={tokenTrend.data}
|
||||
pieLoading={summaryLoading}
|
||||
barLoading={tokenSeriesLoading}
|
||||
trendColor={coolColors[0]}
|
||||
trendGran={tokenGran}
|
||||
trendGran={granularity}
|
||||
headline={
|
||||
<>
|
||||
<Stat
|
||||
@@ -436,6 +475,8 @@ const SummaryTab: React.FC = () => {
|
||||
trendData={computeTrend.data}
|
||||
trendColor={coolColors[1]}
|
||||
trendGran={granularity}
|
||||
pieLoading={summaryLoading}
|
||||
barLoading={computeLoading}
|
||||
headline={
|
||||
<>
|
||||
<Stat
|
||||
@@ -466,6 +507,8 @@ const SummaryTab: React.FC = () => {
|
||||
trendData={storageTrend.data}
|
||||
trendColor={coolColors[3]}
|
||||
trendGran={granularity}
|
||||
pieLoading={storageByTypeLoading}
|
||||
barLoading={storageByDateLoading}
|
||||
headline={
|
||||
<>
|
||||
<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 { Tabs } from 'antd';
|
||||
import React, { useMemo } from 'react';
|
||||
import { UsageFilterItem } from '../config/types';
|
||||
import { UsageFilterItem } from '../../config/types';
|
||||
import ApiKeysTable from '../tables/apikeys-table';
|
||||
import ModelsTable from '../tables/models-table';
|
||||
import UsersTable from '../tables/users-table';
|
||||
@@ -47,7 +47,7 @@ const BreakdownTabs: React.FC<{
|
||||
},
|
||||
{
|
||||
key: 'users',
|
||||
label: intl.formatMessage({ id: 'usage.tabs.users' }),
|
||||
label: intl.formatMessage({ id: 'usage.table.users' }),
|
||||
forceRender: true,
|
||||
children: (
|
||||
<UsersTable
|
||||
@@ -3,13 +3,13 @@ import { formatLargeNumber } from '@/utils';
|
||||
import { SimpleCard } from '@gpustack/core-ui';
|
||||
import { useAccess, useIntl } from '@umijs/max';
|
||||
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 { useUsageFilters } from '../hooks/use-usage-filters';
|
||||
import useQueryUsageMetaData from '../services/use-query-meta-data';
|
||||
import BreakdownTabs from './breakdown-tabs';
|
||||
import DailyUsage from './daily-usage';
|
||||
import ExportData from './export-data';
|
||||
import FilterBar from './filter-bar';
|
||||
import BreakdownTabs from './components/breakdown-tabs';
|
||||
|
||||
type DateType = 'date' | 'week' | 'month' | 'quarter' | 'year';
|
||||
|
||||
+5
-24
@@ -1,14 +1,12 @@
|
||||
import { TABLE_SORT_DIRECTIONS } from '@/config/settings';
|
||||
import PageBox from '@/pages/_components/page-box';
|
||||
import { IconFont, NoResult } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Table } from 'antd';
|
||||
import _ from 'lodash';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { FilterOptionType } from '../config/types';
|
||||
import useAPIKeys from '../hooks/use-apikeys-columns';
|
||||
import useQueryBreakdownList from '../services/use-query-breakdown-list';
|
||||
import getBreakdownRowKey from '../utils/get-breakdown-row-key';
|
||||
import { FilterOptionType } from '../../config/types';
|
||||
import useAPIKeys from '../../hooks/use-apikeys-columns';
|
||||
import useQueryBreakdownList from '../../services/use-query-breakdown-list';
|
||||
import getBreakdownRowKey from '../../utils/get-breakdown-row-key';
|
||||
|
||||
const APIKeys: React.FC<{
|
||||
apiKeys: FilterOptionType[];
|
||||
@@ -38,6 +36,7 @@ const APIKeys: React.FC<{
|
||||
sorter.order === 'descend' ? `-${sorter.field}` : sorter.field;
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
page: 1,
|
||||
sort_by
|
||||
}));
|
||||
};
|
||||
@@ -61,24 +60,6 @@ const APIKeys: React.FC<{
|
||||
}
|
||||
}, [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(() => {
|
||||
if (pendingPageResetRef.current && queryParams.page !== 1) {
|
||||
return;
|
||||
+5
-24
@@ -1,15 +1,13 @@
|
||||
import PluginExtraFields from '@/components/plugin-extra-fields';
|
||||
import { TABLE_SORT_DIRECTIONS } from '@/config/settings';
|
||||
import PageBox from '@/pages/_components/page-box';
|
||||
import { IconFont, NoResult } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Table } from 'antd';
|
||||
import _ from 'lodash';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { BreakdownItem, FilterOptionType } from '../config/types';
|
||||
import useModelsColumns from '../hooks/use-models-columns';
|
||||
import useQueryBreakdownList from '../services/use-query-breakdown-list';
|
||||
import getBreakdownRowKey from '../utils/get-breakdown-row-key';
|
||||
import { BreakdownItem, FilterOptionType } from '../../config/types';
|
||||
import useModelsColumns from '../../hooks/use-models-columns';
|
||||
import useQueryBreakdownList from '../../services/use-query-breakdown-list';
|
||||
import getBreakdownRowKey from '../../utils/get-breakdown-row-key';
|
||||
|
||||
const Models: React.FC<{
|
||||
routes: FilterOptionType[];
|
||||
@@ -40,6 +38,7 @@ const Models: React.FC<{
|
||||
sorter.order === 'descend' ? `-${sorter.field}` : sorter.field;
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
page: 1,
|
||||
sort_by
|
||||
}));
|
||||
};
|
||||
@@ -64,24 +63,6 @@ const Models: React.FC<{
|
||||
}
|
||||
}, [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(() => {
|
||||
if (pendingPageResetRef.current && queryParams.page !== 1) {
|
||||
return;
|
||||
+5
-24
@@ -1,14 +1,12 @@
|
||||
import { TABLE_SORT_DIRECTIONS } from '@/config/settings';
|
||||
import PageBox from '@/pages/_components/page-box';
|
||||
import { IconFont, NoResult } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Table } from 'antd';
|
||||
import _ from 'lodash';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { FilterOptionType } from '../config/types';
|
||||
import useUsersColumns from '../hooks/use-users-columns';
|
||||
import useQueryBreakdownList from '../services/use-query-breakdown-list';
|
||||
import getBreakdownRowKey from '../utils/get-breakdown-row-key';
|
||||
import { FilterOptionType } from '../../config/types';
|
||||
import useUsersColumns from '../../hooks/use-users-columns';
|
||||
import useQueryBreakdownList from '../../services/use-query-breakdown-list';
|
||||
import getBreakdownRowKey from '../../utils/get-breakdown-row-key';
|
||||
|
||||
const Users: React.FC<{
|
||||
users: FilterOptionType[];
|
||||
@@ -38,6 +36,7 @@ const Users: React.FC<{
|
||||
sorter.order === 'descend' ? `-${sorter.field}` : sorter.field;
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
page: 1,
|
||||
sort_by
|
||||
}));
|
||||
};
|
||||
@@ -52,24 +51,6 @@ const Users: React.FC<{
|
||||
|
||||
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(() => {
|
||||
if (queryParams.page !== 1) {
|
||||
pendingPageResetRef.current = true;
|
||||
@@ -26,12 +26,13 @@ export const toExportColumns = (columns: any[]): ExportColumn[] =>
|
||||
dataIndex: c.dataIndex as string
|
||||
}));
|
||||
|
||||
export const exportBreakdownRows = (
|
||||
rows: any[],
|
||||
columns: ExportColumn[],
|
||||
fileName: string,
|
||||
sheetName = 'usage'
|
||||
): void => {
|
||||
export interface ExportSheet {
|
||||
rows: any[];
|
||||
columns: ExportColumn[];
|
||||
sheetName: string;
|
||||
}
|
||||
|
||||
const buildSheet = ({ rows, columns, sheetName }: ExportSheet) => {
|
||||
const fields = columns.map((c) => c.dataIndex);
|
||||
const fieldLabels = Object.fromEntries(
|
||||
columns.map((c) => [c.dataIndex, c.title])
|
||||
@@ -43,8 +44,22 @@ export const exportBreakdownRows = (
|
||||
});
|
||||
return o;
|
||||
});
|
||||
exportJsonToExcel({
|
||||
fileName,
|
||||
sheets: [{ jsonData, sheetName, fields, fieldLabels, formatMap: {} }]
|
||||
});
|
||||
return { 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