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
|
* (cpu/memory/ephemeral hours, dangling volumes) are left at 0 — the
|
||||||
* whole-machine SKU model meters runtime, not decomposed components.
|
* 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';
|
import { instanceTypeSeriesLabel } from '../utils/format-instance-type';
|
||||||
|
|
||||||
export interface ResourceUsageFilters {
|
export interface ResourceUsageFilters {
|
||||||
@@ -283,10 +284,12 @@ function flattenItem(
|
|||||||
const id = it.id ?? undefined;
|
const id = it.id ?? undefined;
|
||||||
const deleted = !!it.deleted;
|
const deleted = !!it.deleted;
|
||||||
const rawKey = it.key ?? undefined;
|
const rawKey = it.key ?? undefined;
|
||||||
// The chart series legend keeps the "(Deleted)" suffix (a legend can't render
|
// The chart series legend can't render a tag, so it carries the deleted
|
||||||
// a tag); the tables show a DeletedTag off ``flat.deleted`` + the id and so
|
// marker as text ("<name> [Deleted.<id>]"); the tables render a DeletedTag off
|
||||||
// use the clean name.
|
// ``flat.deleted`` + the id and so keep the clean name.
|
||||||
const key = deleted && rawKey != null ? `${rawKey} (Deleted)` : rawKey;
|
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
|
// 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).
|
// sub-group value (the switch below targets single-dimension table rows).
|
||||||
if (rawKey != null) flat.group = key;
|
if (rawKey != null) flat.group = key;
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ import {
|
|||||||
UsageBreakdownResponse,
|
UsageBreakdownResponse,
|
||||||
UsageFilterItem
|
UsageFilterItem
|
||||||
} from '../config/types';
|
} 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`
|
const ControlsWrapper = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -124,12 +133,22 @@ const DailyUsage: React.FC<DailyUsageProps> = (props) => {
|
|||||||
(a, b) => dayjs(a).valueOf() - dayjs(b).valueOf()
|
(a, b) => dayjs(a).valueOf() - dayjs(b).valueOf()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const deletedWord = intl.formatMessage({ id: 'usage.table.deleted' });
|
||||||
|
|
||||||
const groupOrder: string[] = [];
|
const groupOrder: string[] = [];
|
||||||
const groupItemsMap = new Map<string, Map<string, BreakdownItem>>();
|
const groupItemsMap = new Map<string, Map<string, BreakdownItem>>();
|
||||||
|
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
|
const groupEntity = groupDim
|
||||||
|
? (item[groupDim] as UsageFilterItem)
|
||||||
|
: undefined;
|
||||||
const groupLabel = groupDim
|
const groupLabel = groupDim
|
||||||
? ((item[groupDim] as UsageFilterItem)?.label ?? '-')
|
? withDeletedMark(
|
||||||
|
groupEntity?.label ?? '-',
|
||||||
|
groupEntity?.deleted,
|
||||||
|
deletedWord,
|
||||||
|
groupEntity?.identity?.current?.[GROUP_ID_KEY[groupDim]]
|
||||||
|
)
|
||||||
: '__total__';
|
: '__total__';
|
||||||
|
|
||||||
if (!groupItemsMap.has(groupLabel)) {
|
if (!groupItemsMap.has(groupLabel)) {
|
||||||
|
|||||||
@@ -4,12 +4,24 @@ import {
|
|||||||
usersTableDataAtom
|
usersTableDataAtom
|
||||||
} from '@/atoms/usage';
|
} from '@/atoms/usage';
|
||||||
import { exportJsonToExcel } from '@gpustack/core-ui/excel';
|
import { exportJsonToExcel } from '@gpustack/core-ui/excel';
|
||||||
|
import { useIntl } from '@umijs/max';
|
||||||
import { useStore } from 'jotai';
|
import { useStore } from 'jotai';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
|
import { withDeletedMark } from '../utils/deleted-label';
|
||||||
import useAPIKeysColumns from './use-apikeys-columns';
|
import useAPIKeysColumns from './use-apikeys-columns';
|
||||||
import useModelsColumns from './use-models-columns';
|
import useModelsColumns from './use-models-columns';
|
||||||
import useUsersColumns from './use-users-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 = {
|
type ColumnLike = {
|
||||||
title: any;
|
title: any;
|
||||||
// Plugin-contributed columns may omit `dataIndex` — they render via a
|
// Plugin-contributed columns may omit `dataIndex` — they render via a
|
||||||
@@ -23,11 +35,14 @@ type ColumnLike = {
|
|||||||
const toFieldKey = (dataIndex: string | string[]): string =>
|
const toFieldKey = (dataIndex: string | string[]): string =>
|
||||||
Array.isArray(dataIndex) ? dataIndex.join('_') : dataIndex;
|
Array.isArray(dataIndex) ? dataIndex.join('_') : dataIndex;
|
||||||
|
|
||||||
const buildSheetMeta = (columns: ColumnLike[]) => {
|
const buildSheetMeta = (columns: ColumnLike[], deletedWord: string) => {
|
||||||
const fields: string[] = [];
|
const fields: string[] = [];
|
||||||
const fieldLabels: Record<string, any> = {};
|
const fieldLabels: Record<string, any> = {};
|
||||||
const formatMap: Record<string, (raw: any, row: any) => 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) => {
|
columns.forEach((col) => {
|
||||||
if (!col.dataIndex) return;
|
if (!col.dataIndex) return;
|
||||||
const key = toFieldKey(col.dataIndex);
|
const key = toFieldKey(col.dataIndex);
|
||||||
@@ -35,7 +50,22 @@ const buildSheetMeta = (columns: ColumnLike[]) => {
|
|||||||
fieldLabels[key] = col.title;
|
fieldLabels[key] = col.title;
|
||||||
if (Array.isArray(col.dataIndex)) {
|
if (Array.isArray(col.dataIndex)) {
|
||||||
const path = 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 useExportTable = () => {
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
|
const intl = useIntl();
|
||||||
const modelsColumns = useModelsColumns();
|
const modelsColumns = useModelsColumns();
|
||||||
const apiKeysColumns = useAPIKeysColumns();
|
const apiKeysColumns = useAPIKeysColumns();
|
||||||
const usersColumns = useUsersColumns();
|
const usersColumns = useUsersColumns();
|
||||||
|
|
||||||
const exportTable = () => {
|
const exportTable = () => {
|
||||||
|
const deletedWord = intl.formatMessage({ id: 'usage.table.deleted' });
|
||||||
const usersTableData = store.get(usersTableDataAtom);
|
const usersTableData = store.get(usersTableDataAtom);
|
||||||
const apiKeysTableData = store.get(apiKeysTableDataAtom);
|
const apiKeysTableData = store.get(apiKeysTableDataAtom);
|
||||||
const modelsTableData = store.get(modelsTableDataAtom);
|
const modelsTableData = store.get(modelsTableDataAtom);
|
||||||
|
|
||||||
const modelsMeta = buildSheetMeta(modelsColumns);
|
const modelsMeta = buildSheetMeta(modelsColumns, deletedWord);
|
||||||
const apiKeysMeta = buildSheetMeta(apiKeysColumns);
|
const apiKeysMeta = buildSheetMeta(apiKeysColumns, deletedWord);
|
||||||
const usersMeta = buildSheetMeta(usersColumns);
|
const usersMeta = buildSheetMeta(usersColumns, deletedWord);
|
||||||
|
|
||||||
exportJsonToExcel({
|
exportJsonToExcel({
|
||||||
fileName: 'table_data.xlsx',
|
fileName: 'table_data.xlsx',
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
import { exportJsonToExcel } from '@gpustack/core-ui/excel';
|
import { exportJsonToExcel } from '@gpustack/core-ui/excel';
|
||||||
|
import { useIntl } from '@umijs/max';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { GroupOption } from '../config';
|
import { GroupOption } from '../config';
|
||||||
import { BreakdownItem, UsageFilterItem } from '../config/types';
|
import { BreakdownItem, UsageFilterItem } from '../config/types';
|
||||||
import useQueryTimeSeriesData from '../services/use-query-timeseries-data';
|
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 = {
|
const DefaultDateConfig = {
|
||||||
defaultRange: 29
|
defaultRange: 29
|
||||||
@@ -74,6 +84,7 @@ export const useUsageFilters = ({
|
|||||||
autoFetchOnFilterChange = true,
|
autoFetchOnFilterChange = true,
|
||||||
onFetchData
|
onFetchData
|
||||||
}: UseUsageFiltersParams) => {
|
}: UseUsageFiltersParams) => {
|
||||||
|
const intl = useIntl();
|
||||||
const {
|
const {
|
||||||
activeRoutes: initialActiveRoutes = [],
|
activeRoutes: initialActiveRoutes = [],
|
||||||
activeApiKeys: initialActiveApiKeys = [],
|
activeApiKeys: initialActiveApiKeys = [],
|
||||||
@@ -288,12 +299,21 @@ export const useUsageFilters = ({
|
|||||||
|
|
||||||
const dateMap: Record<string, Record<string, any>> = {};
|
const dateMap: Record<string, Record<string, any>> = {};
|
||||||
const groupLabels = new Set<string>();
|
const groupLabels = new Set<string>();
|
||||||
|
const deletedWord = intl.formatMessage({ id: 'usage.table.deleted' });
|
||||||
|
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
const date = item.date?.value;
|
const date = item.date?.value;
|
||||||
if (!date) return;
|
if (!date) return;
|
||||||
|
const groupEntity = groupDim
|
||||||
|
? (item[groupDim] as UsageFilterItem)
|
||||||
|
: undefined;
|
||||||
const groupLabel = groupDim
|
const groupLabel = groupDim
|
||||||
? ((item[groupDim] as UsageFilterItem)?.label ?? '-')
|
? withDeletedMark(
|
||||||
|
groupEntity?.label ?? '-',
|
||||||
|
groupEntity?.deleted,
|
||||||
|
deletedWord,
|
||||||
|
groupEntity?.identity?.current?.[GROUP_ID_KEY[groupDim]]
|
||||||
|
)
|
||||||
: metric;
|
: metric;
|
||||||
groupLabels.add(groupLabel);
|
groupLabels.add(groupLabel);
|
||||||
if (!dateMap[date]) dateMap[date] = { date };
|
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 useResourceMeta from '../hooks/use-resource-meta';
|
||||||
import {
|
import {
|
||||||
exportBreakdownSheets,
|
exportBreakdownSheets,
|
||||||
|
markDeletedNames,
|
||||||
toExportColumns
|
toExportColumns
|
||||||
} from '../utils/export-breakdown';
|
} from '../utils/export-breakdown';
|
||||||
import {
|
import {
|
||||||
@@ -302,9 +303,13 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
const deletedWord = intl.formatMessage({ id: 'usage.table.deleted' });
|
||||||
exportBreakdownSheets(
|
exportBreakdownSheets(
|
||||||
tableExportGroups.map((g, i) => ({
|
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),
|
columns: toExportColumns(g.columns),
|
||||||
sheetName: g.sheetName
|
sheetName: g.sheetName
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import ResourceFilterBar from '../components/resource-filter-bar';
|
|||||||
import useResourceMeta from '../hooks/use-resource-meta';
|
import useResourceMeta from '../hooks/use-resource-meta';
|
||||||
import {
|
import {
|
||||||
exportBreakdownSheets,
|
exportBreakdownSheets,
|
||||||
|
markDeletedNames,
|
||||||
toExportColumns
|
toExportColumns
|
||||||
} from '../utils/export-breakdown';
|
} from '../utils/export-breakdown';
|
||||||
import {
|
import {
|
||||||
@@ -289,9 +290,10 @@ const StorageTab: React.FC = () => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
const deletedWord = intl.formatMessage({ id: 'usage.table.deleted' });
|
||||||
exportBreakdownSheets(
|
exportBreakdownSheets(
|
||||||
tableExportGroups.map((g, i) => ({
|
tableExportGroups.map((g, i) => ({
|
||||||
rows: results[i]?.items ?? [],
|
rows: markDeletedNames(results[i]?.items ?? [], g.key, deletedWord),
|
||||||
columns: toExportColumns(g.columns),
|
columns: toExportColumns(g.columns),
|
||||||
sheetName: g.sheetName
|
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.
|
* stay sortable / calculable in the spreadsheet.
|
||||||
*/
|
*/
|
||||||
import { exportJsonToExcel } from '@gpustack/core-ui/excel';
|
import { exportJsonToExcel } from '@gpustack/core-ui/excel';
|
||||||
|
import { withDeletedMark } from './deleted-label';
|
||||||
|
|
||||||
export interface ExportColumn {
|
export interface ExportColumn {
|
||||||
title: string;
|
title: string;
|
||||||
dataIndex: 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
|
// Keep only real data columns (drop index / render-only columns), and only
|
||||||
// those whose title is a plain string so the header is meaningful.
|
// those whose title is a plain string so the header is meaningful.
|
||||||
export const toExportColumns = (columns: any[]): ExportColumn[] =>
|
export const toExportColumns = (columns: any[]): ExportColumn[] =>
|
||||||
|
|||||||
Reference in New Issue
Block a user