/** * 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 { getIntl, request } from '@umijs/max'; import { withDeletedMark } from '../utils/deleted-label'; import { instanceTypeSeriesLabel } from '../utils/format-instance-type'; 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; // One or more grouping dimensions, combined left-to-right (mirrors the token // usage API). A trend uses ['date', '']; a table uses ['']. group_by?: string[]; 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; 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; // The grouped entity (instance / volume / user) no longer exists. The name // fields keep the clean (stale) name; the tables show a DeletedTag off this // flag plus the id, matching the Tokens tab. deleted?: boolean; // Owner user of a per-instance / per-volume row (compound date+dim grouping), // with its own deletion state — independent of the row's ``deleted`` (which // refers to the grouped instance/volume). Lets the export mark the User // column separately, matching the Tokens tab. user_deleted?: boolean; // Grouped-trend rows carry the sub-group label (sku / instance / user / …) // alongside ``date`` so the chart can pivot one series per group. group?: 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; // Instance totals (requested cpu/ram) — the real size, so CPU instance types // show "CPU Only · 2 vCPU · 4 GB" instead of just the per-unit spec. cpu_milli?: number; memory_mib?: number; // Per-instance rows also carry the card count + ephemeral disk so the // Instances table can render " x " + 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 { 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; // status.phaseMessage at event time — the detail behind a failure phase. phase_message?: 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 { date?: string | null; // Grouped entity: ``key`` is its display name, ``id`` its id, ``deleted`` its // own lifecycle state (instance / volume / user / sku, per group_by). key?: string | null; id?: number | null; sku?: string | null; deleted?: boolean | null; // Owner (creator) of the instance/volume row (compound date+dim grouping), // at the item root alongside the grouped entity, with its OWN deletion state // — independent of ``deleted`` — so the export can show a User column that // marks a deleted owner separately from a deleted instance/volume. creator_id?: number | null; creator_name?: string | null; creator_deleted?: boolean | null; dimensions?: { product?: string | null; unit_cpu_milli?: number | null; unit_memory_mib?: number | null; vram_mib?: number | null; cpu_milli?: number | null; memory_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; } 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; const deleted = !!it.deleted; const rawKey = it.key ?? undefined; // The chart series legend can't render a tag, so it carries the deleted // marker as text (" [Deleted.]"); the tables render a DeletedTag off // ``flat.deleted`` + the id and so keep the clean name. const key = rawKey != null ? withDeletedMark( rawKey, deleted, deleted ? getIntl().formatMessage({ id: 'usage.table.deleted' }) : '', id ) : rawKey; // Generic group label — for a compound (date + dim) trend row the key is the // sub-group value (the switch below targets single-dimension table rows). if (rawKey != null) flat.group = key; flat.deleted = deleted; 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 = rawKey; flat.instance_id = id; break; case 'volume': flat.volume_name = rawKey; flat.volume_id = id; break; case 'user': flat.user_name = rawKey; 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 ?? 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.cpu_milli != null) flat.cpu_milli = dims.cpu_milli; if (dims.memory_mib != null) flat.memory_mib = dims.memory_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; } // Owner (creator) of a per-instance / per-volume row — the grouped entity is // the instance/volume (``key``/``deleted``), so the owner sits at the item // root with its own deleted flag for the export's User column. if (it.creator_name != null) flat.user_name = it.creator_name; if (it.creator_id != null) flat.user_id = it.creator_id; if (it.creator_deleted != null) flat.user_deleted = !!it.creator_deleted; // Instance-type grouped trend: the series label (``group``) defaults to the // raw flavor slug. Instance Types are grouped by actual shape, so label each // series by that shape — " x " / "CPU Only · 3 vCPU · 6 GB" — // matching the table and keeping every shape a distinct series (#5700). // ``groupBy`` is the unmapped frontend dimension; the instance-type axis is // ``gpu_type`` (→ backend ``instance_type`` via GROUP_BY_MAP). if (groupBy === 'gpu_type') { flat.group = instanceTypeSeriesLabel(flat); } 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 groupByList = data.group_by?.length ? data.group_by : ['resource_type']; const { creator_ids, instance_ids, volume_ids } = data.filters ?? {}; // The non-date dimension drives response flattening into the right field. const dim = groupByList.find((g) => g !== 'date'); return { body: { start_date: data.start_date, end_date: data.end_date, scope: data.scope ?? 'all', group_by: groupByList.map((g) => GROUP_BY_MAP[g] ?? g), 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 } : {}), ...(data.order_by ? { order_by: data.order_by } : {}), ...(data.descending !== undefined ? { descending: data.descending } : {}), page: data.page ?? 1, perPage: data.perPage ?? 20 }, groupBy: dim }; } // --- request helpers ----------------------------------------------------- async function _breakdown( url: string, data: ResourceBreakdownRequest, options?: { token?: any; } ): Promise { const { body, groupBy } = toServerRequest(data); const res = await request(url, { data: body, method: 'POST', cancelToken: options?.token }); return flattenResponse(groupBy, res); } export async function queryResourceBreakdown( data: ResourceBreakdownRequest, options?: { token?: any; } ): Promise { return _breakdown(URL.RESOURCE_BREAKDOWN, data, options); } export async function queryGpuInstancesBreakdown( data: ResourceBreakdownRequest, options?: { token?: any; } ): Promise { return _breakdown(URL.GPU_BREAKDOWN, data, options); } export async function queryStorageBreakdown( data: ResourceBreakdownRequest, options?: { token?: any; } ): Promise { return _breakdown(URL.STORAGE_BREAKDOWN, data, options); } export async function queryResourceEvents( data: { start_date?: string; end_date?: string; scope?: 'self' | 'all'; filters?: ResourceUsageFilters; resource_types?: string[]; resource_name?: string; event_types?: string[]; page?: number; perPage?: number; }, options?: { skipErrorHandler?: boolean; token?: any } ): 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 list params as CSV strings (avoids axios array // serialization quirks); the server splits them back into lists. ...(creatorIds?.length ? { creator_ids: creatorIds.join(',') } : {}), ...(data.event_types?.length ? { event_types: data.event_types.join(',') } : {}), ...(data.resource_name ? { resource_name: data.resource_name } : {}), page: data.page ?? 1, perPage: data.perPage ?? 50 }, method: 'GET', skipErrorHandler: options?.skipErrorHandler, cancelToken: options?.token }); } export interface ResourceFilterOption { id: number; label: string; deleted?: boolean; } 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[]; }, options?: { token?: any } ): 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', cancelToken: options?.token }); // 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 }, { token: options?.token } ); const total = byType.items.reduce((s, i) => s + (i.gpu_hours || 0), 0); distribution = byType.items .filter((i) => (i.gpu_hours || 0) > 0) .map((i) => ({ // 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, 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; }