feat(usage): resource usage metering page
Add the Usage page with Summary / Tokens / GPU Instances / Storage / Resource Events tabs over the new metering endpoints: per-resource breakdowns with date / scope / user / resource filters, trend charts, server-side sortable tables (GPU-Hours, Instance-Hours, GB-Days, GB-Hours), Excel export with an in-dialog preview, and KPI cards with help tooltips explaining each metric. MaaS-only users (no Kubernetes cluster and no resource events) get a tokens-only view with the tab bar dropped; GPU Service / the full page unlock for admins, cluster owners, or anyone who has run a resource. Instance-type rows reuse the GPU Instances list styling, and deleted users / instances / volumes are flagged in breakdowns and filters.
This commit is contained in:
+7
-1
@@ -3,6 +3,7 @@ import { applyAccessExtensions } from './access.extensions';
|
|||||||
export default (initialState: {
|
export default (initialState: {
|
||||||
currentUser?: Global.UserInfo;
|
currentUser?: Global.UserInfo;
|
||||||
hasKubernetesCluster?: boolean;
|
hasKubernetesCluster?: boolean;
|
||||||
|
hasResourceEvents?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const isPlatformAdmin = !!(
|
const isPlatformAdmin = !!(
|
||||||
initialState &&
|
initialState &&
|
||||||
@@ -20,6 +21,10 @@ export default (initialState: {
|
|||||||
// role-based default so a transient network blip can't lock anyone
|
// role-based default so a transient network blip can't lock anyone
|
||||||
// out of the menu.
|
// out of the menu.
|
||||||
const hasKubernetesCluster = initialState?.hasKubernetesCluster;
|
const hasKubernetesCluster = initialState?.hasKubernetesCluster;
|
||||||
|
// Having run GPU/CPU instances or storage (any resource_events) also unlocks
|
||||||
|
// GPU Service / the full Usage page — a user who used it keeps seeing it even
|
||||||
|
// without a current cluster. MaaS-only users (no cluster, no events) don't.
|
||||||
|
const hasResourceEvents = !!initialState?.hasResourceEvents;
|
||||||
|
|
||||||
// Predicate roles, top-down by strictness:
|
// Predicate roles, top-down by strictness:
|
||||||
// * `canSeeAdmin` — strictly platform admin (`users.is_admin`).
|
// * `canSeeAdmin` — strictly platform admin (`users.is_admin`).
|
||||||
@@ -41,7 +46,8 @@ export default (initialState: {
|
|||||||
return applyAccessExtensions({
|
return applyAccessExtensions({
|
||||||
canSeeAdmin: isPlatformAdmin,
|
canSeeAdmin: isPlatformAdmin,
|
||||||
canSeeOrgAdmin: isPlatformAdmin,
|
canSeeOrgAdmin: isPlatformAdmin,
|
||||||
canSeeGpuService: isPlatformAdmin || hasKubernetesCluster !== false,
|
canSeeGpuService:
|
||||||
|
isPlatformAdmin || hasKubernetesCluster !== false || hasResourceEvents,
|
||||||
canManageCurrentOrg: false,
|
canManageCurrentOrg: false,
|
||||||
canSeeUser,
|
canSeeUser,
|
||||||
canDelete: true,
|
canDelete: true,
|
||||||
|
|||||||
+41
-5
@@ -5,6 +5,7 @@ import { DEFAULT_ENTER_PAGE, GPUSTACK_API_BASE_URL } from '@/config/settings';
|
|||||||
import { COLOR_PRIMARY } from '@/config/theme/constants';
|
import { COLOR_PRIMARY } from '@/config/theme/constants';
|
||||||
import { queryClusterList } from '@/pages/cluster-management/apis';
|
import { queryClusterList } from '@/pages/cluster-management/apis';
|
||||||
import { ProviderValueMap } from '@/pages/cluster-management/config';
|
import { ProviderValueMap } from '@/pages/cluster-management/config';
|
||||||
|
import { queryResourceEvents } from '@/pages/usage/apis/resource';
|
||||||
import { enterprisePluginReady } from '@/plugins/enterprise-ready';
|
import { enterprisePluginReady } from '@/plugins/enterprise-ready';
|
||||||
import { GPUStackPluginManager } from '@/plugins/manager';
|
import { GPUStackPluginManager } from '@/plugins/manager';
|
||||||
import { requestConfig } from '@/request-config';
|
import { requestConfig } from '@/request-config';
|
||||||
@@ -72,12 +73,44 @@ const probeHasKubernetesCluster = async (): Promise<boolean | undefined> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Probes whether the caller has ANY resource-usage events (GPU/CPU instance or
|
||||||
|
// storage lifecycle). Used alongside the cluster probe so a user who has run
|
||||||
|
// GPU instances still sees GPU Service / the full Usage page even if they
|
||||||
|
// currently have no Kubernetes cluster. Mirrored into sessionStorage for the
|
||||||
|
// access extensions; any failure → undefined ("unknown — don't restrict").
|
||||||
|
const HAS_RESOURCE_EVENTS_KEY = 'hasResourceEvents';
|
||||||
|
const probeHasResourceEvents = async (): Promise<boolean | undefined> => {
|
||||||
|
try {
|
||||||
|
// No date range = "ever"; scope is clamped to the caller server-side.
|
||||||
|
const res = await queryResourceEvents({ perPage: 1 });
|
||||||
|
const value = (res?.pagination?.total ?? 0) > 0;
|
||||||
|
try {
|
||||||
|
window.sessionStorage.setItem(
|
||||||
|
HAS_RESOURCE_EVENTS_KEY,
|
||||||
|
JSON.stringify(value)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// sessionStorage may be unavailable; predicate treats missing as unknown.
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('probeHasResourceEvents error', error);
|
||||||
|
try {
|
||||||
|
window.sessionStorage.removeItem(HAS_RESOURCE_EVENTS_KEY);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// runtime configuration
|
// runtime configuration
|
||||||
export async function getInitialState(): Promise<{
|
export async function getInitialState(): Promise<{
|
||||||
fetchUserInfo: () => Promise<Global.UserInfo>;
|
fetchUserInfo: () => Promise<Global.UserInfo>;
|
||||||
currentUser?: Global.UserInfo;
|
currentUser?: Global.UserInfo;
|
||||||
pluginData?: Record<string, any>;
|
pluginData?: Record<string, any>;
|
||||||
hasKubernetesCluster?: boolean;
|
hasKubernetesCluster?: boolean;
|
||||||
|
hasResourceEvents?: boolean;
|
||||||
}> {
|
}> {
|
||||||
const { location } = history;
|
const { location } = history;
|
||||||
|
|
||||||
@@ -161,16 +194,19 @@ export async function getInitialState(): Promise<{
|
|||||||
getAppVersionInfo();
|
getAppVersionInfo();
|
||||||
|
|
||||||
if (![DEFAULT_ENTER_PAGE.login].includes(location.pathname)) {
|
if (![DEFAULT_ENTER_PAGE.login].includes(location.pathname)) {
|
||||||
const [userInfo, hasKubernetesCluster] = await Promise.all([
|
const [userInfo, hasKubernetesCluster, hasResourceEvents] =
|
||||||
fetchUserInfo(),
|
await Promise.all([
|
||||||
probeHasKubernetesCluster()
|
fetchUserInfo(),
|
||||||
]);
|
probeHasKubernetesCluster(),
|
||||||
|
probeHasResourceEvents()
|
||||||
|
]);
|
||||||
checkDefaultPage(userInfo);
|
checkDefaultPage(userInfo);
|
||||||
return {
|
return {
|
||||||
fetchUserInfo,
|
fetchUserInfo,
|
||||||
currentUser: userInfo,
|
currentUser: userInfo,
|
||||||
pluginData,
|
pluginData,
|
||||||
hasKubernetesCluster
|
hasKubernetesCluster,
|
||||||
|
hasResourceEvents
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ const PieChart: React.FC<PieChartProps> = ({
|
|||||||
color: colors,
|
color: colors,
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'item',
|
trigger: 'item',
|
||||||
|
// Keep the tooltip inside the chart box — the donut sits on the left, so
|
||||||
|
// a left-slice tooltip could otherwise spill out and get hidden behind
|
||||||
|
// the side menu.
|
||||||
|
confine: true,
|
||||||
backgroundColor: token.colorBgElevated,
|
backgroundColor: token.colorBgElevated,
|
||||||
borderColor: 'transparent',
|
borderColor: 'transparent',
|
||||||
formatter: (params: any) => {
|
formatter: (params: any) => {
|
||||||
@@ -69,6 +73,9 @@ const PieChart: React.FC<PieChartProps> = ({
|
|||||||
itemWidth: 8,
|
itemWidth: 8,
|
||||||
itemHeight: 8,
|
itemHeight: 8,
|
||||||
itemGap: 10,
|
itemGap: 10,
|
||||||
|
// Long names are truncated in the legend; hovering shows the full name
|
||||||
|
// in a tooltip.
|
||||||
|
tooltip: { show: true },
|
||||||
textStyle: {
|
textStyle: {
|
||||||
color: token.colorTextTertiary,
|
color: token.colorTextTertiary,
|
||||||
overflow: 'truncate',
|
overflow: 'truncate',
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ export interface ResourceBreakdownRequest {
|
|||||||
| 'volume'
|
| 'volume'
|
||||||
| null;
|
| null;
|
||||||
granularity?: 'hour' | 'day' | 'week' | 'month';
|
granularity?: 'hour' | 'day' | 'week' | 'month';
|
||||||
|
// Server-side sort: a metric key (e.g. gpu_hours / instance_hours) +
|
||||||
|
// direction. Defaults on the server when omitted.
|
||||||
|
order_by?: string;
|
||||||
|
descending?: boolean;
|
||||||
page?: number;
|
page?: number;
|
||||||
perPage?: number;
|
perPage?: number;
|
||||||
}
|
}
|
||||||
@@ -66,6 +70,21 @@ export interface ResourceBreakdownItem extends ResourceBreakdownSummary {
|
|||||||
user_id?: number;
|
user_id?: number;
|
||||||
user_name?: string;
|
user_name?: string;
|
||||||
last_active?: string;
|
last_active?: string;
|
||||||
|
// Instance-type rows carry the flavor's display fields (pretty product name +
|
||||||
|
// per-card specs) so the UI matches the GPU Instances list.
|
||||||
|
product?: string;
|
||||||
|
unit_cpu_milli?: number;
|
||||||
|
unit_memory_mib?: number;
|
||||||
|
vram_mib?: number;
|
||||||
|
// Per-instance rows also carry the card count + ephemeral disk so the
|
||||||
|
// Instances table can render "<product> x <count>" + the spec popover.
|
||||||
|
gpu_count?: number;
|
||||||
|
ephemeral_mib?: number;
|
||||||
|
local_storage_mib?: number;
|
||||||
|
persistent_mib?: number;
|
||||||
|
// Storage volume rows: provisioned capacity + storage type.
|
||||||
|
storage_type?: string;
|
||||||
|
capacity_mib?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResourceBreakdownResponse {
|
export interface ResourceBreakdownResponse {
|
||||||
@@ -117,6 +136,8 @@ export interface ResourceEventItem {
|
|||||||
event_type: string;
|
event_type: string;
|
||||||
event_message?: string;
|
event_message?: string;
|
||||||
phase?: string;
|
phase?: string;
|
||||||
|
// status.phaseMessage at event time — the detail behind a failure phase.
|
||||||
|
phase_message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResourceEventsResponse {
|
export interface ResourceEventsResponse {
|
||||||
@@ -179,6 +200,18 @@ interface ServerBreakdownItem {
|
|||||||
date?: string | null;
|
date?: string | null;
|
||||||
sku?: string | null;
|
sku?: string | null;
|
||||||
deleted?: boolean | null;
|
deleted?: boolean | null;
|
||||||
|
dimensions?: {
|
||||||
|
product?: string | null;
|
||||||
|
unit_cpu_milli?: number | null;
|
||||||
|
unit_memory_mib?: number | null;
|
||||||
|
vram_mib?: number | null;
|
||||||
|
gpu_count?: number | null;
|
||||||
|
ephemeral_mib?: number | null;
|
||||||
|
local_storage_mib?: number | null;
|
||||||
|
persistent_mib?: number | null;
|
||||||
|
storage_type?: string | null;
|
||||||
|
capacity_mib?: number | null;
|
||||||
|
} | null;
|
||||||
metrics: ServerMetrics;
|
metrics: ServerMetrics;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,6 +304,23 @@ function flattenItem(
|
|||||||
if (!flat.gpu_type && it.sku) {
|
if (!flat.gpu_type && it.sku) {
|
||||||
flat.gpu_type = it.sku ?? undefined;
|
flat.gpu_type = it.sku ?? undefined;
|
||||||
}
|
}
|
||||||
|
// Instance-type rows carry flavor display fields (pretty product + per-card
|
||||||
|
// specs) so the UI can render them like the GPU Instances list.
|
||||||
|
const dims = it.dimensions;
|
||||||
|
if (dims) {
|
||||||
|
if (dims.product) flat.product = dims.product;
|
||||||
|
if (dims.unit_cpu_milli != null) flat.unit_cpu_milli = dims.unit_cpu_milli;
|
||||||
|
if (dims.unit_memory_mib != null)
|
||||||
|
flat.unit_memory_mib = dims.unit_memory_mib;
|
||||||
|
if (dims.vram_mib != null) flat.vram_mib = dims.vram_mib;
|
||||||
|
if (dims.gpu_count != null) flat.gpu_count = dims.gpu_count;
|
||||||
|
if (dims.ephemeral_mib != null) flat.ephemeral_mib = dims.ephemeral_mib;
|
||||||
|
if (dims.local_storage_mib != null)
|
||||||
|
flat.local_storage_mib = dims.local_storage_mib;
|
||||||
|
if (dims.persistent_mib != null) flat.persistent_mib = dims.persistent_mib;
|
||||||
|
if (dims.storage_type) flat.storage_type = dims.storage_type;
|
||||||
|
if (dims.capacity_mib != null) flat.capacity_mib = dims.capacity_mib;
|
||||||
|
}
|
||||||
return flat;
|
return flat;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,6 +351,8 @@ function toServerRequest(data: ResourceBreakdownRequest) {
|
|||||||
...(creator_ids?.length ? { creator_ids } : {}),
|
...(creator_ids?.length ? { creator_ids } : {}),
|
||||||
...(instance_ids?.length ? { instance_ids } : {}),
|
...(instance_ids?.length ? { instance_ids } : {}),
|
||||||
...(volume_ids?.length ? { volume_ids } : {}),
|
...(volume_ids?.length ? { volume_ids } : {}),
|
||||||
|
...(data.order_by ? { order_by: data.order_by } : {}),
|
||||||
|
...(data.descending !== undefined ? { descending: data.descending } : {}),
|
||||||
page: data.page ?? 1,
|
page: data.page ?? 1,
|
||||||
perPage: data.perPage ?? 20
|
perPage: data.perPage ?? 20
|
||||||
},
|
},
|
||||||
@@ -341,8 +393,8 @@ export async function queryStorageBreakdown(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function queryResourceEvents(data: {
|
export async function queryResourceEvents(data: {
|
||||||
start_date: string;
|
start_date?: string;
|
||||||
end_date: string;
|
end_date?: string;
|
||||||
scope?: 'self' | 'all';
|
scope?: 'self' | 'all';
|
||||||
filters?: ResourceUsageFilters;
|
filters?: ResourceUsageFilters;
|
||||||
resource_types?: string[];
|
resource_types?: string[];
|
||||||
@@ -370,6 +422,7 @@ export async function queryResourceEvents(data: {
|
|||||||
export interface ResourceFilterOption {
|
export interface ResourceFilterOption {
|
||||||
id: number;
|
id: number;
|
||||||
label: string;
|
label: string;
|
||||||
|
deleted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResourceFilterMeta {
|
export interface ResourceFilterMeta {
|
||||||
@@ -435,7 +488,9 @@ export async function queryUsageSummary(params: {
|
|||||||
distribution = byType.items
|
distribution = byType.items
|
||||||
.filter((i) => (i.gpu_hours || 0) > 0)
|
.filter((i) => (i.gpu_hours || 0) > 0)
|
||||||
.map((i) => ({
|
.map((i) => ({
|
||||||
label: i.gpu_type || 'unknown',
|
// Pretty product name (e.g. "NVIDIA-GeForce-RTX-5090-D") when known,
|
||||||
|
// else the raw flavor slug — matches the GPU Instances list.
|
||||||
|
label: i.product || i.gpu_type || 'unknown',
|
||||||
value: i.gpu_hours,
|
value: i.gpu_hours,
|
||||||
percentage: total > 0 ? (i.gpu_hours / total) * 100 : 0
|
percentage: total > 0 ? (i.gpu_hours / total) * 100 : 0
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -50,6 +50,14 @@ const ExportData: React.FC<{
|
|||||||
} = props || {};
|
} = props || {};
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
|
// Members are forced to self scope, where the backend forbids grouping by
|
||||||
|
// user (privacy) — including it 403s the export request. Drop the user
|
||||||
|
// dimension (and its column) when we can't group by it.
|
||||||
|
const canGroupByUser = initialScope !== 'self';
|
||||||
|
const exportGroupBy = canGroupByUser
|
||||||
|
? ['date', 'user', 'route', 'api_key']
|
||||||
|
: ['date', 'route', 'api_key'];
|
||||||
|
|
||||||
const [pageParams, setPageParams] = React.useState<{
|
const [pageParams, setPageParams] = React.useState<{
|
||||||
page: number;
|
page: number;
|
||||||
perPage: number;
|
perPage: number;
|
||||||
@@ -84,7 +92,7 @@ const ExportData: React.FC<{
|
|||||||
...pageParams,
|
...pageParams,
|
||||||
granularity: 'day',
|
granularity: 'day',
|
||||||
sort_by: '-date',
|
sort_by: '-date',
|
||||||
group_by: ['date', 'user', 'route', 'api_key'],
|
group_by: exportGroupBy,
|
||||||
filters: nextFilters,
|
filters: nextFilters,
|
||||||
scope: initialScope,
|
scope: initialScope,
|
||||||
start_date: nextCommonFilters.start_date,
|
start_date: nextCommonFilters.start_date,
|
||||||
@@ -192,6 +200,14 @@ const ExportData: React.FC<{
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Hide the User column when we can't group by user (self scope) — it'd be
|
||||||
|
// empty otherwise.
|
||||||
|
const visibleColumns = canGroupByUser
|
||||||
|
? exportTableColumns
|
||||||
|
: exportTableColumns.filter(
|
||||||
|
(c) => !(Array.isArray(c.dataIndex) && c.dataIndex[0] === 'user')
|
||||||
|
);
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
exportJsonToExcel({
|
exportJsonToExcel({
|
||||||
fileName: `usage_export_${commonFilters.start_date}_${commonFilters.end_date}.xlsx`,
|
fileName: `usage_export_${commonFilters.start_date}_${commonFilters.end_date}.xlsx`,
|
||||||
@@ -211,7 +227,7 @@ const ExportData: React.FC<{
|
|||||||
sheetName: 'usage',
|
sheetName: 'usage',
|
||||||
fields: [
|
fields: [
|
||||||
'date',
|
'date',
|
||||||
'user',
|
...(canGroupByUser ? ['user'] : []),
|
||||||
'route',
|
'route',
|
||||||
'api_key',
|
'api_key',
|
||||||
'input_tokens',
|
'input_tokens',
|
||||||
@@ -252,7 +268,7 @@ const ExportData: React.FC<{
|
|||||||
page,
|
page,
|
||||||
perPage: pageSize,
|
perPage: pageSize,
|
||||||
granularity: 'day',
|
granularity: 'day',
|
||||||
group_by: ['date', 'user', 'model', 'api_key'],
|
group_by: exportGroupBy,
|
||||||
filters,
|
filters,
|
||||||
sort_by: '-date',
|
sort_by: '-date',
|
||||||
scope: initialScope,
|
scope: initialScope,
|
||||||
@@ -271,7 +287,7 @@ const ExportData: React.FC<{
|
|||||||
fetchExportData({
|
fetchExportData({
|
||||||
...INITIAL_PAGE_PARAMS,
|
...INITIAL_PAGE_PARAMS,
|
||||||
granularity: 'day',
|
granularity: 'day',
|
||||||
group_by: ['date', 'user', 'route', 'api_key'],
|
group_by: exportGroupBy,
|
||||||
filters,
|
filters,
|
||||||
sort_by: '-date',
|
sort_by: '-date',
|
||||||
scope: initialScope,
|
scope: initialScope,
|
||||||
@@ -323,7 +339,7 @@ const ExportData: React.FC<{
|
|||||||
></FilterBar>
|
></FilterBar>
|
||||||
</div>
|
</div>
|
||||||
<Table
|
<Table
|
||||||
columns={exportTableColumns}
|
columns={visibleColumns}
|
||||||
className={'scroll-table'}
|
className={'scroll-table'}
|
||||||
tableLayout={'auto'}
|
tableLayout={'auto'}
|
||||||
style={{ width: '100%', marginTop: '16px', minHeight: 400 }}
|
style={{ width: '100%', marginTop: '16px', minHeight: 400 }}
|
||||||
|
|||||||
@@ -10,9 +10,10 @@
|
|||||||
* Talks to the new ``/usage/gpu-instances/{meta,breakdown}`` endpoints.
|
* Talks to the new ``/usage/gpu-instances/{meta,breakdown}`` endpoints.
|
||||||
*/
|
*/
|
||||||
import useCoolColors from '@/hooks/use-cool-colors';
|
import useCoolColors from '@/hooks/use-cool-colors';
|
||||||
|
import InstanceTypeCell from '@/pages/gpu-service/instances/components/instance-type-cell';
|
||||||
import { formatLargeNumber } from '@/utils';
|
import { formatLargeNumber } from '@/utils';
|
||||||
import { SimpleCard } from '@gpustack/core-ui';
|
import { SimpleCard } from '@gpustack/core-ui';
|
||||||
import { useAccess } from '@umijs/max';
|
import { useAccess, useIntl } from '@umijs/max';
|
||||||
import { Table, Tabs } from 'antd';
|
import { Table, Tabs } from 'antd';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
@@ -23,12 +24,18 @@ import {
|
|||||||
ResourceBreakdownResponse
|
ResourceBreakdownResponse
|
||||||
} from '../apis/resource';
|
} from '../apis/resource';
|
||||||
import useResourceMeta from '../hooks/use-resource-meta';
|
import useResourceMeta from '../hooks/use-resource-meta';
|
||||||
|
import {
|
||||||
|
instanceTypeLabel,
|
||||||
|
instanceTypeSections,
|
||||||
|
instanceTypeTitle
|
||||||
|
} from '../utils/format-instance-type';
|
||||||
import {
|
import {
|
||||||
bucketKey,
|
bucketKey,
|
||||||
generateBucketRange,
|
generateBucketRange,
|
||||||
Granularity
|
Granularity
|
||||||
} from '../utils/time-buckets';
|
} from '../utils/time-buckets';
|
||||||
import MetricChartCard from './metric-chart-card';
|
import MetricChartCard from './metric-chart-card';
|
||||||
|
import MetricLabel from './metric-label';
|
||||||
import ResourceExportData from './resource-export-data';
|
import ResourceExportData from './resource-export-data';
|
||||||
import ResourceFilterBar from './resource-filter-bar';
|
import ResourceFilterBar from './resource-filter-bar';
|
||||||
|
|
||||||
@@ -49,6 +56,7 @@ const TABLE_TABS: { key: GroupKey; label: string }[] = [
|
|||||||
|
|
||||||
const GpuInstancesTab: React.FC = () => {
|
const GpuInstancesTab: React.FC = () => {
|
||||||
const access = useAccess();
|
const access = useAccess();
|
||||||
|
const intl = useIntl();
|
||||||
// ``useCoolColors`` returns a memoized factory; resolve it once into a
|
// ``useCoolColors`` returns a memoized factory; resolve it once into a
|
||||||
// fixed 5-slot palette here so the rest of the component reads as
|
// fixed 5-slot palette here so the rest of the component reads as
|
||||||
// array access.
|
// array access.
|
||||||
@@ -85,6 +93,11 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
null
|
null
|
||||||
);
|
);
|
||||||
const [tablePage, setTablePage] = useState(1);
|
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' });
|
||||||
|
|
||||||
const baseRequest = (): Omit<ResourceBreakdownRequest, 'group_by'> => ({
|
const baseRequest = (): Omit<ResourceBreakdownRequest, 'group_by'> => ({
|
||||||
start_date: dateRange[0].format('YYYY-MM-DD'),
|
start_date: dateRange[0].format('YYYY-MM-DD'),
|
||||||
@@ -122,7 +135,9 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
const data = await queryGpuInstancesBreakdown({
|
const data = await queryGpuInstancesBreakdown({
|
||||||
...baseRequest(),
|
...baseRequest(),
|
||||||
group_by: activeTableTab,
|
group_by: activeTableTab,
|
||||||
page: tablePage
|
page: tablePage,
|
||||||
|
order_by: tableSort.field,
|
||||||
|
descending: tableSort.order === 'descend'
|
||||||
});
|
});
|
||||||
setTableData(data);
|
setTableData(data);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -142,6 +157,7 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
selectedInstances,
|
selectedInstances,
|
||||||
activeTableTab,
|
activeTableTab,
|
||||||
tablePage,
|
tablePage,
|
||||||
|
tableSort,
|
||||||
refreshKey
|
refreshKey
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -154,14 +170,24 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
label: formatLargeNumber(
|
label: formatLargeNumber(
|
||||||
Math.round((summary?.gpu_hours ?? 0) * 10) / 10
|
Math.round((summary?.gpu_hours ?? 0) * 10) / 10
|
||||||
) as string,
|
) as string,
|
||||||
value: 'GPU Hours',
|
value: (
|
||||||
|
<MetricLabel
|
||||||
|
text="GPU Hours"
|
||||||
|
tooltip="Instance running time weighted by GPU count: an instance with N GPUs running for H hours counts as N × H GPU-hours. Equal to Instance Hours when every instance uses a single GPU."
|
||||||
|
/>
|
||||||
|
),
|
||||||
color: coolColors[0]
|
color: coolColors[0]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: formatLargeNumber(
|
label: formatLargeNumber(
|
||||||
Math.round((summary?.instance_hours ?? 0) * 10) / 10
|
Math.round((summary?.instance_hours ?? 0) * 10) / 10
|
||||||
) as string,
|
) as string,
|
||||||
value: 'Instance Hours',
|
value: (
|
||||||
|
<MetricLabel
|
||||||
|
text="Instance Hours"
|
||||||
|
tooltip="Total running time summed across all instances, regardless of how many GPUs each uses. One instance running for 2 hours = 2 instance-hours."
|
||||||
|
/>
|
||||||
|
),
|
||||||
color: coolColors[1]
|
color: coolColors[1]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -215,18 +241,48 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
title: 'GPU Hours',
|
title: 'GPU Hours',
|
||||||
dataIndex: 'gpu_hours',
|
dataIndex: 'gpu_hours',
|
||||||
key: 'gpu_hours',
|
key: 'gpu_hours',
|
||||||
|
sorter: true,
|
||||||
|
sortOrder: tableSort.field === 'gpu_hours' ? tableSort.order : null,
|
||||||
render: (v: number) => (v ?? 0).toFixed(2)
|
render: (v: number) => (v ?? 0).toFixed(2)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Instance Hours',
|
title: 'Instance Hours',
|
||||||
dataIndex: 'instance_hours',
|
dataIndex: 'instance_hours',
|
||||||
key: 'instance_hours',
|
key: 'instance_hours',
|
||||||
|
sorter: true,
|
||||||
|
sortOrder:
|
||||||
|
tableSort.field === 'instance_hours' ? tableSort.order : null,
|
||||||
render: (v: number) => (v ?? 0).toFixed(2)
|
render: (v: number) => (v ?? 0).toFixed(2)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
// Instance Types breakdown: just the pretty product name (or flavor slug
|
||||||
|
// for older rows) — no spec sub-line.
|
||||||
|
const instanceTypeColType = {
|
||||||
|
title: 'Instance Type',
|
||||||
|
dataIndex: 'gpu_type',
|
||||||
|
key: 'gpu_type',
|
||||||
|
render: (_v: string, row: ResourceBreakdownItem) => instanceTypeLabel(row)
|
||||||
|
};
|
||||||
|
// Instances breakdown: render exactly like the GPU Instances list —
|
||||||
|
// "<product> x <count>" plus the categorized spec popover behind the icon.
|
||||||
|
const instanceTypeColInstance = {
|
||||||
|
title: 'Instance Type',
|
||||||
|
dataIndex: 'gpu_type',
|
||||||
|
key: 'gpu_type',
|
||||||
|
render: (_v: string, row: ResourceBreakdownItem) => (
|
||||||
|
<InstanceTypeCell
|
||||||
|
title={instanceTypeTitle(row)}
|
||||||
|
name={row.instance_name}
|
||||||
|
sections={instanceTypeSections(row, {
|
||||||
|
vram: intl.formatMessage({ id: 'gpuservice.instance.memory' }),
|
||||||
|
disk: intl.formatMessage({ id: 'gpuservice.instance.disk' })
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
};
|
||||||
if (activeTableTab === 'gpu_type') {
|
if (activeTableTab === 'gpu_type') {
|
||||||
return [
|
return [
|
||||||
{ title: 'Instance Type', dataIndex: 'gpu_type', key: 'gpu_type' },
|
instanceTypeColType,
|
||||||
...baseValueCols,
|
...baseValueCols,
|
||||||
{
|
{
|
||||||
title: 'Active Instances',
|
title: 'Active Instances',
|
||||||
@@ -239,7 +295,7 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
if (activeTableTab === 'instance') {
|
if (activeTableTab === 'instance') {
|
||||||
return [
|
return [
|
||||||
{ title: 'Instance', dataIndex: 'instance_name', key: 'instance_name' },
|
{ title: 'Instance', dataIndex: 'instance_name', key: 'instance_name' },
|
||||||
{ title: 'Instance Type', dataIndex: 'gpu_type', key: 'gpu_type' },
|
instanceTypeColInstance,
|
||||||
...baseValueCols,
|
...baseValueCols,
|
||||||
{ title: 'Last Active', dataIndex: 'last_active', key: 'last_active' }
|
{ title: 'Last Active', dataIndex: 'last_active', key: 'last_active' }
|
||||||
];
|
];
|
||||||
@@ -250,7 +306,7 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
...baseValueCols,
|
...baseValueCols,
|
||||||
{ title: 'Last Active', dataIndex: 'last_active', key: 'last_active' }
|
{ title: 'Last Active', dataIndex: 'last_active', key: 'last_active' }
|
||||||
];
|
];
|
||||||
}, [activeTableTab]);
|
}, [activeTableTab, tableSort, intl]);
|
||||||
|
|
||||||
const tableRows: ResourceBreakdownItem[] = tableData?.items ?? [];
|
const tableRows: ResourceBreakdownItem[] = tableData?.items ?? [];
|
||||||
|
|
||||||
@@ -377,6 +433,24 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
}
|
}
|
||||||
dataSource={tableRows}
|
dataSource={tableRows}
|
||||||
columns={tableColumns as any}
|
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={{
|
pagination={{
|
||||||
current: tablePage,
|
current: tablePage,
|
||||||
pageSize: tableData?.pagination.perPage ?? 50,
|
pageSize: tableData?.pagination.perPage ?? 50,
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { QuestionCircleOutlined } from '@ant-design/icons';
|
||||||
|
import { Tooltip } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KPI card caption with a trailing help icon. The metering metrics
|
||||||
|
* (GPU-Hours vs Instance-Hours, GB-Days vs GB-Hours) aren't self-evident, so
|
||||||
|
* each label carries a one-line explanation behind the standard question-mark.
|
||||||
|
*/
|
||||||
|
const MetricLabel: React.FC<{
|
||||||
|
text: string;
|
||||||
|
tooltip: React.ReactNode;
|
||||||
|
}> = ({ text, tooltip }) => (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
|
||||||
|
{text}
|
||||||
|
<Tooltip title={tooltip} styles={{ root: { maxWidth: 320 } }}>
|
||||||
|
<QuestionCircleOutlined
|
||||||
|
className="m-l-5"
|
||||||
|
style={{ cursor: 'help', opacity: 0.6 }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default MetricLabel;
|
||||||
@@ -20,9 +20,16 @@ import {
|
|||||||
import useResourceMeta from '../hooks/use-resource-meta';
|
import useResourceMeta from '../hooks/use-resource-meta';
|
||||||
import ResourceFilterBar from './resource-filter-bar';
|
import ResourceFilterBar from './resource-filter-bar';
|
||||||
|
|
||||||
|
// Users only ever see "Storage" in the product — never "Persistent Volume".
|
||||||
|
const RESOURCE_TYPE_LABELS: Record<string, string> = {
|
||||||
|
gpu_instance: 'GPU Instance',
|
||||||
|
cpu_instance: 'CPU Instance',
|
||||||
|
persistent_volume: 'Storage'
|
||||||
|
};
|
||||||
|
|
||||||
const RESOURCE_TYPE_OPTIONS = [
|
const RESOURCE_TYPE_OPTIONS = [
|
||||||
{ value: 'gpu_instance', label: 'GPU Instance' },
|
{ value: 'gpu_instance', label: RESOURCE_TYPE_LABELS.gpu_instance },
|
||||||
{ value: 'persistent_volume', label: 'Persistent Volume' }
|
{ value: 'persistent_volume', label: RESOURCE_TYPE_LABELS.persistent_volume }
|
||||||
];
|
];
|
||||||
|
|
||||||
const EVENT_TYPE_OPTIONS = [
|
const EVENT_TYPE_OPTIONS = [
|
||||||
@@ -53,6 +60,13 @@ const EVENT_LABEL: Record<string, string> = Object.fromEntries(
|
|||||||
EVENT_TYPE_OPTIONS.map((o) => [o.value, o.label])
|
EVENT_TYPE_OPTIONS.map((o) => [o.value, o.label])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Humanize a failure phase enum for display, e.g. "SSHPublicKeyCreateFailed" →
|
||||||
|
// "SSH Public Key Create Failed" (fallback when the backend has no detail).
|
||||||
|
const humanizePhase = (phase: string): string =>
|
||||||
|
phase
|
||||||
|
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
||||||
|
.replace(/([a-z\d])([A-Z])/g, '$1 $2');
|
||||||
|
|
||||||
const ResourceEvents: React.FC = () => {
|
const ResourceEvents: React.FC = () => {
|
||||||
const access = useAccess();
|
const access = useAccess();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
@@ -110,8 +124,7 @@ const ResourceEvents: React.FC = () => {
|
|||||||
title: 'Resource',
|
title: 'Resource',
|
||||||
dataIndex: 'resource_type',
|
dataIndex: 'resource_type',
|
||||||
key: 'resource_type',
|
key: 'resource_type',
|
||||||
render: (v: string) =>
|
render: (v: string) => RESOURCE_TYPE_LABELS[v] || v,
|
||||||
v === 'gpu_instance' ? 'GPU Instance' : 'Persistent Volume',
|
|
||||||
width: 160
|
width: 160
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -128,25 +141,22 @@ const ResourceEvents: React.FC = () => {
|
|||||||
),
|
),
|
||||||
width: 180
|
width: 180
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: 'Phase',
|
|
||||||
dataIndex: 'phase',
|
|
||||||
key: 'phase',
|
|
||||||
width: 140
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Creator',
|
|
||||||
dataIndex: 'creator_name',
|
|
||||||
key: 'creator_name',
|
|
||||||
render: (v?: string, row?: ResourceEventItem) =>
|
|
||||||
v ?? (row?.creator_id ? `principal:${row.creator_id}` : '-'),
|
|
||||||
width: 160
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: 'Message',
|
title: 'Message',
|
||||||
dataIndex: 'event_message',
|
dataIndex: 'event_message',
|
||||||
key: 'event_message',
|
key: 'event_message',
|
||||||
render: (v?: string) => v ?? '-'
|
render: (v?: string, row?: ResourceEventItem) => {
|
||||||
|
// A failure phase (…Failed) is the one thing not already shown in the
|
||||||
|
// Event column — surface it (with its detail) as an error message.
|
||||||
|
if (row?.phase && /failed$/i.test(row.phase)) {
|
||||||
|
return (
|
||||||
|
<span style={{ color: 'var(--ant-color-error)' }}>
|
||||||
|
{row.phase_message || humanizePhase(row.phase)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return v ?? '-';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[]
|
[]
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const DefaultDateConfig = {
|
|||||||
interface SelectOption {
|
interface SelectOption {
|
||||||
value: number;
|
value: number;
|
||||||
label: string;
|
label: string;
|
||||||
|
deleted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional per-tab entity filter (GPU instance on the GPU tab / volume on the
|
// Optional per-tab entity filter (GPU instance on the GPU tab / volume on the
|
||||||
@@ -139,6 +140,14 @@ const ResourceFilterBar: React.FC<ResourceFilterBarProps> = (props) => {
|
|||||||
const userOptionRender = (option: any) => (
|
const userOptionRender = (option: any) => (
|
||||||
<span className="flex-center gap-4">
|
<span className="flex-center gap-4">
|
||||||
<AutoTooltip ghost>{option?.data?.label}</AutoTooltip>
|
<AutoTooltip ghost>{option?.data?.label}</AutoTooltip>
|
||||||
|
{option?.data?.deleted && (
|
||||||
|
<span
|
||||||
|
className="text-tertiary"
|
||||||
|
style={{ fontSize: 12, marginRight: 4 }}
|
||||||
|
>
|
||||||
|
[{intl.formatMessage({ id: 'usage.table.deleted' })}]
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
Granularity
|
Granularity
|
||||||
} from '../utils/time-buckets';
|
} from '../utils/time-buckets';
|
||||||
import MetricChartCard from './metric-chart-card';
|
import MetricChartCard from './metric-chart-card';
|
||||||
|
import MetricLabel from './metric-label';
|
||||||
import ResourceExportData from './resource-export-data';
|
import ResourceExportData from './resource-export-data';
|
||||||
import ResourceFilterBar from './resource-filter-bar';
|
import ResourceFilterBar from './resource-filter-bar';
|
||||||
|
|
||||||
@@ -78,6 +79,11 @@ const StorageTab: React.FC = () => {
|
|||||||
null
|
null
|
||||||
);
|
);
|
||||||
const [tablePage, setTablePage] = useState(1);
|
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' });
|
||||||
|
|
||||||
const baseRequest = (): Omit<ResourceBreakdownRequest, 'group_by'> => ({
|
const baseRequest = (): Omit<ResourceBreakdownRequest, 'group_by'> => ({
|
||||||
start_date: dateRange[0].format('YYYY-MM-DD'),
|
start_date: dateRange[0].format('YYYY-MM-DD'),
|
||||||
@@ -107,12 +113,21 @@ const StorageTab: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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 () => {
|
const fetchTable = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await queryStorageBreakdown({
|
const data = await queryStorageBreakdown({
|
||||||
...baseRequest(),
|
...baseRequest(),
|
||||||
group_by: activeTableTab,
|
group_by: activeTableTab,
|
||||||
page: tablePage
|
page: tablePage,
|
||||||
|
order_by: ORDER_BY_KEY[tableSort.field],
|
||||||
|
descending: tableSort.order === 'descend'
|
||||||
});
|
});
|
||||||
setTableData(data);
|
setTableData(data);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -132,6 +147,7 @@ const StorageTab: React.FC = () => {
|
|||||||
selectedVolumes,
|
selectedVolumes,
|
||||||
activeTableTab,
|
activeTableTab,
|
||||||
tablePage,
|
tablePage,
|
||||||
|
tableSort,
|
||||||
refreshKey
|
refreshKey
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -142,14 +158,24 @@ const StorageTab: React.FC = () => {
|
|||||||
label: formatLargeNumber(
|
label: formatLargeNumber(
|
||||||
Math.round((summary?.storage_gb_days ?? 0) * 10) / 10
|
Math.round((summary?.storage_gb_days ?? 0) * 10) / 10
|
||||||
) as string,
|
) as string,
|
||||||
value: 'GB-Days',
|
value: (
|
||||||
|
<MetricLabel
|
||||||
|
text="GB-Days"
|
||||||
|
tooltip="Storage capacity integrated over time, in GB × days: 10 GB kept for 5 days = 50 GB-days. (= GB-Hours ÷ 24)"
|
||||||
|
/>
|
||||||
|
),
|
||||||
color: coolColors[0]
|
color: coolColors[0]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: formatLargeNumber(
|
label: formatLargeNumber(
|
||||||
Math.round((summary?.storage_gb_hours ?? 0) * 10) / 10
|
Math.round((summary?.storage_gb_hours ?? 0) * 10) / 10
|
||||||
) as string,
|
) as string,
|
||||||
value: 'GB-Hours',
|
value: (
|
||||||
|
<MetricLabel
|
||||||
|
text="GB-Hours"
|
||||||
|
tooltip="Storage capacity integrated over time, in GB × hours: 10 GB kept for 5 hours = 50 GB-hours."
|
||||||
|
/>
|
||||||
|
),
|
||||||
color: coolColors[1]
|
color: coolColors[1]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -200,12 +226,18 @@ const StorageTab: React.FC = () => {
|
|||||||
title: 'GB-Days',
|
title: 'GB-Days',
|
||||||
dataIndex: 'storage_gb_days',
|
dataIndex: 'storage_gb_days',
|
||||||
key: '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)
|
render: (v: number) => (v ?? 0).toFixed(2)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'GB-Hours',
|
title: 'GB-Hours',
|
||||||
dataIndex: 'storage_gb_hours',
|
dataIndex: 'storage_gb_hours',
|
||||||
key: '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)
|
render: (v: number) => (v ?? 0).toFixed(2)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -216,6 +248,19 @@ const StorageTab: React.FC = () => {
|
|||||||
dataIndex: 'volume_name',
|
dataIndex: 'volume_name',
|
||||||
key: 'volume_name'
|
key: 'volume_name'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Type',
|
||||||
|
dataIndex: 'storage_type',
|
||||||
|
key: 'storage_type',
|
||||||
|
render: (_v: string, row: ResourceBreakdownItem) =>
|
||||||
|
row.storage_type || row.gpu_type || '-'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Capacity',
|
||||||
|
dataIndex: 'capacity_mib',
|
||||||
|
key: 'capacity_mib',
|
||||||
|
render: (v?: number) => (v ? `${Math.round(v / 1024)}GB` : '-')
|
||||||
|
},
|
||||||
...valueCols,
|
...valueCols,
|
||||||
{ title: 'Last Active', dataIndex: 'last_active', key: 'last_active' }
|
{ title: 'Last Active', dataIndex: 'last_active', key: 'last_active' }
|
||||||
];
|
];
|
||||||
@@ -230,7 +275,7 @@ const StorageTab: React.FC = () => {
|
|||||||
},
|
},
|
||||||
{ title: 'Last Active', dataIndex: 'last_active', key: 'last_active' }
|
{ title: 'Last Active', dataIndex: 'last_active', key: 'last_active' }
|
||||||
];
|
];
|
||||||
}, [activeTableTab]);
|
}, [activeTableTab, tableSort]);
|
||||||
|
|
||||||
const tableRows: ResourceBreakdownItem[] = tableData?.items ?? [];
|
const tableRows: ResourceBreakdownItem[] = tableData?.items ?? [];
|
||||||
|
|
||||||
@@ -353,6 +398,27 @@ const StorageTab: React.FC = () => {
|
|||||||
}
|
}
|
||||||
dataSource={tableRows}
|
dataSource={tableRows}
|
||||||
columns={tableColumns as any}
|
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={{
|
pagination={{
|
||||||
current: tablePage,
|
current: tablePage,
|
||||||
pageSize: tableData?.pagination.perPage ?? 50,
|
pageSize: tableData?.pagination.perPage ?? 50,
|
||||||
|
|||||||
@@ -423,6 +423,10 @@ const SummaryTab: React.FC = () => {
|
|||||||
headline={
|
headline={
|
||||||
<>
|
<>
|
||||||
<Stat value={fmt(summary?.gpu_hours)} label="GPU Hours" />
|
<Stat value={fmt(summary?.gpu_hours)} label="GPU Hours" />
|
||||||
|
<Stat
|
||||||
|
value={fmt(summary?.instance_hours)}
|
||||||
|
label="Instance Hours"
|
||||||
|
/>
|
||||||
<Stat
|
<Stat
|
||||||
value={computeSum?.active_instances ?? 0}
|
value={computeSum?.active_instances ?? 0}
|
||||||
label="Active Instances"
|
label="Active Instances"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
export interface SelectOption {
|
export interface SelectOption {
|
||||||
value: number;
|
value: number;
|
||||||
label: string;
|
label: string;
|
||||||
|
deleted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResourceMetaOptions {
|
export interface ResourceMetaOptions {
|
||||||
@@ -22,7 +23,7 @@ const EMPTY: ResourceMetaOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toOptions = (items: ResourceFilterOption[]): SelectOption[] =>
|
const toOptions = (items: ResourceFilterOption[]): SelectOption[] =>
|
||||||
items.map((i) => ({ value: i.id, label: i.label }));
|
items.map((i) => ({ value: i.id, label: i.label, deleted: i.deleted }));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads the resource tabs' filter dropdown sources in one call:
|
* Loads the resource tabs' filter dropdown sources in one call:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
* The previous implementation lived in this file; it now lives in
|
* The previous implementation lived in this file; it now lives in
|
||||||
* ``components/token-tab.tsx`` so we can host it as a tab pane.
|
* ``components/token-tab.tsx`` so we can host it as a tab pane.
|
||||||
*/
|
*/
|
||||||
|
import { useAccess } from '@umijs/max';
|
||||||
import { Tabs, TabsProps } from 'antd';
|
import { Tabs, TabsProps } from 'antd';
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import GpuInstancesTab from './components/gpu-instances-tab';
|
import GpuInstancesTab from './components/gpu-instances-tab';
|
||||||
@@ -22,6 +23,7 @@ import SummaryTab from './components/summary-tab';
|
|||||||
import TokenTab from './components/token-tab';
|
import TokenTab from './components/token-tab';
|
||||||
|
|
||||||
const Usage: React.FC = () => {
|
const Usage: React.FC = () => {
|
||||||
|
const access = useAccess();
|
||||||
// Land on the cross-resource Summary by default.
|
// Land on the cross-resource Summary by default.
|
||||||
const [activeKey, setActiveKey] = useState<string>('summary');
|
const [activeKey, setActiveKey] = useState<string>('summary');
|
||||||
|
|
||||||
@@ -56,6 +58,12 @@ const Usage: React.FC = () => {
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
activeKey={activeKey}
|
activeKey={activeKey}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Instance-type display helpers — render the Usage "Instance Type" the same way
|
||||||
|
* the GPU Instances list does: a pretty product name (e.g.
|
||||||
|
* "NVIDIA-GeForce-RTX-5090-D") plus a per-card spec line, instead of the raw
|
||||||
|
* kueue flavor slug.
|
||||||
|
*
|
||||||
|
* The product name + per-card specs ride on the breakdown rows via
|
||||||
|
* ``dimensions`` (instance-type / per-instance groupings only); older rows that
|
||||||
|
* predate the enrichment fall back to the flavor slug (``gpu_type``).
|
||||||
|
*/
|
||||||
|
import { InstanceTypeSection } from '@/pages/gpu-service/instances/components/instance-type-cell';
|
||||||
|
import { formatMemoryDisplay } from '@/pages/gpu-service/instances/config';
|
||||||
|
import { ResourceBreakdownItem } from '../apis/resource';
|
||||||
|
|
||||||
|
// Primary label: GPU product name when known, else the flavor slug.
|
||||||
|
export const instanceTypeLabel = (
|
||||||
|
row?: Partial<ResourceBreakdownItem>
|
||||||
|
): string => row?.product || row?.gpu_type || '-';
|
||||||
|
|
||||||
|
// Round to ≤2 decimals, stripping trailing zeros, so fractional CPU allocations
|
||||||
|
// (e.g. 0.5C / 500m) aren't misrounded up to "1C". Memory uses the shared
|
||||||
|
// formatMemoryDisplay so sizes match the GPU Instances list exactly.
|
||||||
|
const fmt = (n: number): number => parseFloat(n.toFixed(2));
|
||||||
|
|
||||||
|
// Secondary spec line "18C · 54GB RAM · 31GB VRAM" (per card). Storage is
|
||||||
|
// intentionally excluded — it's user-customizable and not part of the type.
|
||||||
|
// Empty string when no specs are known (fall back to label only).
|
||||||
|
export const instanceTypeSpecs = (
|
||||||
|
row?: Partial<ResourceBreakdownItem>
|
||||||
|
): string => {
|
||||||
|
if (!row) return '';
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (row.unit_cpu_milli) {
|
||||||
|
parts.push(`${fmt(row.unit_cpu_milli / 1000)}C`);
|
||||||
|
}
|
||||||
|
if (row.unit_memory_mib) {
|
||||||
|
parts.push(`${formatMemoryDisplay(row.unit_memory_mib)} RAM`);
|
||||||
|
}
|
||||||
|
if (row.vram_mib) {
|
||||||
|
parts.push(`${formatMemoryDisplay(row.vram_mib)} VRAM`);
|
||||||
|
}
|
||||||
|
return parts.join(' · ');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Per-instance title for the Instances table: "<product> x <count>", matching
|
||||||
|
// the GPU Instances list (count carried in dimensions per instance).
|
||||||
|
export const instanceTypeTitle = (
|
||||||
|
row?: Partial<ResourceBreakdownItem>
|
||||||
|
): string => {
|
||||||
|
const label = instanceTypeLabel(row);
|
||||||
|
return row?.gpu_count ? `${label} x ${row.gpu_count}` : label;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Spec-popover sections for the Instances table, fed to the shared
|
||||||
|
// InstanceTypeCell so it renders exactly like the GPU Instances list:
|
||||||
|
// GPU (Count / Instance Type / per-card VRAM), CPU + Memory as whole-instance
|
||||||
|
// totals (count × per-card, as the list shows), and the ephemeral data disk.
|
||||||
|
// Empty rows are dropped by the cell. ``labels`` carries the i18n VRAM / Disk
|
||||||
|
// captions so this util stays intl-free.
|
||||||
|
export const instanceTypeSections = (
|
||||||
|
row: Partial<ResourceBreakdownItem> | undefined,
|
||||||
|
labels: { vram: string; disk: string }
|
||||||
|
): InstanceTypeSection[] => {
|
||||||
|
if (!row) return [];
|
||||||
|
const count = row.gpu_count || 0;
|
||||||
|
const cpu =
|
||||||
|
row.unit_cpu_milli && count
|
||||||
|
? `${fmt((row.unit_cpu_milli / 1000) * count)}C`
|
||||||
|
: undefined;
|
||||||
|
// RAM is the whole-instance total (per-card × count), as the list shows.
|
||||||
|
const ram =
|
||||||
|
row.unit_memory_mib && count
|
||||||
|
? formatMemoryDisplay(row.unit_memory_mib * count)
|
||||||
|
: undefined;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
icon: 'icon-gpu',
|
||||||
|
name: 'GPU',
|
||||||
|
rows: [
|
||||||
|
['Count', count ? `${count}` : undefined],
|
||||||
|
['Instance Type', row.product],
|
||||||
|
[labels.vram, formatMemoryDisplay(row.vram_mib)]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ icon: 'icon-cpu', name: 'CPU', rows: [[null, cpu]] },
|
||||||
|
{ icon: 'icon-ram-02', name: 'Memory', rows: [[null, ram]] },
|
||||||
|
{
|
||||||
|
icon: 'icon-hard-disk',
|
||||||
|
name: labels.disk,
|
||||||
|
rows: [
|
||||||
|
['System', formatMemoryDisplay(row.local_storage_mib)],
|
||||||
|
['Data', formatMemoryDisplay(row.ephemeral_mib)],
|
||||||
|
['Persistent', formatMemoryDisplay(row.persistent_mib)]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user