Define a EntityCostInsightsContent extension to show costs per catalog entity

Signed-off-by: Kévin Gomez <contact@kevingomez.fr>
This commit is contained in:
Kévin Gomez
2022-10-30 22:30:58 +01:00
parent 2da4ef9020
commit 9d07922f70
9 changed files with 239 additions and 1 deletions
+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",
@@ -24,6 +24,7 @@ import {
MetricData,
} from '../types';
import { createApiRef } from '@backstage/core-plugin-api';
import { Entity as CatalogEntity } from '@backstage/catalog-model';
/** @public */
export type ProductInsightsOptions = {
@@ -77,6 +78,25 @@ export type CostInsightsApi = {
*/
getGroupProjects(group: string): Promise<Project[]>;
/**
* Get daily cost aggregations for a given 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 entity - The catalog entity
* @param intervals - An ISO 8601 repeating interval string, such as R2/P30D/2020-09-01
* https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals
*/
getEntityDailyCost?(entity: CatalogEntity, intervals: string): Promise<Cost>;
/**
* Get daily cost aggregations for a given group and interval time frame.
*
@@ -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,160 @@
/*
* 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';
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.getEntityDailyCost!(
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.getEntityDailyCost) {
return (
<WarningPanel
title="Could display costs for entity"
message="The getEntityDailyCost() 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';
@@ -38,6 +38,7 @@ import {
getGroupedProjects,
trendlineOf,
} from '../testUtils';
import { Entity as CatalogEntity } from '@backstage/catalog-model';
/** @public */
export class ExampleCostInsightsClient implements CostInsightsApi {
@@ -92,6 +93,29 @@ export class ExampleCostInsightsClient implements CostInsightsApi {
return cost;
}
async getEntityDailyCost(
entity: CatalogEntity,
intervals: string,
): Promise<Cost> {
const aggregation = aggregationFor(intervals, 8_000);
const groupDailyCost: Cost = await this.request(
{ entity, 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(
+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:^"