diff --git a/.changeset/wicked-games-search.md b/.changeset/wicked-games-search.md new file mode 100644 index 0000000000..5264995a2d --- /dev/null +++ b/.changeset/wicked-games-search.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-cost-insights': patch +--- + +added an optional config entry `costInsights.engineerThreshold` to allow users to control the threshold value for the 'negligible' change in costs. diff --git a/app-config.yaml b/app-config.yaml index ef133491af..dd72051189 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -373,6 +373,7 @@ auth: development: {} costInsights: engineerCost: 200000 + engineerThreshold: 0.5 products: computeEngine: name: Compute Engine diff --git a/plugins/cost-insights/README.md b/plugins/cost-insights/README.md index f65194551a..a7bcb0cb0a 100644 --- a/plugins/cost-insights/README.md +++ b/plugins/cost-insights/README.md @@ -222,6 +222,19 @@ costInsights: rate: 3.5 ``` +### Engineer Threshold (Optional; default 0.5) + +This threshold determines whether to show 'Negligible', or a percentage with a fraction of 'engineers' for cost savings or cost excess on top of the charts. +A threshold of 0.5 means that `Negligible` is shown when the difference in costs is lower than that fraction of engineers in that time frame, +and show `XX% or ~N engineers` when it's above the threshold. + +```yaml +## ./app-config.yaml +costInsights: + engineerCost: 200000 + engineerThreshold: 0.5 +``` + ## Alerts The CostInsightsApi `getAlerts` method may return any type of alert or recommendation (called collectively "Action Items" in Cost Insights) that implements the [Alert type](https://github.com/backstage/backstage/blob/master/plugins/cost-insights/src/types/Alert.ts). This allows you to deliver any alerts or recommendations specific to your infrastructure or company migrations. diff --git a/plugins/cost-insights/api-report.md b/plugins/cost-insights/api-report.md index 8ae49dcedc..9bc662dae1 100644 --- a/plugins/cost-insights/api-report.md +++ b/plugins/cost-insights/api-report.md @@ -237,6 +237,7 @@ export type ConfigContextProps = { products: Product[]; icons: Icon[]; engineerCost: number; + engineerThreshold: number; currencies: Currency[]; }; diff --git a/plugins/cost-insights/config.d.ts b/plugins/cost-insights/config.d.ts index 5317485555..61a54f76e2 100644 --- a/plugins/cost-insights/config.d.ts +++ b/plugins/cost-insights/config.d.ts @@ -21,6 +21,11 @@ export interface Config { */ engineerCost: number; + /** + * @visibility frontend + */ + engineerThreshold?: number; + /** * @visibility frontend */ diff --git a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx index 5f47544f7b..3da7af90fb 100644 --- a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx +++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx @@ -17,7 +17,7 @@ import React, { PropsWithChildren } from 'react'; import { renderInTestApp } from '@backstage/test-utils'; import { CostGrowth } from './CostGrowth'; -import { Currency, CurrencyType, Duration } from '../../types'; +import { ChangeThreshold, Currency, CurrencyType, Duration } from '../../types'; import { findAlways } from '../../utils/assert'; import { MockConfigProvider, MockCurrencyProvider } from '../../testUtils'; import { defaultCurrencies } from '../../utils/currency'; @@ -33,11 +33,16 @@ const MockContext = ({ children, currency, engineerCost, + engineerThreshold, }: PropsWithChildren<{ currency: Currency; engineerCost: number; + engineerThreshold?: number; }>) => ( - + {children} ); @@ -104,3 +109,30 @@ describe.each` expect(getByText(expected)).toBeInTheDocument(); }); }); + +describe.each` + ratio | amount | threshold | expected + ${0} | ${0} | ${0} | ${'less than an engineer'} + ${0} | ${0} | ${200} | ${'Negligible'} + ${ChangeThreshold.lower} | ${0.5} | ${0} | ${'less than an engineer'} + ${ChangeThreshold.lower} | ${0.5} | ${0.5} | ${'Negligible'} + ${ChangeThreshold.upper} | ${0.5} | ${0.000001} | ${'less than an engineer'} + ${ChangeThreshold.upper} | ${0.5} | ${0.5} | ${'Negligible'} + ${3} | ${500_000} | ${0} | ${`300% or ~30 engineers`} + ${3} | ${500_000} | ${0.5} | ${`300% or ~30 engineers`} + ${3} | ${500_000} | ${29} | ${'300% or ~30 engineers'} + ${3} | ${500_000} | ${30} | ${'Negligible'} +`('', ({ ratio, amount, threshold, expected }) => { + it(`should display the correct difference the threshold is different. ratio: ${ratio} threshold:${threshold} expected:${expected}`, async () => { + const { getByText } = await renderInTestApp( + + + , + ); + expect(getByText(expected)).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx index d0a4e7901a..51f9c61e7b 100644 --- a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx +++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx @@ -16,12 +16,7 @@ import React from 'react'; import classnames from 'classnames'; -import { - CurrencyType, - Duration, - EngineerThreshold, - GrowthType, -} from '../../types'; +import { CurrencyType, Duration, GrowthType } from '../../types'; import { ChangeStatistic } from '@backstage/plugin-cost-insights-common'; import { rateOf } from '../../utils/currency'; import { growthOf } from '../../utils/change'; @@ -42,7 +37,7 @@ export const CostGrowth = (props: CostGrowthProps) => { const { change, duration } = props; const styles = useStyles(); - const { engineerCost } = useConfig(); + const { engineerCost, engineerThreshold } = useConfig(); const [currency] = useCurrency(); // Only display costs in absolute values @@ -55,7 +50,7 @@ export const CostGrowth = (props: CostGrowthProps) => { // If a ratio cannot be calculated, don't format. const growth = notEmpty(change.ratio) - ? growthOf({ ratio: change.ratio, amount: engineers }) + ? growthOf({ ratio: change.ratio, amount: engineers }, engineerThreshold) : null; // Determine if growth is significant enough to highlight const classes = classnames({ @@ -63,7 +58,7 @@ export const CostGrowth = (props: CostGrowthProps) => { [styles.savings]: growth === GrowthType.Savings, }); - if (engineers < EngineerThreshold) { + if (engineers < engineerThreshold) { return Negligible; } diff --git a/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.test.tsx b/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.test.tsx index d0e7d3ff74..a83a520db2 100644 --- a/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.test.tsx +++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.test.tsx @@ -18,6 +18,7 @@ import React from 'react'; import { renderInTestApp } from '@backstage/test-utils'; import { CostGrowthIndicator } from './CostGrowthIndicator'; import { ChangeThreshold, EngineerThreshold } from '../../types'; +import { MockConfigProvider } from '../../testUtils'; describe.each` ratio | amount | ariaLabel @@ -30,7 +31,9 @@ describe.each` `('growthOf', ({ ratio, amount, ariaLabel }) => { it(`should display the correct indicator for ${ariaLabel}`, async () => { const { getByLabelText } = await renderInTestApp( - , + + + , ); expect(getByLabelText(ariaLabel)).toBeInTheDocument(); }); @@ -48,7 +51,9 @@ describe.each` `('growthOf', ({ ratio, amount }) => { it('should display the correct indicator for negligible growth', async () => { const { queryByLabelText } = await renderInTestApp( - , + + + , ); expect(queryByLabelText('savings')).not.toBeInTheDocument(); expect(queryByLabelText('excess')).not.toBeInTheDocument(); diff --git a/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.tsx b/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.tsx index 2591fd5a47..cf07d97d0a 100644 --- a/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.tsx +++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.tsx @@ -23,6 +23,7 @@ import { growthOf } from '../../utils/change'; import { GrowthType } from '../../types'; import { useCostGrowthStyles as useStyles } from '../../utils/styles'; import { ChangeStatistic, Maybe } from '@backstage/plugin-cost-insights-common'; +import { useConfig } from '../../hooks'; /** @public */ export type CostGrowthIndicatorProps = TypographyProps & { @@ -35,10 +36,12 @@ export type CostGrowthIndicatorProps = TypographyProps & { /** @public */ export const CostGrowthIndicator = (props: CostGrowthIndicatorProps) => { + const { engineerThreshold } = useConfig(); const { change, formatter, className, ...extraProps } = props; const classes = useStyles(); - const growth = growthOf(change); + + const growth = growthOf(change, engineerThreshold); const classNames = classnames(classes.indicator, className, { [classes.excess]: growth === GrowthType.Excess, diff --git a/plugins/cost-insights/src/hooks/useConfig.tsx b/plugins/cost-insights/src/hooks/useConfig.tsx index 6095f97d49..d1b3a4788e 100644 --- a/plugins/cost-insights/src/hooks/useConfig.tsx +++ b/plugins/cost-insights/src/hooks/useConfig.tsx @@ -22,7 +22,7 @@ import React, { useState, } from 'react'; import { Config as BackstageConfig } from '@backstage/config'; -import { Currency, Icon, Metric, Product } from '../types'; +import { Currency, EngineerThreshold, Icon, Metric, Product } from '../types'; import { getIcon } from '../utils/navigation'; import { validateCurrencies, validateMetrics } from '../utils/config'; import { createCurrencyFormat, defaultCurrencies } from '../utils/currency'; @@ -70,6 +70,7 @@ export type ConfigContextProps = { products: Product[]; icons: Icon[]; engineerCost: number; + engineerThreshold: number; currencies: Currency[]; }; @@ -83,6 +84,7 @@ const defaultState: ConfigContextProps = { products: [], icons: [], engineerCost: 0, + engineerThreshold: EngineerThreshold, currencies: defaultCurrencies, }; @@ -183,11 +185,19 @@ export const ConfigProvider = ({ children }: PropsWithChildren<{}>) => { return c.getNumber('costInsights.engineerCost'); } + function getEngineerThreshold(): number { + return ( + c.getOptionalNumber('costInsights.engineerThreshold') ?? + defaultState.engineerThreshold + ); + } + function getConfig() { const baseCurrency = getBaseCurrency(); const products = getProducts(); const metrics = getMetrics(); const engineerCost = getEngineerCost(); + const engineerThreshold = getEngineerThreshold(); const icons = getIcons(); const currencies = getCurrencies(); @@ -200,6 +210,7 @@ export const ConfigProvider = ({ children }: PropsWithChildren<{}>) => { metrics, products, engineerCost, + engineerThreshold, icons, currencies, })); diff --git a/plugins/cost-insights/src/testUtils/providers.tsx b/plugins/cost-insights/src/testUtils/providers.tsx index 46eab60163..9522dfb702 100644 --- a/plugins/cost-insights/src/testUtils/providers.tsx +++ b/plugins/cost-insights/src/testUtils/providers.tsx @@ -31,7 +31,7 @@ import { ScrollContext, ScrollContextProps, } from '../hooks'; -import { Duration } from '../types'; +import { Duration, EngineerThreshold } from '../types'; import { createCurrencyFormat } from '../utils/currency'; export type MockFilterProviderProps = PropsWithChildren< @@ -95,6 +95,7 @@ export const MockConfigProvider = (props: MockConfigProviderProps) => { products: [], icons: [], engineerCost: 0, + engineerThreshold: EngineerThreshold, currencies: [], }; diff --git a/plugins/cost-insights/src/utils/change.test.ts b/plugins/cost-insights/src/utils/change.test.ts index b17e15797e..1a0b9947f5 100644 --- a/plugins/cost-insights/src/utils/change.test.ts +++ b/plugins/cost-insights/src/utils/change.test.ts @@ -63,7 +63,7 @@ describe.each` expected: GrowthType; }) => { it(`should display ${GrowthMap[expected]}`, () => { - expect(growthOf({ ratio, amount })).toBe(expected); + expect(growthOf({ ratio, amount }, EngineerThreshold)).toBe(expected); }); }, ); diff --git a/plugins/cost-insights/src/utils/change.ts b/plugins/cost-insights/src/utils/change.ts index 3a0d19b123..43d58a021b 100644 --- a/plugins/cost-insights/src/utils/change.ts +++ b/plugins/cost-insights/src/utils/change.ts @@ -18,7 +18,6 @@ import { Cost, ChangeStatistic, ChangeThreshold, - EngineerThreshold, GrowthType, MetricData, Duration, @@ -29,8 +28,11 @@ import { inclusiveStartDateOf } from './duration'; import { notEmpty } from './assert'; // Used for displaying status colors -export function growthOf(change: ChangeStatistic): GrowthType { - const exceedsEngineerThreshold = Math.abs(change.amount) >= EngineerThreshold; +export function growthOf( + change: ChangeStatistic, + engineerThreshold: number, +): GrowthType { + const exceedsEngineerThreshold = Math.abs(change.amount) >= engineerThreshold; if (notEmpty(change.ratio)) { if (exceedsEngineerThreshold && change.ratio >= ChangeThreshold.upper) { @@ -41,9 +43,12 @@ export function growthOf(change: ChangeStatistic): GrowthType { return GrowthType.Savings; } } else { - if (exceedsEngineerThreshold && change.amount > 0) return GrowthType.Excess; - if (exceedsEngineerThreshold && change.amount < 0) + if (exceedsEngineerThreshold && change.amount > 0) { + return GrowthType.Excess; + } + if (exceedsEngineerThreshold && change.amount < 0) { return GrowthType.Savings; + } } return GrowthType.Negligible;