Merge pull request #3536 from backstage/update-breakdown-label
Support custom labels for entity breakdowns
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-cost-insights': patch
|
||||
---
|
||||
|
||||
Add support for non-SKU breakdowns for entities in the product panels.
|
||||
@@ -290,6 +290,9 @@ costInsights:
|
||||
bigQuery:
|
||||
name: BigQuery
|
||||
icon: search
|
||||
events:
|
||||
name: Events
|
||||
icon: data
|
||||
metrics:
|
||||
DAU:
|
||||
name: Daily Active Users
|
||||
|
||||
@@ -29,7 +29,7 @@ function createRenderer(col: keyof RowData, classes: Record<string, string>) {
|
||||
const row = rowData as RowData;
|
||||
const rowStyles = classnames(classes.row, {
|
||||
[classes.rowTotal]: row.id === 'total',
|
||||
[classes.colFirst]: col === 'sku',
|
||||
[classes.colFirst]: col === 'label',
|
||||
[classes.colLast]: col === 'ratio',
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ function createRenderer(col: keyof RowData, classes: Record<string, string>) {
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <Typography className={rowStyles}>{row.sku}</Typography>;
|
||||
return <Typography className={rowStyles}>{row.label}</Typography>;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -63,7 +63,7 @@ function createSorter(field?: keyof Omit<RowData, 'id'>) {
|
||||
const b = data2 as RowData;
|
||||
if (a.id === 'total') return 1;
|
||||
if (b.id === 'total') return 1;
|
||||
if (field === 'sku') return a.sku.localeCompare(b.sku);
|
||||
if (field === 'label') return a.label.localeCompare(b.label);
|
||||
|
||||
return field
|
||||
? a[field] - b[field]
|
||||
@@ -80,7 +80,7 @@ const defaultEntity: Entity = {
|
||||
|
||||
type RowData = {
|
||||
id: string;
|
||||
sku: string;
|
||||
label: string;
|
||||
previous: number;
|
||||
current: number;
|
||||
ratio: number;
|
||||
@@ -93,6 +93,7 @@ type ProductEntityDialogOptions = Partial<
|
||||
type ProductEntityDialogProps = {
|
||||
open: boolean;
|
||||
entity?: Entity;
|
||||
entitiesLabel: string;
|
||||
options?: ProductEntityDialogOptions;
|
||||
onClose: () => void;
|
||||
};
|
||||
@@ -100,6 +101,7 @@ type ProductEntityDialogProps = {
|
||||
export const ProductEntityDialog = ({
|
||||
open,
|
||||
entity = defaultEntity,
|
||||
entitiesLabel,
|
||||
options = {},
|
||||
onClose,
|
||||
}: ProductEntityDialogProps) => {
|
||||
@@ -118,10 +120,12 @@ export const ProductEntityDialog = ({
|
||||
|
||||
const columns: TableColumn[] = [
|
||||
{
|
||||
field: 'sku',
|
||||
title: <Typography className={firstColClasses}>SKU</Typography>,
|
||||
render: createRenderer('sku', classes),
|
||||
customSort: createSorter('sku'),
|
||||
field: 'label',
|
||||
title: (
|
||||
<Typography className={firstColClasses}>{entitiesLabel}</Typography>
|
||||
),
|
||||
render: createRenderer('label', classes),
|
||||
customSort: createSorter('label'),
|
||||
width: '33.33%',
|
||||
},
|
||||
{
|
||||
@@ -154,14 +158,14 @@ export const ProductEntityDialog = ({
|
||||
const rowData: RowData[] = entity.entities
|
||||
.map(e => ({
|
||||
id: e.id || 'Unknown',
|
||||
sku: e.id || 'Unknown',
|
||||
label: e.id || 'Unknown',
|
||||
previous: e.aggregation[0],
|
||||
current: e.aggregation[1],
|
||||
ratio: e.change.ratio,
|
||||
}))
|
||||
.concat({
|
||||
id: 'total',
|
||||
sku: 'Total',
|
||||
label: 'Total',
|
||||
previous: entity.aggregation[0],
|
||||
current: entity.aggregation[1],
|
||||
ratio: entity.change.ratio,
|
||||
|
||||
@@ -66,9 +66,9 @@ export const ProductInsightsChart = ({
|
||||
const [selectLabel, setSelected] = useState<Maybe<string>>();
|
||||
const isSelected = useMemo(() => !isUndefined(selectLabel), [selectLabel]);
|
||||
const isClickable = useMemo(() => {
|
||||
const skus =
|
||||
const breakdownEntities =
|
||||
entity.entities.find(e => e.id === activeLabel)?.entities ?? [];
|
||||
return skus.length > 0;
|
||||
return breakdownEntities.length > 0;
|
||||
}, [entity, activeLabel]);
|
||||
|
||||
const legendTitle = `Cost ${entity.change.ratio <= 0 ? 'Savings' : 'Growth'}`;
|
||||
@@ -122,10 +122,13 @@ export const ProductInsightsChart = ({
|
||||
|
||||
const activeEntity = findAlways(entity.entities, e => e.id === id);
|
||||
const ratio = activeEntity.change.ratio;
|
||||
const skus = activeEntity.entities;
|
||||
const subtitle = `${skus.length} ${pluralOf(skus.length, 'SKU')}`;
|
||||
const breakdownEntities = activeEntity.entities;
|
||||
const subtitle = `${breakdownEntities.length} ${pluralOf(
|
||||
breakdownEntities.length,
|
||||
entity.entitiesLabel || 'SKU',
|
||||
)}`;
|
||||
|
||||
if (skus.length) {
|
||||
if (breakdownEntities.length) {
|
||||
return (
|
||||
<BarChartTooltip
|
||||
title={title}
|
||||
@@ -151,7 +154,7 @@ export const ProductInsightsChart = ({
|
||||
);
|
||||
}
|
||||
|
||||
// If an entity doesn't have any skus, there aren't any costs to break down.
|
||||
// If an entity doesn't have any sub-entities, there aren't any costs to break down.
|
||||
return (
|
||||
<BarChartTooltip
|
||||
title={title}
|
||||
@@ -197,6 +200,7 @@ export const ProductInsightsChart = ({
|
||||
onClose={() => setSelected(undefined)}
|
||||
entity={entity.entities.find(e => e.id === selectLabel)}
|
||||
options={options}
|
||||
entitiesLabel={entity.entitiesLabel || 'SKU'}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface Entity {
|
||||
aggregation: [number, number];
|
||||
entities: Entity[];
|
||||
change: ChangeStatistic;
|
||||
entitiesLabel?: string;
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -812,7 +812,7 @@ export const SampleComputeEngineInsights: Entity = {
|
||||
id: 'Sample SKU A',
|
||||
aggregation: [1_000, 2_000],
|
||||
change: {
|
||||
ratio: 0.5,
|
||||
ratio: 1,
|
||||
amount: 1_000,
|
||||
},
|
||||
entities: [],
|
||||
@@ -849,6 +849,92 @@ export const SampleComputeEngineInsights: Entity = {
|
||||
],
|
||||
};
|
||||
|
||||
export const SampleEventsInsights: Entity = {
|
||||
id: 'events',
|
||||
aggregation: [20_000, 10_000],
|
||||
change: {
|
||||
ratio: -0.5,
|
||||
amount: -10_000,
|
||||
},
|
||||
entitiesLabel: 'Product',
|
||||
entities: [
|
||||
{
|
||||
id: 'entity-a',
|
||||
aggregation: [15_000, 7_000],
|
||||
change: {
|
||||
ratio: -0.53333333333,
|
||||
amount: -8_000,
|
||||
},
|
||||
entities: [
|
||||
{
|
||||
id: 'Sample Product A',
|
||||
aggregation: [5_000, 2_000],
|
||||
change: {
|
||||
ratio: -0.6,
|
||||
amount: -3_000,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
{
|
||||
id: 'Sample Product B',
|
||||
aggregation: [7_000, 2_500],
|
||||
change: {
|
||||
ratio: -0.64285714285,
|
||||
amount: -4_500,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
{
|
||||
id: 'Sample Product C',
|
||||
aggregation: [3_000, 2_500],
|
||||
change: {
|
||||
ratio: -0.16666666666,
|
||||
amount: -500,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'entity-b',
|
||||
aggregation: [5_000, 3_000],
|
||||
change: {
|
||||
ratio: -0.4,
|
||||
amount: -2_000,
|
||||
},
|
||||
entities: [
|
||||
{
|
||||
id: 'Sample Product A',
|
||||
aggregation: [2_000, 1_000],
|
||||
change: {
|
||||
ratio: -0.5,
|
||||
amount: -1_000,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
{
|
||||
id: 'Sample Product B',
|
||||
aggregation: [1_000, 1_500],
|
||||
change: {
|
||||
ratio: 0.5,
|
||||
amount: 500,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
{
|
||||
id: 'Sample Product C',
|
||||
aggregation: [2_000, 500],
|
||||
change: {
|
||||
ratio: -0.75,
|
||||
amount: -1_500,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function entityOf(product: string): Entity {
|
||||
switch (product) {
|
||||
case 'computeEngine':
|
||||
@@ -859,6 +945,8 @@ export function entityOf(product: string): Entity {
|
||||
return SampleCloudStorageInsights;
|
||||
case 'bigQuery':
|
||||
return SampleBigQueryInsights;
|
||||
case 'events':
|
||||
return SampleEventsInsights;
|
||||
default:
|
||||
throw new Error(
|
||||
`Cannot get insights for ${product}. Make sure product matches product property in app-info.yaml`,
|
||||
|
||||
Reference in New Issue
Block a user