Merge pull request #14392 from K-Phoen/entity-cost-insights
Entity cost insights
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-cost-insights': patch
|
||||
---
|
||||
|
||||
Added support for displaying entity cost insights by implementing the new `getCatalogEntityDailyCost` that's part of the `CostInsightsApi`.
|
||||
@@ -153,6 +153,7 @@ import {
|
||||
TextSize,
|
||||
ReportIssue,
|
||||
} from '@backstage/plugin-techdocs-module-addons-contrib';
|
||||
import { EntityCostInsightsContent } from '@backstage/plugin-cost-insights';
|
||||
|
||||
const customEntityFilterKind = ['Component', 'API', 'System'];
|
||||
|
||||
@@ -489,6 +490,10 @@ const serviceEntityPage = (
|
||||
<EntityTodoContent />
|
||||
</EntityLayout.Route>
|
||||
|
||||
<EntityLayout.Route path="/costs" title="Costs">
|
||||
<EntityCostInsightsContent />
|
||||
</EntityLayout.Route>
|
||||
|
||||
<EntityLayout.Route
|
||||
path="/dynatrace"
|
||||
title="Dynatrace"
|
||||
|
||||
@@ -267,6 +267,10 @@ export type CostInsightsApi = {
|
||||
getLastCompleteBillingDate(): Promise<string>;
|
||||
getUserGroups(userId: string): Promise<Group[]>;
|
||||
getGroupProjects(group: string): Promise<Project[]>;
|
||||
getCatalogEntityDailyCost?(
|
||||
catalogEntityRef: string,
|
||||
intervals: string,
|
||||
): Promise<Cost>;
|
||||
getGroupDailyCost(group: string, intervals: string): Promise<Cost>;
|
||||
getProjectDailyCost(project: string, intervals: string): Promise<Cost>;
|
||||
getDailyMetricData(metric: string, intervals: string): Promise<MetricData>;
|
||||
@@ -404,11 +408,19 @@ export const EngineerThreshold = 0.5;
|
||||
// @public @deprecated (undocumented)
|
||||
export type Entity = common.Entity;
|
||||
|
||||
// @public
|
||||
export const EntityCostInsightsContent: () => JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export class ExampleCostInsightsClient implements CostInsightsApi {
|
||||
// (undocumented)
|
||||
getAlerts(group: string): Promise<Alert[]>;
|
||||
// (undocumented)
|
||||
getCatalogEntityDailyCost(
|
||||
entityRef: string,
|
||||
intervals: string,
|
||||
): Promise<Cost>;
|
||||
// (undocumented)
|
||||
getDailyMetricData(metric: string, intervals: string): Promise<MetricData>;
|
||||
// (undocumented)
|
||||
getGroupDailyCost(group: string, intervals: string): Promise<Cost>;
|
||||
|
||||
@@ -23,7 +23,26 @@ import {
|
||||
CostInsightsPage,
|
||||
CostInsightsProjectGrowthInstructionsPage,
|
||||
CostInsightsLabelDataflowInstructionsPage,
|
||||
EntityCostInsightsContent,
|
||||
} from '../src/plugin';
|
||||
import { Content, Header, Page } from '@backstage/core-components';
|
||||
import { Grid } from '@material-ui/core';
|
||||
import { EntityProvider } from '@backstage/plugin-catalog-react';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
const mockEntity: Entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'backstage',
|
||||
description: 'backstage.io',
|
||||
},
|
||||
spec: {
|
||||
lifecycle: 'production',
|
||||
type: 'service',
|
||||
owner: 'user:guest',
|
||||
},
|
||||
};
|
||||
|
||||
createDevApp()
|
||||
.registerPlugin(costInsightsPlugin)
|
||||
@@ -44,4 +63,22 @@ createDevApp()
|
||||
title: 'Labelling',
|
||||
element: <CostInsightsLabelDataflowInstructionsPage />,
|
||||
})
|
||||
.addPage({
|
||||
title: 'Entity',
|
||||
element: (
|
||||
<Page themeId="home">
|
||||
<Header title="Entity" />
|
||||
|
||||
<Content>
|
||||
<Grid container>
|
||||
<Grid item md={12}>
|
||||
<EntityProvider entity={mockEntity}>
|
||||
<EntityCostInsightsContent />
|
||||
</EntityProvider>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Content>
|
||||
</Page>
|
||||
),
|
||||
})
|
||||
.render();
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"@backstage/config": "workspace:^",
|
||||
"@backstage/core-components": "workspace:^",
|
||||
"@backstage/core-plugin-api": "workspace:^",
|
||||
"@backstage/plugin-catalog-react": "workspace:^",
|
||||
"@backstage/plugin-cost-insights-common": "workspace:^",
|
||||
"@backstage/theme": "workspace:^",
|
||||
"@material-ui/core": "^4.12.2",
|
||||
|
||||
@@ -77,6 +77,29 @@ export type CostInsightsApi = {
|
||||
*/
|
||||
getGroupProjects(group: string): Promise<Project[]>;
|
||||
|
||||
/**
|
||||
* Get daily cost aggregations for a given catalog entity and interval time frame.
|
||||
*
|
||||
* The return type includes an array of daily cost aggregations as well as statistics about the
|
||||
* change in cost over the intervals. Calculating these statistics requires us to bucket costs
|
||||
* into two or more time periods, hence a repeating interval format rather than just a start and
|
||||
* end date.
|
||||
*
|
||||
* The rate of change in this comparison allows teams to reason about their cost growth (or
|
||||
* reduction) and compare it to metrics important to the business.
|
||||
*
|
||||
* Note: implementing this is only required when using the `EntityCostInsightsContent` extension.
|
||||
*
|
||||
* @param catalogEntityRef - A reference to the catalog entity, as described in
|
||||
* https://backstage.io/docs/features/software-catalog/references
|
||||
* @param intervals - An ISO 8601 repeating interval string, such as R2/P30D/2020-09-01
|
||||
* https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals
|
||||
*/
|
||||
getCatalogEntityDailyCost?(
|
||||
catalogEntityRef: string,
|
||||
intervals: string,
|
||||
): Promise<Cost>;
|
||||
|
||||
/**
|
||||
* Get daily cost aggregations for a given group and interval time frame.
|
||||
*
|
||||
|
||||
+3
-1
@@ -54,12 +54,14 @@ import { TooltipRenderer } from '../../types/Tooltip';
|
||||
|
||||
export type CostOverviewBreakdownChartProps = {
|
||||
costBreakdown: Cost[];
|
||||
responsive?: boolean;
|
||||
};
|
||||
|
||||
const LOW_COST_THRESHOLD = 0.1;
|
||||
|
||||
export const CostOverviewBreakdownChart = ({
|
||||
costBreakdown,
|
||||
responsive = true,
|
||||
}: CostOverviewBreakdownChartProps) => {
|
||||
const theme = useTheme<CostInsightsTheme>();
|
||||
const classes = useStyles(theme);
|
||||
@@ -228,7 +230,7 @@ export const CostOverviewBreakdownChart = ({
|
||||
/>
|
||||
</Box>
|
||||
<ResponsiveContainer
|
||||
width={classes.container.width}
|
||||
width={responsive ? '100%' : classes.container.width}
|
||||
height={classes.container.height}
|
||||
>
|
||||
<AreaChart
|
||||
|
||||
@@ -103,7 +103,8 @@ export const CostOverviewCard = ({
|
||||
};
|
||||
|
||||
// Metrics can only be selected on the total cost graph
|
||||
const showMetricSelect = config.metrics.length && safeTabIndex === 0;
|
||||
const showMetricSelect =
|
||||
metricData && config.metrics.length && safeTabIndex === 0;
|
||||
|
||||
return (
|
||||
<Card style={{ position: 'relative', overflow: 'visible' }}>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import {
|
||||
changeOf,
|
||||
MockAggregatedDailyCosts,
|
||||
MockBillingDateProvider,
|
||||
MockConfigProvider,
|
||||
MockFilterProvider,
|
||||
MockScrollProvider,
|
||||
trendlineOf,
|
||||
} from '../../testUtils';
|
||||
import { CostInsightsThemeProvider } from '../CostInsightsPage/CostInsightsThemeProvider';
|
||||
import { EntityCostsCard } from './EntityCosts';
|
||||
import { TestApiProvider } from '@backstage/test-utils';
|
||||
import { CostInsightsApi, costInsightsApiRef } from '../../api';
|
||||
import { EntityProvider } from '@backstage/plugin-catalog-react';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { LoadingProvider } from '../../hooks';
|
||||
import { Cost } from '@backstage/plugin-cost-insights-common';
|
||||
|
||||
function renderInContext(children: JSX.Element) {
|
||||
const mockEntity = {
|
||||
metadata: { name: 'mock' },
|
||||
kind: 'MockKind',
|
||||
} as Entity;
|
||||
const mockGroupDailyCost: Cost = {
|
||||
id: 'test-group',
|
||||
aggregation: MockAggregatedDailyCosts,
|
||||
change: changeOf(MockAggregatedDailyCosts),
|
||||
trendline: trendlineOf(MockAggregatedDailyCosts),
|
||||
};
|
||||
const mockApi: jest.Mocked<CostInsightsApi> = {
|
||||
getLastCompleteBillingDate: jest.fn().mockResolvedValue('2022-10-30'),
|
||||
getUserGroups: jest.fn().mockResolvedValue(['team-a']),
|
||||
getGroupProjects: jest.fn().mockResolvedValue(['project-a', 'project-b']),
|
||||
getCatalogEntityDailyCost: jest.fn().mockResolvedValue(mockGroupDailyCost),
|
||||
getGroupDailyCost: jest.fn().mockResolvedValue({}),
|
||||
getProjectDailyCost: jest.fn().mockResolvedValue({}),
|
||||
getDailyMetricData: jest.fn().mockResolvedValue({}),
|
||||
getProductInsights: jest.fn().mockResolvedValue({}),
|
||||
getAlerts: jest.fn().mockResolvedValue({}),
|
||||
};
|
||||
|
||||
return renderInTestApp(
|
||||
<TestApiProvider apis={[[costInsightsApiRef, mockApi]]}>
|
||||
<EntityProvider entity={mockEntity}>
|
||||
<CostInsightsThemeProvider>
|
||||
<MockConfigProvider>
|
||||
<LoadingProvider>
|
||||
<MockFilterProvider>
|
||||
<MockBillingDateProvider>
|
||||
<MockScrollProvider>{children}</MockScrollProvider>
|
||||
</MockBillingDateProvider>
|
||||
</MockFilterProvider>
|
||||
</LoadingProvider>
|
||||
</MockConfigProvider>
|
||||
</CostInsightsThemeProvider>
|
||||
</EntityProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('<EntityCostsCard/>', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-expect-error: Since we have strictNullChecks enabled, this will throw an error as window.ResizeObserver
|
||||
// it's not an optional operand
|
||||
delete window.ResizeObserver;
|
||||
window.ResizeObserver = jest.fn().mockImplementation(() => ({
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.ResizeObserver = ResizeObserver;
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('Renders without exploding', async () => {
|
||||
const { getByText } = await renderInContext(<EntityCostsCard />);
|
||||
expect(getByText('Cloud Cost')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { CostOverviewCard } from '../CostOverviewCard';
|
||||
import {
|
||||
BillingDateProvider,
|
||||
ConfigProvider,
|
||||
CurrencyProvider,
|
||||
FilterProvider,
|
||||
GroupsProvider,
|
||||
LoadingProvider,
|
||||
ScrollProvider,
|
||||
useFilters,
|
||||
useLastCompleteBillingDate,
|
||||
useLoading,
|
||||
} from '../../hooks';
|
||||
import { CostInsightsThemeProvider } from '../CostInsightsPage/CostInsightsThemeProvider';
|
||||
import { Progress, WarningPanel } from '@backstage/core-components';
|
||||
import { default as MaterialAlert } from '@material-ui/lab/Alert';
|
||||
import { mapLoadingToProps } from '../CostInsightsPage/selector';
|
||||
import { intervalsOf } from '../../utils/duration';
|
||||
import { costInsightsApiRef } from '../../api';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import { Cost, Maybe } from '@backstage/plugin-cost-insights-common';
|
||||
import { stringifyEntityRef } from '@backstage/catalog-model';
|
||||
|
||||
export const EntityCostsCard = () => {
|
||||
const client = useApi(costInsightsApiRef);
|
||||
const { entity } = useEntity();
|
||||
|
||||
const {
|
||||
loadingActions,
|
||||
loadingGroups,
|
||||
loadingBillingDate,
|
||||
loadingInitial,
|
||||
dispatchInitial,
|
||||
dispatchInsights,
|
||||
dispatchNone,
|
||||
} = useLoading(mapLoadingToProps);
|
||||
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
// The dispatchLoading functions are derived from loading state using mapLoadingToProps, to
|
||||
// provide nicer props for the component. These are re-derived whenever loading state changes,
|
||||
// which causes an infinite loop as product panels load and re-trigger the useEffect below.
|
||||
// Since the functions don't change, we can memoize - but we trigger the same loop if we satisfy
|
||||
// exhaustive-deps by including the function itself in dependencies.
|
||||
|
||||
const dispatchLoadingInitial = useCallback(dispatchInitial, []);
|
||||
const dispatchLoadingInsights = useCallback(dispatchInsights, []);
|
||||
const dispatchLoadingNone = useCallback(dispatchNone, []);
|
||||
/* eslint-enable react-hooks/exhaustive-deps */
|
||||
|
||||
const lastCompleteBillingDate = useLastCompleteBillingDate();
|
||||
const [dailyCost, setDailyCost] = useState<Maybe<Cost>>(null);
|
||||
const [error, setError] = useState<Maybe<Error>>(null);
|
||||
const { pageFilters } = useFilters(p => p);
|
||||
|
||||
useEffect(() => {
|
||||
async function getInsights() {
|
||||
setError(null);
|
||||
try {
|
||||
dispatchLoadingInsights(true);
|
||||
const intervals = intervalsOf(
|
||||
pageFilters.duration,
|
||||
lastCompleteBillingDate,
|
||||
);
|
||||
|
||||
const fetchedDailyCost = await client.getCatalogEntityDailyCost!(
|
||||
stringifyEntityRef(entity),
|
||||
intervals,
|
||||
);
|
||||
setDailyCost(fetchedDailyCost);
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
dispatchLoadingNone(loadingActions);
|
||||
} finally {
|
||||
dispatchLoadingNone(loadingActions);
|
||||
dispatchLoadingInitial(false);
|
||||
dispatchLoadingInsights(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for metadata to finish loading
|
||||
if (!loadingBillingDate) {
|
||||
getInsights();
|
||||
}
|
||||
}, [
|
||||
client,
|
||||
entity,
|
||||
pageFilters,
|
||||
loadingActions,
|
||||
loadingGroups,
|
||||
loadingBillingDate,
|
||||
dispatchLoadingInsights,
|
||||
dispatchLoadingInitial,
|
||||
dispatchLoadingNone,
|
||||
lastCompleteBillingDate,
|
||||
]);
|
||||
|
||||
if (loadingInitial) {
|
||||
return <Progress />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <MaterialAlert severity="error">{error.message}</MaterialAlert>;
|
||||
}
|
||||
|
||||
if (!dailyCost) {
|
||||
return <MaterialAlert severity="error">No daily costs</MaterialAlert>;
|
||||
}
|
||||
|
||||
return <CostOverviewCard dailyCostData={dailyCost} metricData={null} />;
|
||||
};
|
||||
|
||||
export const EntityCosts = () => {
|
||||
const client = useApi(costInsightsApiRef);
|
||||
|
||||
if (!client.getCatalogEntityDailyCost) {
|
||||
return (
|
||||
<WarningPanel
|
||||
title="Could display costs for entity"
|
||||
message="The getCatalogEntityDailyCost() method is not implemented by the costInsightsApi."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CostInsightsThemeProvider>
|
||||
<ConfigProvider>
|
||||
<LoadingProvider>
|
||||
<GroupsProvider>
|
||||
<BillingDateProvider>
|
||||
<FilterProvider>
|
||||
<ScrollProvider>
|
||||
<CurrencyProvider>
|
||||
<EntityCostsCard />
|
||||
</CurrencyProvider>
|
||||
</ScrollProvider>
|
||||
</FilterProvider>
|
||||
</BillingDateProvider>
|
||||
</GroupsProvider>
|
||||
</LoadingProvider>
|
||||
</ConfigProvider>
|
||||
</CostInsightsThemeProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { EntityCosts } from './EntityCosts';
|
||||
@@ -92,6 +92,29 @@ export class ExampleCostInsightsClient implements CostInsightsApi {
|
||||
return cost;
|
||||
}
|
||||
|
||||
async getCatalogEntityDailyCost(
|
||||
entityRef: string,
|
||||
intervals: string,
|
||||
): Promise<Cost> {
|
||||
const aggregation = aggregationFor(intervals, 8_000);
|
||||
const groupDailyCost: Cost = await this.request(
|
||||
{ entityRef, intervals },
|
||||
{
|
||||
aggregation: aggregation,
|
||||
change: changeOf(aggregation),
|
||||
trendline: trendlineOf(aggregation),
|
||||
// Optional field providing cost groupings / breakdowns keyed by the type. In this example,
|
||||
// daily cost grouped by cloud product OR by project / billing account.
|
||||
groupedCosts: {
|
||||
product: getGroupedProducts(intervals),
|
||||
project: getGroupedProjects(intervals),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return groupDailyCost;
|
||||
}
|
||||
|
||||
async getGroupDailyCost(group: string, intervals: string): Promise<Cost> {
|
||||
const aggregation = aggregationFor(intervals, 8_000);
|
||||
const groupDailyCost: Cost = await this.request(
|
||||
|
||||
@@ -87,6 +87,17 @@ export class CostInsightsClient implements CostInsightsApi {
|
||||
}
|
||||
}
|
||||
|
||||
async getCatalogEntityDailyCost(catalogEntityRef: string, intervals: string): Promise<Cost> {
|
||||
return {
|
||||
id: 'remove-me',
|
||||
aggregation: [],
|
||||
change: {
|
||||
ratio: 0,
|
||||
amount: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getProductInsights(options: ProductInsightsOptions): Promise<Entity> {
|
||||
return {
|
||||
id: 'remove-me',
|
||||
|
||||
@@ -24,6 +24,7 @@ export {
|
||||
costInsightsPlugin,
|
||||
costInsightsPlugin as plugin,
|
||||
CostInsightsPage,
|
||||
EntityCostInsightsContent,
|
||||
CostInsightsProjectGrowthInstructionsPage,
|
||||
CostInsightsLabelDataflowInstructionsPage,
|
||||
} from './plugin';
|
||||
|
||||
@@ -53,6 +53,20 @@ export const CostInsightsPage = costInsightsPlugin.provide(
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* An extension for displaying costs on an entity page.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const EntityCostInsightsContent = costInsightsPlugin.provide(
|
||||
createRoutableExtension({
|
||||
name: 'EntityCostInsightsContent',
|
||||
component: () =>
|
||||
import('./components/EntityCosts').then(m => m.EntityCosts),
|
||||
mountPoint: rootRouteRef,
|
||||
}),
|
||||
);
|
||||
|
||||
/** @public */
|
||||
export const CostInsightsProjectGrowthInstructionsPage =
|
||||
costInsightsPlugin.provide(
|
||||
|
||||
@@ -5470,6 +5470,7 @@ __metadata:
|
||||
"@backstage/core-components": "workspace:^"
|
||||
"@backstage/core-plugin-api": "workspace:^"
|
||||
"@backstage/dev-utils": "workspace:^"
|
||||
"@backstage/plugin-catalog-react": "workspace:^"
|
||||
"@backstage/plugin-cost-insights-common": "workspace:^"
|
||||
"@backstage/test-utils": "workspace:^"
|
||||
"@backstage/theme": "workspace:^"
|
||||
|
||||
Reference in New Issue
Block a user