diff --git a/plugins/cost-insights/package.json b/plugins/cost-insights/package.json index 9896c3e60e..0f53d22457 100644 --- a/plugins/cost-insights/package.json +++ b/plugins/cost-insights/package.json @@ -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", diff --git a/plugins/cost-insights/src/api/CostInsightsApi.ts b/plugins/cost-insights/src/api/CostInsightsApi.ts index 3c97407f08..ffc2314435 100644 --- a/plugins/cost-insights/src/api/CostInsightsApi.ts +++ b/plugins/cost-insights/src/api/CostInsightsApi.ts @@ -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; + /** + * 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; + /** * Get daily cost aggregations for a given group and interval time frame. * diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx index 3e77858967..9eb5f1e190 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx @@ -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 ( diff --git a/plugins/cost-insights/src/components/EntityCosts/EntityCosts.tsx b/plugins/cost-insights/src/components/EntityCosts/EntityCosts.tsx new file mode 100644 index 0000000000..9fdcb9c98b --- /dev/null +++ b/plugins/cost-insights/src/components/EntityCosts/EntityCosts.tsx @@ -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>(null); + const [error, setError] = useState>(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 ; + } + + if (error) { + return {error.message}; + } + + if (!dailyCost) { + return No daily costs; + } + + return ; +}; + +export const EntityCosts = () => { + const client = useApi(costInsightsApiRef); + + if (!client.getEntityDailyCost) { + return ( + + ); + } + + return ( + + + + + + + + + + + + + + + + + + ); +}; diff --git a/plugins/cost-insights/src/components/EntityCosts/index.ts b/plugins/cost-insights/src/components/EntityCosts/index.ts new file mode 100644 index 0000000000..b8d5106090 --- /dev/null +++ b/plugins/cost-insights/src/components/EntityCosts/index.ts @@ -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'; diff --git a/plugins/cost-insights/src/example/client.ts b/plugins/cost-insights/src/example/client.ts index 9c3e20c2b3..a1401a06a2 100644 --- a/plugins/cost-insights/src/example/client.ts +++ b/plugins/cost-insights/src/example/client.ts @@ -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 { + 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 { const aggregation = aggregationFor(intervals, 8_000); const groupDailyCost: Cost = await this.request( diff --git a/plugins/cost-insights/src/index.ts b/plugins/cost-insights/src/index.ts index 2c46156005..8a17b1bf43 100644 --- a/plugins/cost-insights/src/index.ts +++ b/plugins/cost-insights/src/index.ts @@ -24,6 +24,7 @@ export { costInsightsPlugin, costInsightsPlugin as plugin, CostInsightsPage, + EntityCostInsightsContent, CostInsightsProjectGrowthInstructionsPage, CostInsightsLabelDataflowInstructionsPage, } from './plugin'; diff --git a/plugins/cost-insights/src/plugin.ts b/plugins/cost-insights/src/plugin.ts index 789d803438..04d58d1877 100644 --- a/plugins/cost-insights/src/plugin.ts +++ b/plugins/cost-insights/src/plugin.ts @@ -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( diff --git a/yarn.lock b/yarn.lock index 6a88a61621..74b78b52d9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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:^"