From 2407416e33a09869273305c16f39ffcf68dc799c Mon Sep 17 00:00:00 2001 From: michelia Date: Wed, 3 Jun 2026 13:17:24 +0800 Subject: [PATCH] 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. --- src/pages/usage/apis/resource.ts | 482 +++++++++++++++++++++ src/pages/usage/hooks/use-resource-meta.ts | 58 +++ src/pages/usage/utils/export-breakdown.ts | 50 +++ src/pages/usage/utils/time-buckets.ts | 45 ++ 4 files changed, 635 insertions(+) create mode 100644 src/pages/usage/apis/resource.ts create mode 100644 src/pages/usage/hooks/use-resource-meta.ts create mode 100644 src/pages/usage/utils/export-breakdown.ts create mode 100644 src/pages/usage/utils/time-buckets.ts diff --git a/src/pages/usage/apis/resource.ts b/src/pages/usage/apis/resource.ts new file mode 100644 index 00000000..6e6310b7 --- /dev/null +++ b/src/pages/usage/apis/resource.ts @@ -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 = { + 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 { + const { body, groupBy } = toServerRequest(data); + const res = await request(url, { + data: body, + method: 'POST' + }); + return flattenResponse(groupBy, res); +} + +export async function queryResourceBreakdown( + data: ResourceBreakdownRequest +): Promise { + return _breakdown(URL.RESOURCE_BREAKDOWN, data); +} + +export async function queryGpuInstancesBreakdown( + data: ResourceBreakdownRequest +): Promise { + return _breakdown(URL.GPU_BREAKDOWN, data); +} + +export async function queryStorageBreakdown( + data: ResourceBreakdownRequest +): Promise { + 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 { + const creatorIds = data.filters?.creator_ids; + return request(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 { + const res = await request>(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 { + 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 { + return STATIC_META; +} +export async function queryGpuInstancesMeta(): Promise { + return STATIC_META; +} +export async function queryStorageMeta(): Promise { + return STATIC_META; +} diff --git a/src/pages/usage/hooks/use-resource-meta.ts b/src/pages/usage/hooks/use-resource-meta.ts new file mode 100644 index 00000000..8ffcb90b --- /dev/null +++ b/src/pages/usage/hooks/use-resource-meta.ts @@ -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(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; +} diff --git a/src/pages/usage/utils/export-breakdown.ts b/src/pages/usage/utils/export-breakdown.ts new file mode 100644 index 00000000..93dcc30d --- /dev/null +++ b/src/pages/usage/utils/export-breakdown.ts @@ -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 = {}; + columns.forEach((c) => { + o[c.dataIndex] = r?.[c.dataIndex] ?? ''; + }); + return o; + }); + exportJsonToExcel({ + fileName, + sheets: [{ jsonData, sheetName, fields, fieldLabels, formatMap: {} }] + }); +}; diff --git a/src/pages/usage/utils/time-buckets.ts b/src/pages/usage/utils/time-buckets.ts new file mode 100644 index 00000000..9493e424 --- /dev/null +++ b/src/pages/usage/utils/time-buckets.ts @@ -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; +};