feat(usage): add resource-usage API client, meta hook, and shared utils
The data layer the resource tabs build on: - apis/resource.ts: adapter over the unified metered_usage read API (resource/gpu-instances/storage/summary/events breakdowns), flattening the server's generic shape into the per-tab item shape. - hooks/use-resource-meta.ts: loads creators/instances/volumes filter options for the current scope. - utils/time-buckets.ts: day/week/month/hour bucket keys + range fill. - utils/export-breakdown.ts: derive Excel columns from antd table specs.
This commit is contained in:
@@ -0,0 +1,482 @@
|
|||||||
|
/**
|
||||||
|
* Resource Usage API client — adapter over the unified ``metered_usage``
|
||||||
|
* read API (``/usage/{resource,gpu-instances,storage,summary,events}``).
|
||||||
|
*
|
||||||
|
* The server returns a generic ``{ key, id, metrics:{...} }`` breakdown shape
|
||||||
|
* (one engine for every tab). This module flattens it into the per-tab item
|
||||||
|
* shape the components consume, maps the frontend ``group_by`` vocabulary onto
|
||||||
|
* the backend's (``gpu_type`` → ``instance_type``/sku), and derives the few
|
||||||
|
* convenience fields (``gpu_minutes``). Metrics the backend doesn't track
|
||||||
|
* (cpu/memory/ephemeral hours, dangling volumes) are left at 0 — the
|
||||||
|
* whole-machine SKU model meters runtime, not decomposed components.
|
||||||
|
*/
|
||||||
|
import { request } from '@umijs/max';
|
||||||
|
|
||||||
|
export interface ResourceUsageFilters {
|
||||||
|
creator_ids?: number[];
|
||||||
|
cluster_ids?: number[];
|
||||||
|
instance_ids?: number[];
|
||||||
|
gpu_types?: string[];
|
||||||
|
volume_ids?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceBreakdownRequest {
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
scope?: 'self' | 'all';
|
||||||
|
filters?: ResourceUsageFilters;
|
||||||
|
group_by?:
|
||||||
|
| 'date'
|
||||||
|
| 'resource_type'
|
||||||
|
| 'gpu_type'
|
||||||
|
| 'type'
|
||||||
|
| 'instance'
|
||||||
|
| 'user'
|
||||||
|
| 'volume'
|
||||||
|
| null;
|
||||||
|
granularity?: 'hour' | 'day' | 'week' | 'month';
|
||||||
|
page?: number;
|
||||||
|
perPage?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceBreakdownSummary {
|
||||||
|
gpu_hours: number;
|
||||||
|
gpu_minutes: number;
|
||||||
|
instance_hours: number;
|
||||||
|
cpu_hours: number;
|
||||||
|
memory_gb_hours: number;
|
||||||
|
ephemeral_gb_hours: number;
|
||||||
|
active_instances: number;
|
||||||
|
gpu_types_used: number;
|
||||||
|
active_users: number;
|
||||||
|
storage_gb_days: number;
|
||||||
|
storage_gb_hours: number;
|
||||||
|
active_volumes: number;
|
||||||
|
dangling_volumes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceBreakdownItem extends ResourceBreakdownSummary {
|
||||||
|
date?: string;
|
||||||
|
resource_type?: string;
|
||||||
|
gpu_type?: string;
|
||||||
|
instance_id?: number;
|
||||||
|
instance_name?: string;
|
||||||
|
volume_id?: number;
|
||||||
|
volume_name?: string;
|
||||||
|
user_id?: number;
|
||||||
|
user_name?: string;
|
||||||
|
last_active?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceBreakdownResponse {
|
||||||
|
summary: ResourceBreakdownSummary;
|
||||||
|
group_by?: string;
|
||||||
|
granularity?: string;
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
perPage: number;
|
||||||
|
total: number;
|
||||||
|
totalPage: number;
|
||||||
|
};
|
||||||
|
items: ResourceBreakdownItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageOption {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceUsageFilterOption {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceUsageMetaResponse {
|
||||||
|
metrics: UsageOption[];
|
||||||
|
granularities: UsageOption[];
|
||||||
|
group_bys: UsageOption[];
|
||||||
|
filters: {
|
||||||
|
creators?: ResourceUsageFilterOption[];
|
||||||
|
clusters?: ResourceUsageFilterOption[];
|
||||||
|
instances?: ResourceUsageFilterOption[];
|
||||||
|
gpu_types?: UsageOption[];
|
||||||
|
volumes?: ResourceUsageFilterOption[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceEventItem {
|
||||||
|
id: number;
|
||||||
|
occurred_at: string;
|
||||||
|
creator_id?: number;
|
||||||
|
creator_name?: string;
|
||||||
|
cluster_id?: number;
|
||||||
|
cluster_name?: string;
|
||||||
|
resource_type: string;
|
||||||
|
resource_id?: number;
|
||||||
|
resource_name: string;
|
||||||
|
event_type: string;
|
||||||
|
event_message?: string;
|
||||||
|
phase?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceEventsResponse {
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
perPage: number;
|
||||||
|
total: number;
|
||||||
|
totalPage: number;
|
||||||
|
};
|
||||||
|
items: ResourceEventItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SummaryResourceDistributionItem {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
percentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageSummaryResponse {
|
||||||
|
total_tokens: number;
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
token_active_users: number;
|
||||||
|
gpu_hours: number;
|
||||||
|
instance_hours: number;
|
||||||
|
active_instances: number;
|
||||||
|
storage_gb_days: number;
|
||||||
|
active_users: number;
|
||||||
|
distribution: SummaryResourceDistributionItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- endpoints -----------------------------------------------------------
|
||||||
|
|
||||||
|
const URL = {
|
||||||
|
RESOURCE_BREAKDOWN: '/usage/resource/breakdown',
|
||||||
|
GPU_BREAKDOWN: '/usage/gpu-instances/breakdown',
|
||||||
|
STORAGE_BREAKDOWN: '/usage/storage/breakdown',
|
||||||
|
EVENTS: '/usage/resource-events',
|
||||||
|
SUMMARY: '/usage/summary',
|
||||||
|
RESOURCE_META: '/usage/resource/meta'
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- server (generic) shapes ---------------------------------------------
|
||||||
|
|
||||||
|
interface ServerMetrics {
|
||||||
|
instance_hours?: number;
|
||||||
|
gpu_hours?: number;
|
||||||
|
gb_days?: number;
|
||||||
|
gb_hours?: number;
|
||||||
|
resources?: number;
|
||||||
|
active_users?: number;
|
||||||
|
last_active?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// gpu_type / type both mean the sku (Type) on the server.
|
||||||
|
|
||||||
|
interface ServerBreakdownItem {
|
||||||
|
key?: string | null;
|
||||||
|
id?: number | null;
|
||||||
|
date?: string | null;
|
||||||
|
sku?: string | null;
|
||||||
|
deleted?: boolean | null;
|
||||||
|
metrics: ServerMetrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerBreakdownResponse {
|
||||||
|
summary: ServerMetrics;
|
||||||
|
group_by?: string;
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
perPage: number;
|
||||||
|
total: number;
|
||||||
|
totalPage: number;
|
||||||
|
};
|
||||||
|
items: ServerBreakdownItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- transforms ----------------------------------------------------------
|
||||||
|
|
||||||
|
// Frontend group_by vocabulary → backend. "gpu_type" / "type" both mean the
|
||||||
|
// sku (Type / flavor) on the server.
|
||||||
|
const GROUP_BY_MAP: Record<string, string> = {
|
||||||
|
resource_type: 'resource_type',
|
||||||
|
gpu_type: 'instance_type',
|
||||||
|
type: 'type',
|
||||||
|
instance: 'instance',
|
||||||
|
volume: 'volume',
|
||||||
|
user: 'user',
|
||||||
|
date: 'date'
|
||||||
|
};
|
||||||
|
|
||||||
|
const num = (v?: number) => Number(v ?? 0);
|
||||||
|
|
||||||
|
function flattenMetrics(m: ServerMetrics): ResourceBreakdownSummary {
|
||||||
|
const gpuHours = num(m.gpu_hours);
|
||||||
|
return {
|
||||||
|
gpu_hours: gpuHours,
|
||||||
|
gpu_minutes: gpuHours * 60,
|
||||||
|
instance_hours: num(m.instance_hours),
|
||||||
|
// not metered under the whole-machine SKU model → 0
|
||||||
|
cpu_hours: 0,
|
||||||
|
memory_gb_hours: 0,
|
||||||
|
ephemeral_gb_hours: 0,
|
||||||
|
active_instances: num(m.resources),
|
||||||
|
gpu_types_used: 0,
|
||||||
|
active_users: num(m.active_users),
|
||||||
|
storage_gb_days: num(m.gb_days),
|
||||||
|
storage_gb_hours: num(m.gb_hours),
|
||||||
|
active_volumes: num(m.resources),
|
||||||
|
dangling_volumes: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenItem(
|
||||||
|
groupBy: string | null | undefined,
|
||||||
|
it: ServerBreakdownItem
|
||||||
|
): ResourceBreakdownItem {
|
||||||
|
const flat: ResourceBreakdownItem = {
|
||||||
|
...flattenMetrics(it.metrics || {}),
|
||||||
|
last_active: it.metrics?.last_active ?? undefined
|
||||||
|
};
|
||||||
|
if (it.date) flat.date = it.date;
|
||||||
|
const id = it.id ?? undefined;
|
||||||
|
// Deleted entities get a "(Deleted)" suffix, matching the Token breakdown.
|
||||||
|
const rawKey = it.key ?? undefined;
|
||||||
|
const key = it.deleted && rawKey != null ? `${rawKey} (Deleted)` : rawKey;
|
||||||
|
switch (groupBy) {
|
||||||
|
case 'resource_type':
|
||||||
|
flat.resource_type = key;
|
||||||
|
break;
|
||||||
|
case 'gpu_type':
|
||||||
|
case 'type':
|
||||||
|
flat.gpu_type = key;
|
||||||
|
break;
|
||||||
|
case 'instance':
|
||||||
|
flat.instance_name = key;
|
||||||
|
flat.instance_id = id;
|
||||||
|
break;
|
||||||
|
case 'volume':
|
||||||
|
flat.volume_name = key;
|
||||||
|
flat.volume_id = id;
|
||||||
|
break;
|
||||||
|
case 'user':
|
||||||
|
flat.user_name = key;
|
||||||
|
flat.user_id = id;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Per-resource rows (instance / volume) carry their sku → surface it as the
|
||||||
|
// Instance Type / Type column when not already the group key.
|
||||||
|
if (!flat.gpu_type && it.sku) {
|
||||||
|
flat.gpu_type = it.sku;
|
||||||
|
}
|
||||||
|
return flat;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenResponse(
|
||||||
|
groupBy: string | null | undefined,
|
||||||
|
res: ServerBreakdownResponse
|
||||||
|
): ResourceBreakdownResponse {
|
||||||
|
return {
|
||||||
|
summary: flattenMetrics(res.summary || {}),
|
||||||
|
group_by: res.group_by,
|
||||||
|
pagination: res.pagination,
|
||||||
|
items: (res.items || []).map((it) => flattenItem(groupBy, it))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toServerRequest(data: ResourceBreakdownRequest) {
|
||||||
|
const groupBy = data.group_by ?? 'resource_type';
|
||||||
|
const { creator_ids, instance_ids, volume_ids } = data.filters ?? {};
|
||||||
|
return {
|
||||||
|
body: {
|
||||||
|
start_date: data.start_date,
|
||||||
|
end_date: data.end_date,
|
||||||
|
scope: data.scope ?? 'all',
|
||||||
|
group_by: GROUP_BY_MAP[groupBy] ?? groupBy,
|
||||||
|
granularity: data.granularity ?? 'day',
|
||||||
|
// POST endpoints take proper id arrays. "filter by user" + "filter by
|
||||||
|
// resource" (instance ids on the GPU tab / volume ids on Storage).
|
||||||
|
...(creator_ids?.length ? { creator_ids } : {}),
|
||||||
|
...(instance_ids?.length ? { instance_ids } : {}),
|
||||||
|
...(volume_ids?.length ? { volume_ids } : {}),
|
||||||
|
page: data.page ?? 1,
|
||||||
|
perPage: data.perPage ?? 20
|
||||||
|
},
|
||||||
|
groupBy
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- request helpers -----------------------------------------------------
|
||||||
|
|
||||||
|
async function _breakdown(
|
||||||
|
url: string,
|
||||||
|
data: ResourceBreakdownRequest
|
||||||
|
): Promise<ResourceBreakdownResponse> {
|
||||||
|
const { body, groupBy } = toServerRequest(data);
|
||||||
|
const res = await request<ServerBreakdownResponse>(url, {
|
||||||
|
data: body,
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
return flattenResponse(groupBy, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryResourceBreakdown(
|
||||||
|
data: ResourceBreakdownRequest
|
||||||
|
): Promise<ResourceBreakdownResponse> {
|
||||||
|
return _breakdown(URL.RESOURCE_BREAKDOWN, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryGpuInstancesBreakdown(
|
||||||
|
data: ResourceBreakdownRequest
|
||||||
|
): Promise<ResourceBreakdownResponse> {
|
||||||
|
return _breakdown(URL.GPU_BREAKDOWN, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryStorageBreakdown(
|
||||||
|
data: ResourceBreakdownRequest
|
||||||
|
): Promise<ResourceBreakdownResponse> {
|
||||||
|
return _breakdown(URL.STORAGE_BREAKDOWN, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryResourceEvents(data: {
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
scope?: 'self' | 'all';
|
||||||
|
filters?: ResourceUsageFilters;
|
||||||
|
resource_types?: string[];
|
||||||
|
event_types?: string[];
|
||||||
|
page?: number;
|
||||||
|
perPage?: number;
|
||||||
|
}): Promise<ResourceEventsResponse> {
|
||||||
|
const creatorIds = data.filters?.creator_ids;
|
||||||
|
return request<ResourceEventsResponse>(URL.EVENTS, {
|
||||||
|
params: {
|
||||||
|
start_date: data.start_date,
|
||||||
|
end_date: data.end_date,
|
||||||
|
scope: data.scope ?? 'all',
|
||||||
|
resource_type: data.resource_types?.[0],
|
||||||
|
// GET endpoints take creator_ids as a CSV string (avoids axios array
|
||||||
|
// serialization quirks); the server splits it back into a list.
|
||||||
|
...(creatorIds?.length ? { creator_ids: creatorIds.join(',') } : {}),
|
||||||
|
page: data.page ?? 1,
|
||||||
|
perPage: data.perPage ?? 50
|
||||||
|
},
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceFilterOption {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceFilterMeta {
|
||||||
|
creators: ResourceFilterOption[];
|
||||||
|
instances: ResourceFilterOption[];
|
||||||
|
volumes: ResourceFilterOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryResourceFilterMeta(
|
||||||
|
scope: 'self' | 'all' = 'all'
|
||||||
|
): Promise<ResourceFilterMeta> {
|
||||||
|
const res = await request<Partial<ResourceFilterMeta>>(URL.RESOURCE_META, {
|
||||||
|
params: { scope },
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
creators: res.creators || [],
|
||||||
|
instances: res.instances || [],
|
||||||
|
volumes: res.volumes || []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryUsageSummary(params: {
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
scope?: 'self' | 'all';
|
||||||
|
creator_ids?: number[];
|
||||||
|
}): Promise<UsageSummaryResponse> {
|
||||||
|
const { creator_ids, ...rest } = params;
|
||||||
|
const res = await request<{
|
||||||
|
total_tokens: number;
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
token_active_users: number;
|
||||||
|
gpu_hours: number;
|
||||||
|
instance_hours: number;
|
||||||
|
storage_gb_days: number;
|
||||||
|
active_users: number;
|
||||||
|
}>(URL.SUMMARY, {
|
||||||
|
params: {
|
||||||
|
...rest,
|
||||||
|
scope: params.scope ?? 'all',
|
||||||
|
...(creator_ids?.length ? { creator_ids: creator_ids.join(',') } : {})
|
||||||
|
},
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resource Distribution donut — by GPU type, using GPU-Hours (a single,
|
||||||
|
// well-defined unit). Built from the GPU-instances breakdown grouped by
|
||||||
|
// 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 total = byType.items.reduce((s, i) => s + (i.gpu_hours || 0), 0);
|
||||||
|
distribution = byType.items
|
||||||
|
.filter((i) => (i.gpu_hours || 0) > 0)
|
||||||
|
.map((i) => ({
|
||||||
|
label: i.gpu_type || 'unknown',
|
||||||
|
value: i.gpu_hours,
|
||||||
|
percentage: total > 0 ? (i.gpu_hours / total) * 100 : 0
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
distribution = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
total_tokens: num(res.total_tokens),
|
||||||
|
input_tokens: num(res.input_tokens),
|
||||||
|
output_tokens: num(res.output_tokens),
|
||||||
|
token_active_users: num(res.token_active_users),
|
||||||
|
gpu_hours: num(res.gpu_hours),
|
||||||
|
instance_hours: num(res.instance_hours),
|
||||||
|
active_instances: 0,
|
||||||
|
storage_gb_days: num(res.storage_gb_days),
|
||||||
|
active_users: num(res.active_users),
|
||||||
|
distribution
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meta is synthesized client-side — the components hardcode their metric /
|
||||||
|
// group_by options and don't call these, but keep them for any external
|
||||||
|
// importers. Filter dropdowns are empty until a meta endpoint lands.
|
||||||
|
const STATIC_META: ResourceUsageMetaResponse = {
|
||||||
|
metrics: [],
|
||||||
|
granularities: [
|
||||||
|
{ key: 'day', label: 'Day' },
|
||||||
|
{ key: 'week', label: 'Week' },
|
||||||
|
{ key: 'month', label: 'Month' }
|
||||||
|
],
|
||||||
|
group_bys: [],
|
||||||
|
filters: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function queryResourceMeta(): Promise<ResourceUsageMetaResponse> {
|
||||||
|
return STATIC_META;
|
||||||
|
}
|
||||||
|
export async function queryGpuInstancesMeta(): Promise<ResourceUsageMetaResponse> {
|
||||||
|
return STATIC_META;
|
||||||
|
}
|
||||||
|
export async function queryStorageMeta(): Promise<ResourceUsageMetaResponse> {
|
||||||
|
return STATIC_META;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
queryResourceFilterMeta,
|
||||||
|
ResourceFilterOption
|
||||||
|
} from '../apis/resource';
|
||||||
|
|
||||||
|
export interface SelectOption {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceMetaOptions {
|
||||||
|
creators: SelectOption[];
|
||||||
|
instances: SelectOption[];
|
||||||
|
volumes: SelectOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY: ResourceMetaOptions = {
|
||||||
|
creators: [],
|
||||||
|
instances: [],
|
||||||
|
volumes: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const toOptions = (items: ResourceFilterOption[]): SelectOption[] =>
|
||||||
|
items.map((i) => ({ value: i.id, label: i.label }));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the resource tabs' filter dropdown sources in one call:
|
||||||
|
* - ``creators`` — "filter by user" (Tokens-tab equivalent of /usage/meta
|
||||||
|
* users); only shown to managers, but cheap to always load.
|
||||||
|
* - ``instances`` — "filter by GPU instance" (GPU Instances tab)
|
||||||
|
* - ``volumes`` — "filter by volume" (Storage tab)
|
||||||
|
*
|
||||||
|
* Scope-aware: managers get the org-wide lists, others only their own
|
||||||
|
* resources. Refetched when ``scope`` changes.
|
||||||
|
*/
|
||||||
|
export default function useResourceMeta(
|
||||||
|
scope: 'self' | 'all' = 'all'
|
||||||
|
): ResourceMetaOptions {
|
||||||
|
const [meta, setMeta] = useState<ResourceMetaOptions>(EMPTY);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
queryResourceFilterMeta(scope)
|
||||||
|
.then((res) =>
|
||||||
|
setMeta({
|
||||||
|
creators: toOptions(res.creators),
|
||||||
|
instances: toOptions(res.instances),
|
||||||
|
volumes: toOptions(res.volumes)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.catch(() => {
|
||||||
|
// Network/auth errors surface via the global interceptor; leave the
|
||||||
|
// dropdowns empty rather than crashing the tab.
|
||||||
|
});
|
||||||
|
}, [scope]);
|
||||||
|
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Export a resource-breakdown table to Excel — the GPU Instances / Storage
|
||||||
|
* tabs' counterpart to the Tokens tab export.
|
||||||
|
*
|
||||||
|
* Columns are derived from the same antd column specs the table renders, so the
|
||||||
|
* export always matches what's on screen (whichever group_by tab is active).
|
||||||
|
* Raw values are written (not the table's formatted render output) so numbers
|
||||||
|
* stay sortable / calculable in the spreadsheet.
|
||||||
|
*/
|
||||||
|
import { exportJsonToExcel } from '@gpustack/core-ui/excel';
|
||||||
|
|
||||||
|
export interface ExportColumn {
|
||||||
|
title: string;
|
||||||
|
dataIndex: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep only real data columns (drop index / render-only columns), and only
|
||||||
|
// those whose title is a plain string so the header is meaningful.
|
||||||
|
export const toExportColumns = (columns: any[]): ExportColumn[] =>
|
||||||
|
(columns || [])
|
||||||
|
.filter(
|
||||||
|
(c) => typeof c?.dataIndex === 'string' && typeof c?.title === 'string'
|
||||||
|
)
|
||||||
|
.map((c) => ({
|
||||||
|
title: c.title as string,
|
||||||
|
dataIndex: c.dataIndex as string
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const exportBreakdownRows = (
|
||||||
|
rows: any[],
|
||||||
|
columns: ExportColumn[],
|
||||||
|
fileName: string,
|
||||||
|
sheetName = 'usage'
|
||||||
|
): void => {
|
||||||
|
const fields = columns.map((c) => c.dataIndex);
|
||||||
|
const fieldLabels = Object.fromEntries(
|
||||||
|
columns.map((c) => [c.dataIndex, c.title])
|
||||||
|
);
|
||||||
|
const jsonData = (rows || []).map((r) => {
|
||||||
|
const o: Record<string, any> = {};
|
||||||
|
columns.forEach((c) => {
|
||||||
|
o[c.dataIndex] = r?.[c.dataIndex] ?? '';
|
||||||
|
});
|
||||||
|
return o;
|
||||||
|
});
|
||||||
|
exportJsonToExcel({
|
||||||
|
fileName,
|
||||||
|
sheets: [{ jsonData, sheetName, fields, fieldLabels, formatMap: {} }]
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Time-bucket helpers shared by the resource-usage tabs' charts.
|
||||||
|
*
|
||||||
|
* The backend returns a per-bucket value keyed by ``bucket_start`` (hourly) or
|
||||||
|
* a date_trunc'd date (day/week/month). The chart x-axis must use the SAME key
|
||||||
|
* format so series line up. ``bucketKey`` normalizes any returned value to that
|
||||||
|
* format via dayjs; ``generateBucketRange`` produces a contiguous axis.
|
||||||
|
*/
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
export type Granularity = 'hour' | 'day' | 'week' | 'month';
|
||||||
|
|
||||||
|
// Cap the hourly axis so a wide date range doesn't render hundreds of bars.
|
||||||
|
const HOUR_MAX_DAYS = 7;
|
||||||
|
|
||||||
|
export const bucketKey = (value: any, granularity: Granularity): string => {
|
||||||
|
const d = dayjs(value);
|
||||||
|
if (granularity === 'hour') return d.format('YYYY-MM-DD HH:00');
|
||||||
|
if (granularity === 'month') return d.format('YYYY-MM');
|
||||||
|
return d.format('YYYY-MM-DD'); // day / week (week-start date as returned)
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateBucketRange = (
|
||||||
|
start: string,
|
||||||
|
end: string,
|
||||||
|
granularity: Granularity
|
||||||
|
): string[] => {
|
||||||
|
if (!start || !end) return [];
|
||||||
|
const endDay = dayjs(end);
|
||||||
|
let cursor = dayjs(start);
|
||||||
|
// Hour view: clamp to the last HOUR_MAX_DAYS to keep the axis readable.
|
||||||
|
if (granularity === 'hour') {
|
||||||
|
const clampStart = endDay.subtract(HOUR_MAX_DAYS, 'day');
|
||||||
|
if (cursor.isBefore(clampStart)) cursor = clampStart;
|
||||||
|
}
|
||||||
|
const step = granularity === 'hour' ? 'hour' : granularity;
|
||||||
|
const out: string[] = [];
|
||||||
|
const last =
|
||||||
|
granularity === 'hour' ? endDay.endOf('day') : endDay.startOf('day');
|
||||||
|
while (cursor.isBefore(last) || cursor.isSame(last)) {
|
||||||
|
out.push(bucketKey(cursor, granularity));
|
||||||
|
cursor = cursor.add(1, step as dayjs.ManipulateType);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user