style(usage): show deleted marker in chart legends and exports

This commit is contained in:
jialin
2026-07-09 15:17:53 +08:00
committed by jialin
parent e79d49f2a8
commit b1efa415e8
8 changed files with 147 additions and 14 deletions
+8 -5
View File
@@ -10,7 +10,8 @@
* (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';
import { getIntl, request } from '@umijs/max';
import { withDeletedMark } from '../utils/deleted-label';
import { instanceTypeSeriesLabel } from '../utils/format-instance-type';
export interface ResourceUsageFilters {
@@ -283,10 +284,12 @@ function flattenItem(
const id = it.id ?? undefined;
const deleted = !!it.deleted;
const rawKey = it.key ?? undefined;
// The chart series legend keeps the "(Deleted)" suffix (a legend can't render
// a tag); the tables show a DeletedTag off ``flat.deleted`` + the id and so
// use the clean name.
const key = deleted && rawKey != null ? `${rawKey} (Deleted)` : rawKey;
// The chart series legend can't render a tag, so it carries the deleted
// marker as text ("<name> [Deleted.<id>]"); the tables render a DeletedTag off
// ``flat.deleted`` + the id and so keep the clean name.
const deletedWord = getIntl().formatMessage({ id: 'usage.table.deleted' });
const key =
rawKey != null ? withDeletedMark(rawKey, deleted, deletedWord, 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;
+20 -1
View File
@@ -12,6 +12,15 @@ import {
UsageBreakdownResponse,
UsageFilterItem
} from '../config/types';
import { withDeletedMark } from '../utils/deleted-label';
// group dimension → the id field inside ``identity.current`` (the backend nulls
// it for deleted entities, so the marker falls back to just "[Deleted]").
const GROUP_ID_KEY: Record<string, 'route_id' | 'user_id' | 'api_key_id'> = {
route: 'route_id',
user: 'user_id',
api_key: 'api_key_id'
};
const ControlsWrapper = styled.div`
display: flex;
@@ -124,12 +133,22 @@ const DailyUsage: React.FC<DailyUsageProps> = (props) => {
(a, b) => dayjs(a).valueOf() - dayjs(b).valueOf()
);
const deletedWord = intl.formatMessage({ id: 'usage.table.deleted' });
const groupOrder: string[] = [];
const groupItemsMap = new Map<string, Map<string, BreakdownItem>>();
items.forEach((item) => {
const groupEntity = groupDim
? (item[groupDim] as UsageFilterItem)
: undefined;
const groupLabel = groupDim
? ((item[groupDim] as UsageFilterItem)?.label ?? '-')
? withDeletedMark(
groupEntity?.label ?? '-',
groupEntity?.deleted,
deletedWord,
groupEntity?.identity?.current?.[GROUP_ID_KEY[groupDim]]
)
: '__total__';
if (!groupItemsMap.has(groupLabel)) {
+37 -5
View File
@@ -4,12 +4,24 @@ import {
usersTableDataAtom
} from '@/atoms/usage';
import { exportJsonToExcel } from '@gpustack/core-ui/excel';
import { useIntl } from '@umijs/max';
import { useStore } from 'jotai';
import _ from 'lodash';
import { withDeletedMark } from '../utils/deleted-label';
import useAPIKeysColumns from './use-apikeys-columns';
import useModelsColumns from './use-models-columns';
import useUsersColumns from './use-users-columns';
// The name column's ``dataIndex`` starts with the embedded entity object
// (route / user / api_key); that entity carries ``deleted`` + the id used for
// the "[Deleted.<id>]" export marker.
const ID_KEY_BY_ENTITY: Record<string, 'route_id' | 'user_id' | 'api_key_id'> =
{
route: 'route_id',
user: 'user_id',
api_key: 'api_key_id'
};
type ColumnLike = {
title: any;
// Plugin-contributed columns may omit `dataIndex` — they render via a
@@ -23,11 +35,14 @@ type ColumnLike = {
const toFieldKey = (dataIndex: string | string[]): string =>
Array.isArray(dataIndex) ? dataIndex.join('_') : dataIndex;
const buildSheetMeta = (columns: ColumnLike[]) => {
const buildSheetMeta = (columns: ColumnLike[], deletedWord: string) => {
const fields: string[] = [];
const fieldLabels: Record<string, any> = {};
const formatMap: Record<string, (raw: any, row: any) => any> = {};
// The first entity-backed column is the name column; its exported value gets
// the "<name> [Deleted.<id>]" marker for deleted rows.
let nameMarked = false;
columns.forEach((col) => {
if (!col.dataIndex) return;
const key = toFieldKey(col.dataIndex);
@@ -35,7 +50,22 @@ const buildSheetMeta = (columns: ColumnLike[]) => {
fieldLabels[key] = col.title;
if (Array.isArray(col.dataIndex)) {
const path = col.dataIndex;
formatMap[key] = (_raw, row) => _.get(row, path);
if (!nameMarked) {
nameMarked = true;
const entityKey = path[0];
const idKey = ID_KEY_BY_ENTITY[entityKey];
formatMap[key] = (_raw, row) => {
const entity = row?.[entityKey];
return withDeletedMark(
_.get(row, path),
entity?.deleted,
deletedWord,
idKey ? entity?.identity?.current?.[idKey] : undefined
);
};
} else {
formatMap[key] = (_raw, row) => _.get(row, path);
}
}
});
@@ -44,18 +74,20 @@ const buildSheetMeta = (columns: ColumnLike[]) => {
const useExportTable = () => {
const store = useStore();
const intl = useIntl();
const modelsColumns = useModelsColumns();
const apiKeysColumns = useAPIKeysColumns();
const usersColumns = useUsersColumns();
const exportTable = () => {
const deletedWord = intl.formatMessage({ id: 'usage.table.deleted' });
const usersTableData = store.get(usersTableDataAtom);
const apiKeysTableData = store.get(apiKeysTableDataAtom);
const modelsTableData = store.get(modelsTableDataAtom);
const modelsMeta = buildSheetMeta(modelsColumns);
const apiKeysMeta = buildSheetMeta(apiKeysColumns);
const usersMeta = buildSheetMeta(usersColumns);
const modelsMeta = buildSheetMeta(modelsColumns, deletedWord);
const apiKeysMeta = buildSheetMeta(apiKeysColumns, deletedWord);
const usersMeta = buildSheetMeta(usersColumns, deletedWord);
exportJsonToExcel({
fileName: 'table_data.xlsx',
+21 -1
View File
@@ -1,10 +1,20 @@
import { exportJsonToExcel } from '@gpustack/core-ui/excel';
import { useIntl } from '@umijs/max';
import dayjs from 'dayjs';
import _ from 'lodash';
import { useEffect, useRef, useState } from 'react';
import { GroupOption } from '../config';
import { BreakdownItem, UsageFilterItem } from '../config/types';
import useQueryTimeSeriesData from '../services/use-query-timeseries-data';
import { withDeletedMark } from '../utils/deleted-label';
// group dimension → the id field inside ``identity.current`` (null for deleted
// entities on the Tokens tab, so the marker degrades to just "[Deleted]").
const GROUP_ID_KEY: Record<string, 'route_id' | 'user_id' | 'api_key_id'> = {
route: 'route_id',
user: 'user_id',
api_key: 'api_key_id'
};
const DefaultDateConfig = {
defaultRange: 29
@@ -74,6 +84,7 @@ export const useUsageFilters = ({
autoFetchOnFilterChange = true,
onFetchData
}: UseUsageFiltersParams) => {
const intl = useIntl();
const {
activeRoutes: initialActiveRoutes = [],
activeApiKeys: initialActiveApiKeys = [],
@@ -288,12 +299,21 @@ export const useUsageFilters = ({
const dateMap: Record<string, Record<string, any>> = {};
const groupLabels = new Set<string>();
const deletedWord = intl.formatMessage({ id: 'usage.table.deleted' });
items.forEach((item) => {
const date = item.date?.value;
if (!date) return;
const groupEntity = groupDim
? (item[groupDim] as UsageFilterItem)
: undefined;
const groupLabel = groupDim
? ((item[groupDim] as UsageFilterItem)?.label ?? '-')
? withDeletedMark(
groupEntity?.label ?? '-',
groupEntity?.deleted,
deletedWord,
groupEntity?.identity?.current?.[GROUP_ID_KEY[groupDim]]
)
: metric;
groupLabels.add(groupLabel);
if (!dateMap[date]) dateMap[date] = { date };
+6 -1
View File
@@ -27,6 +27,7 @@ import ResourceFilterBar from '../components/resource-filter-bar';
import useResourceMeta from '../hooks/use-resource-meta';
import {
exportBreakdownSheets,
markDeletedNames,
toExportColumns
} from '../utils/export-breakdown';
import {
@@ -302,9 +303,13 @@ const GpuInstancesTab: React.FC = () => {
})
)
);
const deletedWord = intl.formatMessage({ id: 'usage.table.deleted' });
exportBreakdownSheets(
tableExportGroups.map((g, i) => ({
rows: results[i]?.items ?? [],
// The Instance Types sheet's name (``gpu_type``) is already marked by
// the adapter; the instance / user sheets keep the clean name (the
// table renders a tag) so mark it here for the export only.
rows: markDeletedNames(results[i]?.items ?? [], g.key, deletedWord),
columns: toExportColumns(g.columns),
sheetName: g.sheetName
})),
+3 -1
View File
@@ -30,6 +30,7 @@ import ResourceFilterBar from '../components/resource-filter-bar';
import useResourceMeta from '../hooks/use-resource-meta';
import {
exportBreakdownSheets,
markDeletedNames,
toExportColumns
} from '../utils/export-breakdown';
import {
@@ -289,9 +290,10 @@ const StorageTab: React.FC = () => {
})
)
);
const deletedWord = intl.formatMessage({ id: 'usage.table.deleted' });
exportBreakdownSheets(
tableExportGroups.map((g, i) => ({
rows: results[i]?.items ?? [],
rows: markDeletedNames(results[i]?.items ?? [], g.key, deletedWord),
columns: toExportColumns(g.columns),
sheetName: g.sheetName
})),
+17
View File
@@ -0,0 +1,17 @@
/**
* Text form of the deleted marker for the usage CHARTS and EXPORTS, where a
* Tag component can't be used (chart legends, Excel cells). Renders
* "<label> [<Deleted>.<id>]", or "<label> [<Deleted>]" when no id is available
* (e.g. the Tokens tab, whose backend nulls the id for deleted entities).
*
* The filter dropdowns and table cells use the <DeletedTag> component instead;
* this keeps that same information as plain text for surfaces that only take
* strings.
*/
export const withDeletedMark = (
label: string,
deleted: boolean | undefined | null,
deletedWord: string,
id?: string | number | null
): string =>
deleted ? `${label} [${deletedWord}${id != null ? `.#${id}` : ''}]` : label;
+35
View File
@@ -8,12 +8,47 @@
* stay sortable / calculable in the spreadsheet.
*/
import { exportJsonToExcel } from '@gpustack/core-ui/excel';
import { withDeletedMark } from './deleted-label';
export interface ExportColumn {
title: string;
dataIndex: string;
}
// group_by key → the row's clean name field + its id field. Only these
// groupings carry a clean name (their tables render a DeletedTag); other
// groupings (e.g. instance type) are already marked by the adapter.
const RESOURCE_NAME_FIELD: Record<string, { name: string; id: string }> = {
instance: { name: 'instance_name', id: 'instance_id' },
volume: { name: 'volume_name', id: 'volume_id' },
user: { name: 'user_name', id: 'user_id' }
};
// Append the "[Deleted.<id>]" marker to the name field of deleted export rows,
// so the Excel export matches the on-screen tag. Rows are export-only copies,
// never the displayed data. Non-name groupings pass through unchanged.
export const markDeletedNames = (
rows: any[],
groupKey: string,
deletedWord: string
): any[] => {
const field = RESOURCE_NAME_FIELD[groupKey];
if (!field) return rows || [];
return (rows || []).map((row) =>
row?.deleted
? {
...row,
[field.name]: withDeletedMark(
row[field.name] ?? '',
true,
deletedWord,
row[field.id]
)
}
: row
);
};
// 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[] =>