Merge pull request #14392 from K-Phoen/entity-cost-insights

Entity cost insights
This commit is contained in:
Johan Haals
2022-11-11 10:30:35 +01:00
committed by GitHub
16 changed files with 414 additions and 2 deletions
+5
View File
@@ -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"
+12
View File
@@ -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>;
+37
View File
@@ -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();
+1
View File
@@ -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.
*
@@ -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',
+1
View File
@@ -24,6 +24,7 @@ export {
costInsightsPlugin,
costInsightsPlugin as plugin,
CostInsightsPage,
EntityCostInsightsContent,
CostInsightsProjectGrowthInstructionsPage,
CostInsightsLabelDataflowInstructionsPage,
} from './plugin';
+14
View File
@@ -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(
+1
View File
@@ -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:^"