diff --git a/.changeset/shaggy-moles-jump.md b/.changeset/shaggy-moles-jump.md
new file mode 100644
index 0000000000..b2417beb9e
--- /dev/null
+++ b/.changeset/shaggy-moles-jump.md
@@ -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`.
diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx
index 8e72bb03e4..02b00f038c 100644
--- a/packages/app/src/components/catalog/EntityPage.tsx
+++ b/packages/app/src/components/catalog/EntityPage.tsx
@@ -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 = (
+
+
+
+
;
getUserGroups(userId: string): Promise;
getGroupProjects(group: string): Promise;
+ getCatalogEntityDailyCost?(
+ catalogEntityRef: string,
+ intervals: string,
+ ): Promise;
getGroupDailyCost(group: string, intervals: string): Promise;
getProjectDailyCost(project: string, intervals: string): Promise;
getDailyMetricData(metric: string, intervals: string): Promise;
@@ -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;
// (undocumented)
+ getCatalogEntityDailyCost(
+ entityRef: string,
+ intervals: string,
+ ): Promise;
+ // (undocumented)
getDailyMetricData(metric: string, intervals: string): Promise;
// (undocumented)
getGroupDailyCost(group: string, intervals: string): Promise;
diff --git a/plugins/cost-insights/dev/index.tsx b/plugins/cost-insights/dev/index.tsx
index 4be788427a..622abaffc9 100644
--- a/plugins/cost-insights/dev/index.tsx
+++ b/plugins/cost-insights/dev/index.tsx
@@ -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: ,
})
+ .addPage({
+ title: 'Entity',
+ element: (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+ })
.render();
diff --git a/plugins/cost-insights/package.json b/plugins/cost-insights/package.json
index 4f882fc431..b6ced5cc7e 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..a7065a04aa 100644
--- a/plugins/cost-insights/src/api/CostInsightsApi.ts
+++ b/plugins/cost-insights/src/api/CostInsightsApi.ts
@@ -77,6 +77,29 @@ export type CostInsightsApi = {
*/
getGroupProjects(group: string): Promise;
+ /**
+ * 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;
+
/**
* Get daily cost aggregations for a given group and interval time frame.
*
diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewBreakdownChart.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewBreakdownChart.tsx
index f6463b65ed..962dc6b332 100644
--- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewBreakdownChart.tsx
+++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewBreakdownChart.tsx
@@ -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();
const classes = useStyles(theme);
@@ -228,7 +230,7 @@ export const CostOverviewBreakdownChart = ({
/>
diff --git a/plugins/cost-insights/src/components/EntityCosts/EntityCost.test.tsx b/plugins/cost-insights/src/components/EntityCosts/EntityCost.test.tsx
new file mode 100644
index 0000000000..53e1a13ebf
--- /dev/null
+++ b/plugins/cost-insights/src/components/EntityCosts/EntityCost.test.tsx
@@ -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 = {
+ 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(
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+ ,
+ );
+}
+
+describe('', () => {
+ 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();
+ expect(getByText('Cloud Cost')).toBeInTheDocument();
+ });
+});
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..9dda1c65e3
--- /dev/null
+++ b/plugins/cost-insights/src/components/EntityCosts/EntityCosts.tsx
@@ -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>(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.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 ;
+ }
+
+ if (error) {
+ return {error.message};
+ }
+
+ if (!dailyCost) {
+ return No daily costs;
+ }
+
+ return ;
+};
+
+export const EntityCosts = () => {
+ const client = useApi(costInsightsApiRef);
+
+ if (!client.getCatalogEntityDailyCost) {
+ 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..e2a183b682 100644
--- a/plugins/cost-insights/src/example/client.ts
+++ b/plugins/cost-insights/src/example/client.ts
@@ -92,6 +92,29 @@ export class ExampleCostInsightsClient implements CostInsightsApi {
return cost;
}
+ async getCatalogEntityDailyCost(
+ entityRef: string,
+ intervals: string,
+ ): Promise {
+ 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 {
const aggregation = aggregationFor(intervals, 8_000);
const groupDailyCost: Cost = await this.request(
diff --git a/plugins/cost-insights/src/example/templates/CostInsightsClient.ts b/plugins/cost-insights/src/example/templates/CostInsightsClient.ts
index fc68eb62c7..50c3bb1470 100644
--- a/plugins/cost-insights/src/example/templates/CostInsightsClient.ts
+++ b/plugins/cost-insights/src/example/templates/CostInsightsClient.ts
@@ -87,6 +87,17 @@ export class CostInsightsClient implements CostInsightsApi {
}
}
+ async getCatalogEntityDailyCost(catalogEntityRef: string, intervals: string): Promise {
+ return {
+ id: 'remove-me',
+ aggregation: [],
+ change: {
+ ratio: 0,
+ amount: 0
+ }
+ }
+ }
+
async getProductInsights(options: ProductInsightsOptions): Promise {
return {
id: 'remove-me',
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 b22003e0ed..0e3457a57b 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:^"