Define a EntityCostInsightsContent extension to show costs per catalog entity
Signed-off-by: Kévin Gomez <contact@kevingomez.fr>
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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