style(usage): show deleted marker in chart legends and exports
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
})),
|
||||
|
||||
@@ -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
|
||||
})),
|
||||
|
||||
@@ -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;
|
||||
@@ -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[] =>
|
||||
|
||||
Reference in New Issue
Block a user