From 6437a7d9ad5bee911894bd73b2c88f063929938b Mon Sep 17 00:00:00 2001 From: Tim Hansen Date: Tue, 18 Aug 2020 20:00:49 -0600 Subject: [PATCH 1/4] Update CODEOWNERS for internal clone --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index de0655cdbc..20e574382b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -6,6 +6,7 @@ * @spotify/backstage-core /docs/features/techdocs @spotify/techdocs-core +/plugins/cost-engineering @cost-engineering/silver-lining /plugins/techdocs @spotify/techdocs-core /plugins/techdocs-backend @spotify/techdocs-core /packages/techdocs-cli @spotify/techdocs-core From e9de56f2991fd43c2a409ae5b301462fc83248de Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Fri, 25 Sep 2020 15:38:23 -0400 Subject: [PATCH 2/4] Update CODEOWNERS --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 20e574382b..0aebb18a41 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -6,7 +6,7 @@ * @spotify/backstage-core /docs/features/techdocs @spotify/techdocs-core -/plugins/cost-engineering @cost-engineering/silver-lining +/plugins/cost-insights @spotify/silver-lining /plugins/techdocs @spotify/techdocs-core /plugins/techdocs-backend @spotify/techdocs-core /packages/techdocs-cli @spotify/techdocs-core From dbeb43fac34f7c75abbc720a0afc762b174784b6 Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Fri, 25 Sep 2020 15:52:28 -0400 Subject: [PATCH 3/4] reset from upstream --- .github/CODEOWNERS | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0aebb18a41..de0655cdbc 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -6,7 +6,6 @@ * @spotify/backstage-core /docs/features/techdocs @spotify/techdocs-core -/plugins/cost-insights @spotify/silver-lining /plugins/techdocs @spotify/techdocs-core /plugins/techdocs-backend @spotify/techdocs-core /packages/techdocs-cli @spotify/techdocs-core From a4281932fb472a02543c3ebf5375d1c86003d7f0 Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Mon, 28 Sep 2020 15:20:31 -0400 Subject: [PATCH 4/4] add cost insights plugin --- .github/CODEOWNERS | 1 + app-config.yaml | 20 + packages/app/package.json | 1 + packages/app/src/apis.ts | 6 + packages/app/src/components/Root/Root.tsx | 2 + packages/app/src/plugins.ts | 1 + .../ExampleCostInsightsClient.ts | 209 ++++++++ .../app/src/plugins/cost-insights/index.ts | 17 + plugins/cost-insights/.eslintrc.js | 3 + plugins/cost-insights/.npmrc | 1 + plugins/cost-insights/README.md | 106 ++++ plugins/cost-insights/dev/index.tsx | 20 + plugins/cost-insights/package.json | 59 +++ .../cost-insights/src/api/CostInsightsApi.ts | 115 +++++ plugins/cost-insights/src/api/index.ts | 17 + .../AlertActionCard.test.tsx | 50 ++ .../AlertActionCardList/AlertActionCard.tsx | 49 ++ .../AlertActionCardList.tsx | 36 ++ .../components/AlertActionCardList/index.ts | 17 + .../AlertInsights/AlertInsights.tsx | 51 ++ .../AlertInsights/AlertInsightsHeader.tsx | 47 ++ .../AlertInsights/AlertInsightsSection.tsx | 62 +++ .../AlertInsightsSectionHeader.tsx | 55 +++ .../src/components/AlertInsights/index.ts | 17 + .../AlertInstructionsLayout.tsx | 61 +++ .../AlertInstructionsLayout/index.ts | 17 + .../src/components/BarChart/BarChart.test.tsx | 191 ++++++++ .../src/components/BarChart/BarChart.tsx | 175 +++++++ .../src/components/BarChart/BarChartLabel.tsx | 51 ++ .../components/BarChart/BarChartStepper.tsx | 115 +++++ .../BarChart/BarChartStepperButton.tsx | 46 ++ .../src/components/BarChart/BarChartSteps.tsx | 52 ++ .../src/components/BarChart/BarChartTick.tsx | 48 ++ .../src/components/BarChart/index.ts | 17 + .../CopyUrlToClipboard/CopyUrlToClipboard.tsx | 70 +++ .../components/CopyUrlToClipboard/index.ts | 17 + .../components/CostGrowth/CostGrowth.test.tsx | 113 +++++ .../src/components/CostGrowth/CostGrowth.tsx | 74 +++ .../src/components/CostGrowth/index.ts | 17 + .../CostInsightsHeader.test.tsx | 95 ++++ .../CostInsightsHeader/CostInsightsHeader.tsx | 136 ++++++ .../components/CostInsightsHeader/index.ts | 17 + .../CostInsightsLayout/CostInsightsLayout.tsx | 59 +++ .../components/CostInsightsLayout/index.ts | 17 + .../CostInsightsNavigation.test.tsx | 79 +++ .../CostInsightsNavigation.tsx | 97 ++++ .../CostInsightsNavigation/index.ts | 17 + .../CostInsightsPage/CostInsightsPage.tsx | 249 ++++++++++ .../CostInsightsPage/CostInsightsPageRoot.tsx | 45 ++ .../CostInsightsThemeProvider.tsx | 50 ++ .../src/components/CostInsightsPage/index.ts | 17 + .../components/CostInsightsPage/selector.tsx | 42 ++ .../CostInsightsSupportButton.tsx | 28 ++ .../CostInsightsSupportButton/index.ts | 17 + .../CostInsightsTabs.test.tsx | 104 ++++ .../CostInsightsTabs/CostInsightsTabs.tsx | 111 +++++ .../src/components/CostInsightsTabs/index.ts | 17 + .../components/CostInsightsTabs/selector.ts | 50 ++ .../CostOverviewCard/CostOverviewCard.tsx | 93 ++++ .../CostOverviewCard/CostOverviewFooter.tsx | 36 ++ .../CostOverviewCard/CostOverviewHeader.tsx | 53 ++ .../src/components/CostOverviewCard/index.ts | 17 + .../components/CostOverviewCard/selector.tsx | 46 ++ .../CostOverviewChart.test.tsx | 50 ++ .../CostOverviewChart/CostOverviewChart.tsx | 120 +++++ .../CostOverviewChart/CostOverviewTooltip.tsx | 45 ++ .../src/components/CostOverviewChart/index.ts | 17 + .../CostOverviewChartLegend.test.tsx | 40 ++ .../CostOverviewChartLegend.tsx | 48 ++ .../CostOverviewChartLegend/index.ts | 17 + .../CurrencySelect/CurrencySelect.tsx | 75 +++ .../src/components/CurrencySelect/index.ts | 17 + .../LabelDataflowInstructionsPage.tsx | 96 ++++ .../LabelDataflowInstructionsPage/index.ts | 17 + .../src/components/LegendItem/LegendItem.tsx | 78 +++ .../src/components/LegendItem/index.ts | 17 + .../MetricSelect/MetricSelect.test.tsx | 71 +++ .../components/MetricSelect/MetricSelect.tsx | 67 +++ .../src/components/MetricSelect/index.ts | 17 + .../PeriodSelect/PeriodSelect.test.tsx | 84 ++++ .../components/PeriodSelect/PeriodSelect.tsx | 100 ++++ .../src/components/PeriodSelect/index.ts | 17 + .../ProductInsights/ProductInsights.tsx | 43 ++ .../src/components/ProductInsights/index.ts | 17 + .../ProductInsightsCard.test.tsx | 167 +++++++ .../ProductInsightsCard.tsx | 147 ++++++ .../components/ProductInsightsCard/index.ts | 17 + .../ProductInsightsCard/selector.ts | 56 +++ .../ProjectGrowthAlertCard.test.tsx | 85 ++++ .../ProjectGrowthAlertCard.tsx | 60 +++ .../ProjectGrowthAlertCard/index.ts | 17 + .../ProjectGrowthInstructionsPage.tsx | 212 ++++++++ .../ProjectGrowthInstructionsPage/index.ts | 17 + .../ProjectSelect/ProjectSelect.test.tsx | 68 +++ .../ProjectSelect/ProjectSelect.tsx | 70 +++ .../src/components/ProjectSelect/index.ts | 17 + .../ResourceGrowthBarChart.test.tsx | 41 ++ .../ResourceGrowthBarChart.tsx | 97 ++++ .../ResourceGrowthBarChart/index.ts | 17 + .../ResourceGrowthBarChartLegend.test.tsx | 93 ++++ .../ResourceGrowthBarChartLegend.tsx | 72 +++ .../ResourceGrowthBarChartLegend/index.ts | 17 + .../src/components/Tooltip/Tooltip.test.tsx | 59 +++ .../src/components/Tooltip/Tooltip.tsx | 54 +++ .../src/components/Tooltip/TooltipItem.tsx | 49 ++ .../src/components/Tooltip/index.ts | 18 + .../UnlabeledDataflowAlertCard.test.tsx | 66 +++ .../UnlabeledDataflowAlertCard.tsx | 51 ++ .../UnlabeledDataflowAlertCard/index.ts | 17 + .../UnlabeledDataflowBarChart.tsx | 73 +++ .../UnlabeledDataflowBarChart/index.ts | 17 + .../UnlabeledDataflowBarChartLegend.test.tsx | 34 ++ .../UnlabeledDataflowBarChartLegend.tsx | 59 +++ .../UnlabeledDataflowBarChartLegend/index.ts | 17 + .../WhyCostsMatter/WhyCostsMatter.tsx | 77 +++ .../src/components/WhyCostsMatter/index.ts | 17 + plugins/cost-insights/src/hooks/index.ts | 24 + plugins/cost-insights/src/hooks/useConfig.tsx | 162 +++++++ .../cost-insights/src/hooks/useCurrency.tsx | 60 +++ .../cost-insights/src/hooks/useFilters.tsx | 148 ++++++ plugins/cost-insights/src/hooks/useGroups.tsx | 84 ++++ .../cost-insights/src/hooks/useLoading.tsx | 107 ++++ .../src/hooks/useQueryParams.tsx | 35 ++ plugins/cost-insights/src/hooks/useScroll.tsx | 126 +++++ plugins/cost-insights/src/index.ts | 19 + plugins/cost-insights/src/plugin.test.ts | 23 + plugins/cost-insights/src/plugin.ts | 45 ++ plugins/cost-insights/src/setupTests.ts | 19 + plugins/cost-insights/src/types/Alert.ts | 77 +++ .../src/types/ChangeStatistic.ts | 48 ++ plugins/cost-insights/src/types/Cost.ts | 26 + plugins/cost-insights/src/types/Currency.ts | 78 +++ .../src/types/DateAggregation.ts | 20 + .../cost-insights/src/types/Duration.test.ts | 32 ++ plugins/cost-insights/src/types/Duration.ts | 85 ++++ plugins/cost-insights/src/types/Entity.ts | 24 + plugins/cost-insights/src/types/Filters.ts | 44 ++ plugins/cost-insights/src/types/Group.ts | 19 + plugins/cost-insights/src/types/Icon.ts | 29 ++ plugins/cost-insights/src/types/Loading.ts | 63 +++ plugins/cost-insights/src/types/Maybe.ts | 45 ++ plugins/cost-insights/src/types/Metric.ts | 22 + plugins/cost-insights/src/types/Product.ts | 30 ++ plugins/cost-insights/src/types/Project.ts | 19 + plugins/cost-insights/src/types/Theme.ts | 47 ++ plugins/cost-insights/src/types/Trendline.ts | 19 + plugins/cost-insights/src/types/index.ts | 33 ++ plugins/cost-insights/src/utils/alerts.tsx | 82 ++++ .../src/utils/formatters.test.ts | 50 ++ plugins/cost-insights/src/utils/formatters.ts | 126 +++++ plugins/cost-insights/src/utils/grammar.ts | 47 ++ plugins/cost-insights/src/utils/graphs.tsx | 31 ++ plugins/cost-insights/src/utils/history.ts | 23 + plugins/cost-insights/src/utils/mockData.ts | 194 ++++++++ .../cost-insights/src/utils/navigation.tsx | 74 +++ plugins/cost-insights/src/utils/sort.test.ts | 32 ++ plugins/cost-insights/src/utils/sort.ts | 23 + plugins/cost-insights/src/utils/styles.ts | 455 ++++++++++++++++++ plugins/cost-insights/src/utils/tests.tsx | 86 ++++ .../src/components/ExplorePluginPage.tsx | 7 + yarn.lock | 192 +++++++- 161 files changed, 9477 insertions(+), 6 deletions(-) create mode 100644 packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts create mode 100644 packages/app/src/plugins/cost-insights/index.ts create mode 100644 plugins/cost-insights/.eslintrc.js create mode 100644 plugins/cost-insights/.npmrc create mode 100644 plugins/cost-insights/README.md create mode 100644 plugins/cost-insights/dev/index.tsx create mode 100644 plugins/cost-insights/package.json create mode 100644 plugins/cost-insights/src/api/CostInsightsApi.ts create mode 100644 plugins/cost-insights/src/api/index.ts create mode 100644 plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.test.tsx create mode 100644 plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.tsx create mode 100644 plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx create mode 100644 plugins/cost-insights/src/components/AlertActionCardList/index.ts create mode 100644 plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx create mode 100644 plugins/cost-insights/src/components/AlertInsights/AlertInsightsHeader.tsx create mode 100644 plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx create mode 100644 plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx create mode 100644 plugins/cost-insights/src/components/AlertInsights/index.ts create mode 100644 plugins/cost-insights/src/components/AlertInstructionsLayout/AlertInstructionsLayout.tsx create mode 100644 plugins/cost-insights/src/components/AlertInstructionsLayout/index.ts create mode 100644 plugins/cost-insights/src/components/BarChart/BarChart.test.tsx create mode 100644 plugins/cost-insights/src/components/BarChart/BarChart.tsx create mode 100644 plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx create mode 100644 plugins/cost-insights/src/components/BarChart/BarChartStepper.tsx create mode 100644 plugins/cost-insights/src/components/BarChart/BarChartStepperButton.tsx create mode 100644 plugins/cost-insights/src/components/BarChart/BarChartSteps.tsx create mode 100644 plugins/cost-insights/src/components/BarChart/BarChartTick.tsx create mode 100644 plugins/cost-insights/src/components/BarChart/index.ts create mode 100644 plugins/cost-insights/src/components/CopyUrlToClipboard/CopyUrlToClipboard.tsx create mode 100644 plugins/cost-insights/src/components/CopyUrlToClipboard/index.ts create mode 100644 plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx create mode 100644 plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx create mode 100644 plugins/cost-insights/src/components/CostGrowth/index.ts create mode 100644 plugins/cost-insights/src/components/CostInsightsHeader/CostInsightsHeader.test.tsx create mode 100644 plugins/cost-insights/src/components/CostInsightsHeader/CostInsightsHeader.tsx create mode 100644 plugins/cost-insights/src/components/CostInsightsHeader/index.ts create mode 100644 plugins/cost-insights/src/components/CostInsightsLayout/CostInsightsLayout.tsx create mode 100644 plugins/cost-insights/src/components/CostInsightsLayout/index.ts create mode 100644 plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.test.tsx create mode 100644 plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx create mode 100644 plugins/cost-insights/src/components/CostInsightsNavigation/index.ts create mode 100644 plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx create mode 100644 plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPageRoot.tsx create mode 100644 plugins/cost-insights/src/components/CostInsightsPage/CostInsightsThemeProvider.tsx create mode 100644 plugins/cost-insights/src/components/CostInsightsPage/index.ts create mode 100644 plugins/cost-insights/src/components/CostInsightsPage/selector.tsx create mode 100644 plugins/cost-insights/src/components/CostInsightsSupportButton/CostInsightsSupportButton.tsx create mode 100644 plugins/cost-insights/src/components/CostInsightsSupportButton/index.ts create mode 100644 plugins/cost-insights/src/components/CostInsightsTabs/CostInsightsTabs.test.tsx create mode 100644 plugins/cost-insights/src/components/CostInsightsTabs/CostInsightsTabs.tsx create mode 100644 plugins/cost-insights/src/components/CostInsightsTabs/index.ts create mode 100644 plugins/cost-insights/src/components/CostInsightsTabs/selector.ts create mode 100644 plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx create mode 100644 plugins/cost-insights/src/components/CostOverviewCard/CostOverviewFooter.tsx create mode 100644 plugins/cost-insights/src/components/CostOverviewCard/CostOverviewHeader.tsx create mode 100644 plugins/cost-insights/src/components/CostOverviewCard/index.ts create mode 100644 plugins/cost-insights/src/components/CostOverviewCard/selector.tsx create mode 100644 plugins/cost-insights/src/components/CostOverviewChart/CostOverviewChart.test.tsx create mode 100644 plugins/cost-insights/src/components/CostOverviewChart/CostOverviewChart.tsx create mode 100644 plugins/cost-insights/src/components/CostOverviewChart/CostOverviewTooltip.tsx create mode 100644 plugins/cost-insights/src/components/CostOverviewChart/index.ts create mode 100644 plugins/cost-insights/src/components/CostOverviewChartLegend/CostOverviewChartLegend.test.tsx create mode 100644 plugins/cost-insights/src/components/CostOverviewChartLegend/CostOverviewChartLegend.tsx create mode 100644 plugins/cost-insights/src/components/CostOverviewChartLegend/index.ts create mode 100644 plugins/cost-insights/src/components/CurrencySelect/CurrencySelect.tsx create mode 100644 plugins/cost-insights/src/components/CurrencySelect/index.ts create mode 100644 plugins/cost-insights/src/components/LabelDataflowInstructionsPage/LabelDataflowInstructionsPage.tsx create mode 100644 plugins/cost-insights/src/components/LabelDataflowInstructionsPage/index.ts create mode 100644 plugins/cost-insights/src/components/LegendItem/LegendItem.tsx create mode 100644 plugins/cost-insights/src/components/LegendItem/index.ts create mode 100644 plugins/cost-insights/src/components/MetricSelect/MetricSelect.test.tsx create mode 100644 plugins/cost-insights/src/components/MetricSelect/MetricSelect.tsx create mode 100644 plugins/cost-insights/src/components/MetricSelect/index.ts create mode 100644 plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx create mode 100644 plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx create mode 100644 plugins/cost-insights/src/components/PeriodSelect/index.ts create mode 100644 plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx create mode 100644 plugins/cost-insights/src/components/ProductInsights/index.ts create mode 100644 plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx create mode 100644 plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx create mode 100644 plugins/cost-insights/src/components/ProductInsightsCard/index.ts create mode 100644 plugins/cost-insights/src/components/ProductInsightsCard/selector.ts create mode 100644 plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx create mode 100644 plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx create mode 100644 plugins/cost-insights/src/components/ProjectGrowthAlertCard/index.ts create mode 100644 plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx create mode 100644 plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/index.ts create mode 100644 plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx create mode 100644 plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx create mode 100644 plugins/cost-insights/src/components/ProjectSelect/index.ts create mode 100644 plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.test.tsx create mode 100644 plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.tsx create mode 100644 plugins/cost-insights/src/components/ResourceGrowthBarChart/index.ts create mode 100644 plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.test.tsx create mode 100644 plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.tsx create mode 100644 plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/index.ts create mode 100644 plugins/cost-insights/src/components/Tooltip/Tooltip.test.tsx create mode 100644 plugins/cost-insights/src/components/Tooltip/Tooltip.tsx create mode 100644 plugins/cost-insights/src/components/Tooltip/TooltipItem.tsx create mode 100644 plugins/cost-insights/src/components/Tooltip/index.ts create mode 100644 plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.test.tsx create mode 100644 plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.tsx create mode 100644 plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/index.ts create mode 100644 plugins/cost-insights/src/components/UnlabeledDataflowBarChart/UnlabeledDataflowBarChart.tsx create mode 100644 plugins/cost-insights/src/components/UnlabeledDataflowBarChart/index.ts create mode 100644 plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/UnlabeledDataflowBarChartLegend.test.tsx create mode 100644 plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/UnlabeledDataflowBarChartLegend.tsx create mode 100644 plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/index.ts create mode 100644 plugins/cost-insights/src/components/WhyCostsMatter/WhyCostsMatter.tsx create mode 100644 plugins/cost-insights/src/components/WhyCostsMatter/index.ts create mode 100644 plugins/cost-insights/src/hooks/index.ts create mode 100644 plugins/cost-insights/src/hooks/useConfig.tsx create mode 100644 plugins/cost-insights/src/hooks/useCurrency.tsx create mode 100644 plugins/cost-insights/src/hooks/useFilters.tsx create mode 100644 plugins/cost-insights/src/hooks/useGroups.tsx create mode 100644 plugins/cost-insights/src/hooks/useLoading.tsx create mode 100644 plugins/cost-insights/src/hooks/useQueryParams.tsx create mode 100644 plugins/cost-insights/src/hooks/useScroll.tsx create mode 100644 plugins/cost-insights/src/index.ts create mode 100644 plugins/cost-insights/src/plugin.test.ts create mode 100644 plugins/cost-insights/src/plugin.ts create mode 100644 plugins/cost-insights/src/setupTests.ts create mode 100644 plugins/cost-insights/src/types/Alert.ts create mode 100644 plugins/cost-insights/src/types/ChangeStatistic.ts create mode 100644 plugins/cost-insights/src/types/Cost.ts create mode 100644 plugins/cost-insights/src/types/Currency.ts create mode 100644 plugins/cost-insights/src/types/DateAggregation.ts create mode 100644 plugins/cost-insights/src/types/Duration.test.ts create mode 100644 plugins/cost-insights/src/types/Duration.ts create mode 100644 plugins/cost-insights/src/types/Entity.ts create mode 100644 plugins/cost-insights/src/types/Filters.ts create mode 100644 plugins/cost-insights/src/types/Group.ts create mode 100644 plugins/cost-insights/src/types/Icon.ts create mode 100644 plugins/cost-insights/src/types/Loading.ts create mode 100644 plugins/cost-insights/src/types/Maybe.ts create mode 100644 plugins/cost-insights/src/types/Metric.ts create mode 100644 plugins/cost-insights/src/types/Product.ts create mode 100644 plugins/cost-insights/src/types/Project.ts create mode 100644 plugins/cost-insights/src/types/Theme.ts create mode 100644 plugins/cost-insights/src/types/Trendline.ts create mode 100644 plugins/cost-insights/src/types/index.ts create mode 100644 plugins/cost-insights/src/utils/alerts.tsx create mode 100644 plugins/cost-insights/src/utils/formatters.test.ts create mode 100644 plugins/cost-insights/src/utils/formatters.ts create mode 100644 plugins/cost-insights/src/utils/grammar.ts create mode 100644 plugins/cost-insights/src/utils/graphs.tsx create mode 100644 plugins/cost-insights/src/utils/history.ts create mode 100644 plugins/cost-insights/src/utils/mockData.ts create mode 100644 plugins/cost-insights/src/utils/navigation.tsx create mode 100644 plugins/cost-insights/src/utils/sort.test.ts create mode 100644 plugins/cost-insights/src/utils/sort.ts create mode 100644 plugins/cost-insights/src/utils/styles.ts create mode 100644 plugins/cost-insights/src/utils/tests.tsx diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index de0655cdbc..0aebb18a41 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -6,6 +6,7 @@ * @spotify/backstage-core /docs/features/techdocs @spotify/techdocs-core +/plugins/cost-insights @spotify/silver-lining /plugins/techdocs @spotify/techdocs-core /plugins/techdocs-backend @spotify/techdocs-core /packages/techdocs-cli @spotify/techdocs-core diff --git a/app-config.yaml b/app-config.yaml index a29240fd9c..e54b3b9e60 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -215,3 +215,23 @@ auth: tenantId: $secret: env: AUTH_MICROSOFT_TENANT_ID +costInsights: + engineerCost: 200000 + products: + computeEngine: + name: Compute Engine + icon: compute + cloudDataflow: + name: Cloud Dataflow + icon: data + cloudStorage: + name: Cloud Storage + icon: storage + bigQuery: + name: Big Query + icon: search + metrics: + dailyCost: + name: Your Company's Daily Cost + DAU: + name: Cost Per DAU diff --git a/packages/app/package.json b/packages/app/package.json index 77b1882b47..44d18b833d 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -10,6 +10,7 @@ "@backstage/plugin-api-docs": "^0.1.1-alpha.23", "@backstage/plugin-catalog": "^0.1.1-alpha.23", "@backstage/plugin-circleci": "^0.1.1-alpha.23", + "@backstage/plugin-cost-insights": "^0.1.1-alpha.23", "@backstage/plugin-explore": "^0.1.1-alpha.23", "@backstage/plugin-gcp-projects": "^0.1.1-alpha.23", "@backstage/plugin-github-actions": "^0.1.1-alpha.23", diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index 581a591152..c129bebfa2 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -29,11 +29,15 @@ import { TravisCIApi, travisCIApiRef, } from '@roadiehq/backstage-plugin-travis-ci'; + import { GithubPullRequestsClient, githubPullRequestsApiRef, } from '@roadiehq/backstage-plugin-github-pull-requests'; +import { costInsightsApiRef } from '@backstage/plugin-cost-insights'; +import { ExampleCostInsightsClient } from './plugins/cost-insights'; + export const apis = [ createApiFactory({ api: graphQlBrowseApiRef, @@ -54,6 +58,8 @@ export const apis = [ ]), }), + createApiFactory(costInsightsApiRef, new ExampleCostInsightsClient()), + // TODO: move to plugins createApiFactory(travisCIApiRef, new TravisCIApi()), createApiFactory(githubPullRequestsApiRef, new GithubPullRequestsClient()), diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 03e24cf4ae..45ffca7801 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -23,6 +23,7 @@ import RuleIcon from '@material-ui/icons/AssignmentTurnedIn'; import MapIcon from '@material-ui/icons/MyLocation'; import LibraryBooks from '@material-ui/icons/LibraryBooks'; import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; +import MoneyIcon from '@material-ui/icons/MonetizationOn'; import LogoFull from './LogoFull'; import LogoIcon from './LogoIcon'; import { @@ -94,6 +95,7 @@ const Root: FC<{}> = ({ children }) => ( + { + return new Promise(resolve => setTimeout(resolve, 0, res)); + } + + async getUserGroups(userId: string): Promise { + const groups: Group[] = await this.request({ userId }, [ + { id: 'pied-piper' }, + ]); + + return groups; + } + + async getGroupProjects(group: string): Promise { + const projects: Project[] = await this.request({ group }, [ + { id: 'project-a' }, + { id: 'project-b' }, + { id: 'project-c' }, + ]); + + return projects; + } + + async getGroupDailyCost( + group: string, + metric: string | null, + intervals: string, + ): Promise { + const groupDailyCost: Cost = await this.request( + { group, metric, intervals }, + { + id: metric, // costs with null ids will appear as "All Projects" in Cost Overview panel + aggregation: [ + { date: '2020-08-01', amount: 75_000 / (metric ? 200_000 : 1) }, + { date: '2020-08-02', amount: 120_000 / (metric ? 200_000 : 1) }, + { date: '2020-08-03', amount: 110_000 / (metric ? 200_000 : 1) }, + { date: '2020-08-04', amount: 90_000 / (metric ? 200_000 : 1) }, + { date: '2020-08-05', amount: 80_000 / (metric ? 200_000 : 1) }, + { date: '2020-08-06', amount: 85_000 / (metric ? 200_000 : 1) }, + { date: '2020-08-07', amount: 82_500 / (metric ? 200_000 : 1) }, + { date: '2020-08-08', amount: 100_000 / (metric ? 200_000 : 1) }, + { date: '2020-08-09', amount: 130_000 / (metric ? 200_000 : 1) }, + { date: '2020-08-10', amount: 140_000 / (metric ? 200_000 : 1) }, + ], + change: { + ratio: 0.86, + amount: 65_000, + }, + trendline: { + slope: 0, + intercept: 90_000, + }, + }, + ); + + return groupDailyCost; + } + + async getProjectDailyCost( + project: string, + metric: string | null, + intervals: string, + ): Promise { + const projectDailyCost: Cost = await this.request( + { project, metric, intervals }, + { + id: 'project-a', + aggregation: [ + { date: '2020-08-01', amount: 1000 }, + { date: '2020-08-02', amount: 2000 }, + { date: '2020-08-03', amount: 3000 }, + { date: '2020-08-04', amount: 4000 }, + { date: '2020-08-05', amount: 5000 }, + { date: '2020-08-06', amount: 6000 }, + { date: '2020-08-07', amount: 7000 }, + { date: '2020-08-08', amount: 8000 }, + { date: '2020-08-09', amount: 9000 }, + { date: '2020-08-10', amount: 10_000 }, + ], + change: { + ratio: 0.5, + amount: 10000, + }, + trendline: { + slope: 0, + intercept: 0, + }, + }, + ); + + return projectDailyCost; + } + + async getProductInsights( + product: string, + group: string, + duration: Duration, + ): Promise { + const productInsights: ProductCost = await this.request( + { product, group, duration }, + { + aggregation: [200_000, 250_000], + change: { + ratio: 0.2, + amount: 50_000, + }, + entities: [ + { + id: null, // entities with null ids will be appear as "Unlabeled" in product panels + aggregation: [45_000, 50_000], + }, + { + id: 'entity-a', + aggregation: [15_000, 20_000], + }, + { + id: 'entity-b', + aggregation: [20_000, 30_000], + }, + { + id: 'entity-c', + aggregation: [18_000, 25_000], + }, + { + id: 'entity-d', + aggregation: [36_000, 42_000], + }, + { + id: 'entity-e', + aggregation: [0, 10_000], + }, + { + id: 'entity-f', + aggregation: [17_000, 19_000], + }, + { + id: 'entity-g', + aggregation: [49_000, 30_000], + }, + { + id: 'entity-h', + aggregation: [0, 34_000], + }, + ], + }, + ); + + return productInsights; + } + + async getAlerts(group: string): Promise { + const alerts: Alert[] = await this.request({ group }, [ + { + id: 'projectGrowth', + project: 'example-project', + periodStart: 'Q1 2020', + periodEnd: 'Q2 2020', + aggregation: [60_000, 120_000], + change: { + ratio: 1, + amount: 60000, + }, + products: [ + { + id: 'Compute Engine', + aggregation: [58_000, 118_000], + }, + { + id: 'Cloud Dataflow', + aggregation: [1200, 1500], + }, + { + id: 'Cloud Storage', + aggregation: [800, 500], + }, + ], + }, + ]); + + return alerts; + } +} diff --git a/packages/app/src/plugins/cost-insights/index.ts b/packages/app/src/plugins/cost-insights/index.ts new file mode 100644 index 0000000000..47d540049f --- /dev/null +++ b/packages/app/src/plugins/cost-insights/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { ExampleCostInsightsClient } from './ExampleCostInsightsClient'; diff --git a/plugins/cost-insights/.eslintrc.js b/plugins/cost-insights/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/cost-insights/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/cost-insights/.npmrc b/plugins/cost-insights/.npmrc new file mode 100644 index 0000000000..214c29d139 --- /dev/null +++ b/plugins/cost-insights/.npmrc @@ -0,0 +1 @@ +registry=https://registry.npmjs.org/ diff --git a/plugins/cost-insights/README.md b/plugins/cost-insights/README.md new file mode 100644 index 0000000000..21156c10b3 --- /dev/null +++ b/plugins/cost-insights/README.md @@ -0,0 +1,106 @@ +# Cost Insights + +Cost Insights is a plugin to help engineers visualize, understand and optimize their cloud costs. The Cost Insights page shows daily cost data for a team, trends over time, and comparisons with the business metrics you care about. + +At Spotify, we find that cloud costs are optimized organically when: + +- Engineers see cost data in their daily work (that is, in Backstage). +- It's clear when cloud costs need attention. +- The data is shown in software terms familiar to them. +- Alerts and recommendations are targeted and actionable. + +Cost Insights shows trends over time, at the granularity of your software deployments - rather than the cloud provider's concepts. It can be used to troubleshoot cost anomalies, and promote cost-saving infrastructure migrations. + +## Install + +```bash +yarn add @backstage/plugin-cost-insights +``` + +## Setup + +1. Configure `app-config.yaml`. See [Configuration](#configuration). + +2. Create a CostInsights client. Clients must implement the CostInsightsApi interface. See the [API file](https://github.com/spotify/backstage/plugins/cost-insights/src/api/CostInsightsApi.ts) for required methods and documentation. + +```ts +// path/to/CostInsightsClient.ts +import { CostInsightsApi } from '@backstage/plugin-cost-insights'; + +export class CostInsightsClient implements CostInsightsApi { ... } +``` + +3. Import the client and the CostInsights plugin API to your Backstage instance. + +```ts +// packages/app/src/api.ts +import { createApiFactory } from '@backstage/core'; +import { costInsightsApiRef } from '@backstage/plugin-cost-insights'; +import { CostInsightsClient } from './path/to/file'; + +export const apis = [ + createApiFactory({ + api: costInsightsApiRef, + deps: {}, + factory: () => new CostInsightsClient(), + }), +]; +``` + +4. Add cost-insights to your Backstage plugins. + +```ts +// packages/app/src/plugins.ts +export { plugin as CostInsights } from '@backstage/plugin-cost-insights'; +``` + +## Configuration + +Cost Insights has only two required configuration fields: a map of cloud `products` for showing cost breakdowns and `engineerCost` - the average yearly cost of an engineer including benefits. Products must be defined as keys on the `products` field. + +You can optionally supply a product `icon` to display in Cost Insights navigation. See the [type file](https://github.com/spotify/backstage/plugins/cost-insights/types/Icon.ts) for supported types and Material UI icon [mappings](https://github.com/spotify/backstage/plugins/cost-insights/utils/navigation.ts). + +**Note:** Product keys should be unique and camelCased. Backstage does not support underscores in configuration keys. + +### Basic + +```yaml +## ./app-config.yaml +costInsights: + engineerCost: 200000 + products: + productA: + name: Some Cloud Product ## required + icon: storage + productB: + name: Some Other Cloud Product + icon: data +``` + +### Metrics (Optional) + +In the `Cost Overview` panel, users can choose from a dropdown of business metrics to see costs as they relate to a metric, such as daily active users. Metrics must be defined as keys on the `metrics` field. A user-friendly name is **required**. Metrics will be provided to the `getDailyCost` and `getProjectCosts` API methods via the `metric` parameter. + +**Note:** Cost Insights displays daily cost without a metric by default. The dropdown text for this default can be overriden by assigning it a value on the `dailyCost` field. + +```yaml +## ./app-config.yaml +costInsights: + engineerCost: 200000 + products: + productA: + name: Some Cloud Product + icon: storage + productB: + name: Some Other Cloud Product + icon: data + metrics: + dailyCost: + name: Earth Rotation + metricA: + name: Metric A ## required + metricB: + name: Metric B + metricC: + name: Metric C +``` diff --git a/plugins/cost-insights/dev/index.tsx b/plugins/cost-insights/dev/index.tsx new file mode 100644 index 0000000000..812a5585d4 --- /dev/null +++ b/plugins/cost-insights/dev/index.tsx @@ -0,0 +1,20 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { createDevApp } from '@backstage/dev-utils'; +import { plugin } from '../src/plugin'; + +createDevApp().registerPlugin(plugin).render(); diff --git a/plugins/cost-insights/package.json b/plugins/cost-insights/package.json new file mode 100644 index 0000000000..96a74cb746 --- /dev/null +++ b/plugins/cost-insights/package.json @@ -0,0 +1,59 @@ +{ + "name": "@backstage/plugin-cost-insights", + "version": "0.1.1-alpha.23", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "build": "backstage-cli plugin:build", + "start": "backstage-cli plugin:serve", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "diff": "backstage-cli plugin:diff", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/config": "^0.1.1-alpha.23", + "@backstage/core": "^0.1.1-alpha.23", + "@backstage/test-utils": "^0.1.1-alpha.23", + "@backstage/theme": "^0.1.1-alpha.23", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.45", + "@material-ui/styles": "^4.9.6", + "canvas": "^2.6.1", + "classnames": "^2.2.6", + "history": "^5.0.0", + "moment": "^2.27.0", + "qs": "^6.9.4", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-router-dom": "6.0.0-beta.0", + "react-use": "^15.3.3", + "recharts": "^1.8.5" + }, + "devDependencies": { + "@backstage/cli": "^0.1.1-alpha.23", + "@backstage/dev-utils": "^0.1.1-alpha.23", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^10.4.1", + "@testing-library/user-event": "^12.0.7", + "@types/jest": "^26.0.7", + "@types/node": "^12.0.0", + "@types/recharts": "^1.8.14", + "jest-fetch-mock": "^3.0.3", + "msw": "^0.20.5", + "node-fetch": "^2.6.1" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/cost-insights/src/api/CostInsightsApi.ts b/plugins/cost-insights/src/api/CostInsightsApi.ts new file mode 100644 index 0000000000..b7628e2f9e --- /dev/null +++ b/plugins/cost-insights/src/api/CostInsightsApi.ts @@ -0,0 +1,115 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { createApiRef } from '@backstage/core'; +import { Alert, Cost, Duration, Group, Project, ProductCost } from '../types'; + +export type CostInsightsApi = { + /** + * Get a list of groups the given user belongs to. These may be LDAP groups or similar + * organizational groups. Cost Insights is designed to show costs based on group membership; + * if a user has multiple groups, they are able to switch between groups to see costs for each. + * + * This method should be removed once the Backstage identity plugin provides the same concept. + */ + getUserGroups(userId: string): Promise; + + /** + * Get a list of cloud billing entities that belong to this group (projects in GCP, AWS has a + * similar concept in billing accounts). These act as filters for the displayed costs, users can + * choose whether they see all costs for a group, or those from a particular owned project. + */ + getGroupProjects(group: string): Promise; + + /** + * Get daily cost aggregations for a given group and interval timeframe. + * + * 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. + * + * @param group The group id from getUserGroups or query parameters + * @param metric A metric from the cost-insights configuration in app-config.yaml. The backend + * should divide the actual daily cost by the corresponding metric for the same date. + * @param intervals An ISO 8601 repeating interval string, such as R2/P1M/2020-09-01 + * https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals + */ + getGroupDailyCost( + group: string, + metric: string | null, + intervals: string, + ): Promise; + + /** + * Get daily cost aggregations for a given billing entity (project in GCP, AWS has a similar + * concept in billing accounts) and interval timeframe. + * + * 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 the project's cost growth + * (or reduction) and compare it to metrics important to the business. + * + * @param project The project id from getGroupProjects or query parameters + * @param metric A metric from the cost-insights configuration in app-config.yaml. The backend + * should divide the actual daily cost by the corresponding metric for the same date. + * @param intervals An ISO 8601 repeating interval string, such as R2/P1M/2020-09-01 + * https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals + */ + getProjectDailyCost( + project: string, + metric: string | null, + intervals: string, + ): Promise; + + /** + * Get cost aggregations for a particular cloud product and interval timeframe. This includes + * total cost for the product, as well as a breakdown of particular entities that incurred cost + * in this product. The type of entity depends on the product - it may be deployed services, + * storage buckets, managed database instances, etc. + * + * The time period is supplied as a Duration rather than intervals, since this is always expected + * to return data for two bucketed time period (e.g. month vs month, or quarter vs quarter). + * + * @param product The product from the cost-insights configuration in app-config.yaml + * @param group + * @param duration A time duration, such as P1M. See the Duration type for a detailed explanation + * of how the durations are interpreted in Cost Insights. + */ + getProductInsights( + product: string, + group: string, + duration: Duration, + ): Promise; + + /** + * Get current cost alerts for a given group. These show up as Action Items for the group on the + * Cost Insights page. Alerts may include cost-saving recommendations, such as infrastructure + * migrations, or cost-related warnings, such as an unexpected billing anomaly. + */ + getAlerts(group: string): Promise; +}; + +export const costInsightsApiRef = createApiRef({ + id: 'plugin.costinsights.service', + description: 'Provides cost data and alerts for the cost-insights plugin', +}); diff --git a/plugins/cost-insights/src/api/index.ts b/plugins/cost-insights/src/api/index.ts new file mode 100644 index 0000000000..d231570e9b --- /dev/null +++ b/plugins/cost-insights/src/api/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 * from './CostInsightsApi'; diff --git a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.test.tsx b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.test.tsx new file mode 100644 index 0000000000..6e6f935137 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.test.tsx @@ -0,0 +1,50 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 AlertActionCard from './AlertActionCard'; +import { AlertType, ProjectGrowthAlert } from '../../types'; +import { getAlertText } from '../../utils/alerts'; +import { MockScrollProvider } from '../../utils/tests'; + +const alert = { + id: AlertType.ProjectGrowth, + aggregation: [500000.8, 970502.8], + project: 'test-project', + periodStart: '2019-10-01', + periodEnd: '2020-03-31', + change: { ratio: 120, amount: 120000 }, + products: [], +} as ProjectGrowthAlert; + +describe('', () => { + it('Renders an alert', async () => { + const rendered = await renderInTestApp( + + , + , + ); + + expect(rendered.getByText('1')).toBeInTheDocument(); + const text = getAlertText(alert); + expect(text).toBeDefined(); + if (text) { + expect(rendered.getByText(text.title)).toBeInTheDocument(); + expect(rendered.getByText(text.subtitle)).toBeInTheDocument(); + } + }); +}); diff --git a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.tsx b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.tsx new file mode 100644 index 0000000000..f568f999f8 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.tsx @@ -0,0 +1,49 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Avatar, Card, CardHeader } from '@material-ui/core'; +import { useScroll } from '../../hooks'; +import { Alert } from '../../types'; +import { getAlertText, getAlertNavigation } from '../../utils/alerts'; +import { + useAlertActionCardStyles as useStyles, + useAlertActionCardHeader as useHeaderStyles, +} from '../../utils/styles'; + +type AlertActionCardProps = { + alert: Alert; + number: number; +}; + +const AlertActionCard = ({ alert, number }: AlertActionCardProps) => { + const { scrollIntoView } = useScroll(getAlertNavigation(alert, number)); + const headerClasses = useHeaderStyles(); + const text = getAlertText(alert); + const classes = useStyles(); + + return ( + + {number}} + title={text?.title} + subheader={text?.subtitle} + /> + + ); +}; + +export default AlertActionCard; diff --git a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx new file mode 100644 index 0000000000..4ddc26e040 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx @@ -0,0 +1,36 @@ +/* + * Copyright 2020 Spotify AB + * + * 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, { FC, Fragment } from 'react'; +import { Paper, Divider } from '@material-ui/core'; +import AlertActionCard from './AlertActionCard'; +import { Alert } from '../../types'; + +type AlertActionCardList = { + alerts: Array; +}; + +const AlertActionCardList: FC = ({ alerts }) => ( + + {alerts.map((alert, index) => ( + + + {index < alerts.length - 1 && } + + ))} + +); + +export default AlertActionCardList; diff --git a/plugins/cost-insights/src/components/AlertActionCardList/index.ts b/plugins/cost-insights/src/components/AlertActionCardList/index.ts new file mode 100644 index 0000000000..cccffd2985 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertActionCardList/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default } from './AlertActionCardList'; diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx new file mode 100644 index 0000000000..59dac3a758 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Grid } from '@material-ui/core'; +import AlertInsightsSection from './AlertInsightsSection'; +import AlertInsightsHeader from './AlertInsightsHeader'; +import { Alert } from '../../types'; +import { renderAlert } from '../../utils/alerts'; + +const title = "Your team's action items"; +const subtitle = + 'This section outlines suggested action items your team can address to improve cloud costs.'; + +type AlertInsightsProps = { + alerts: Array; +}; + +const AlertInsights = ({ alerts }: AlertInsightsProps) => ( + + + + + + {alerts.map((alert, index) => ( + + + + ))} + + +); + +export default AlertInsights; diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsHeader.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsHeader.tsx new file mode 100644 index 0000000000..b08ea01730 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsHeader.tsx @@ -0,0 +1,47 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Box, Typography } from '@material-ui/core'; +import { useCostInsightsStyles as useStyles } from '../../utils/styles'; +import { useScroll } from '../../hooks'; +import { DefaultNavigation } from '../../utils/navigation'; + +type AlertInsightsHeaderProps = { + title: string; + subtitle: string; +}; + +const AlertInsightsHeader = ({ title, subtitle }: AlertInsightsHeaderProps) => { + const classes = useStyles(); + const { ScrollAnchor } = useScroll(DefaultNavigation.AlertInsightsHeader); + return ( + + + + {title}{' '} + + 🎯 + + + + {subtitle} + + + ); +}; + +export default AlertInsightsHeader; diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx new file mode 100644 index 0000000000..c00d924cf2 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx @@ -0,0 +1,62 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Box, Button } from '@material-ui/core'; +import AlertInsightsSectionHeader from './AlertInsightsSectionHeader'; +import { + getAlertButtonText, + getAlertText, + getAlertUrl, +} from '../../utils/alerts'; +import { Alert, Currency } from '../../types'; +import { useCurrency } from '../../hooks'; + +type AlertInsightsSectionProps = { + alert: Alert; + number: number; + render: (alert: Alert, currency: Currency) => JSX.Element; +}; + +const AlertInsightsSection = ({ + alert, + number, + render, +}: AlertInsightsSectionProps) => { + const [currency] = useCurrency(); + const text = getAlertText(alert); + const url = getAlertUrl(alert); + const buttonText = getAlertButtonText(alert); + + return ( + + + + + {/* */} + + {render(alert, currency)} + + ); +}; + +export default AlertInsightsSection; diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx new file mode 100644 index 0000000000..5d5f94b9be --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx @@ -0,0 +1,55 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Avatar, Box, Typography, Grid } from '@material-ui/core'; +import { Alert } from '../../types'; +import { getAlertNavigation } from '../../utils/alerts'; +import { useAlertInsightsSectionStyles as useStyles } from '../../utils/styles'; +import { useScroll } from '../../hooks'; + +type AlertInsightsSectionHeaderProps = { + alert: Alert; + number: number; + title: string; + subtitle: string; +}; + +const AlertInsightsSectionHeader = ({ + alert, + number, + title, + subtitle, +}: AlertInsightsSectionHeaderProps) => { + const { ScrollAnchor } = useScroll(getAlertNavigation(alert, number)); + const classes = useStyles(); + return ( + + + + + {number} + + + {title} + {subtitle} + + + + ); +}; + +export default AlertInsightsSectionHeader; diff --git a/plugins/cost-insights/src/components/AlertInsights/index.ts b/plugins/cost-insights/src/components/AlertInsights/index.ts new file mode 100644 index 0000000000..5ed33f4e39 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default } from './AlertInsights'; diff --git a/plugins/cost-insights/src/components/AlertInstructionsLayout/AlertInstructionsLayout.tsx b/plugins/cost-insights/src/components/AlertInstructionsLayout/AlertInstructionsLayout.tsx new file mode 100644 index 0000000000..0a422c335a --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInstructionsLayout/AlertInstructionsLayout.tsx @@ -0,0 +1,61 @@ +/* + * Copyright 2020 Spotify AB + * + * 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, { ReactNode } from 'react'; +import { Box, Button, Container, makeStyles } from '@material-ui/core'; +import ChevronLeftIcon from '@material-ui/icons/ChevronLeft'; +import { Header, Page, pageTheme } from '@backstage/core'; +import { CostInsightsThemeProvider } from '../CostInsightsPage/CostInsightsThemeProvider'; + +const useStyles = makeStyles(theme => ({ + root: { + gridArea: 'pageContent', + padding: theme.spacing(4), + }, +})); + +type AlertInstructionsLayoutProps = { + title: string; + children: ReactNode; +}; + +const AlertInstructionsLayout = ({ + title, + children, +}: AlertInstructionsLayoutProps) => { + const classes = useStyles(); + return ( + + +
+ + + + + {children} + + + + ); +}; + +export default AlertInstructionsLayout; diff --git a/plugins/cost-insights/src/components/AlertInstructionsLayout/index.ts b/plugins/cost-insights/src/components/AlertInstructionsLayout/index.ts new file mode 100644 index 0000000000..4063fe06d9 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInstructionsLayout/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default } from './AlertInstructionsLayout'; diff --git a/plugins/cost-insights/src/components/BarChart/BarChart.test.tsx b/plugins/cost-insights/src/components/BarChart/BarChart.test.tsx new file mode 100644 index 0000000000..a39c5bafc2 --- /dev/null +++ b/plugins/cost-insights/src/components/BarChart/BarChart.test.tsx @@ -0,0 +1,191 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { TooltipPayload } from 'recharts'; +import { fireEvent } from '@testing-library/react'; +import BarChart, { BarChartProps } from './BarChart'; +import { BarChartData, ResourceData } from '../../types'; +import { createMockEntity } from '../../utils/mockData'; +import { resourceSort } from '../../utils/sort'; +import { renderInTestApp } from '@backstage/test-utils'; +import { TooltipItemProps } from '../Tooltip'; +import { costInsightsLightTheme } from '../../utils/styles'; + +const MockEntities = [...Array(10)].map((_, index) => + createMockEntity(() => ({ + id: `test-id-${index + 1}`, + // grow resource costs linearly for testing + aggregation: [index * 1000, (index + 1) * 1000], + })), +); + +const MockBarChartData: BarChartData = { + previousFill: costInsightsLightTheme.palette.yellow, + currentFill: costInsightsLightTheme.palette.darkBlue, + previousName: 'Last Period', + currentName: 'Current Period', +}; + +const MockResources: ResourceData[] = MockEntities.map(entity => ({ + name: entity.id, + previous: entity.aggregation[0], + current: entity.aggregation[1], +})); + +const MockTooltipItem = (payload: TooltipPayload): TooltipItemProps => ({ + label: payload.name, + value: payload.value as string, + fill: payload.fill as string, +}); + +const renderWithProps = ({ + responsive = false, + displayAmount = 6, + barChartData = MockBarChartData, + getTooltipItem = MockTooltipItem, + resources = MockResources, +}: BarChartProps) => { + return renderInTestApp( + , + ); +}; + +describe('', () => { + it('Renders without exploding', async () => { + const rendered = await renderWithProps({} as BarChartProps); + expect(rendered.getByText('test-id-10')).toBeInTheDocument(); + }); + + it('Should display only 6 resources by default, sorted by cost', async () => { + const rendered = await renderWithProps({} as BarChartProps); + + MockResources.sort(resourceSort).forEach((resource, index) => { + if (index < 6) { + expect(rendered.getByText(resource.name!)).toBeInTheDocument(); + } else { + expect(rendered.queryByText(resource.name!)).not.toBeInTheDocument(); + } + }); + }); + + describe('Stepper', () => { + it('should not display stepper if displaying less than 6 resources', async () => { + const rendered = await renderWithProps({ + resources: MockResources.slice(0, 3), + } as BarChartProps); + expect( + rendered.queryByTestId('bar-chart-stepper'), + ).not.toBeInTheDocument(); + }); + + it('should display stepper if displaying more than 6 resources', async () => { + const rendered = await renderWithProps({} as BarChartProps); + expect(rendered.queryByTestId('bar-chart-stepper')).toBeInTheDocument(); + }); + + it('should display the next step button if resources are remaining', async () => { + const rendered = await renderWithProps({} as BarChartProps); + fireEvent.mouseEnter(rendered.getByTestId('bar-chart-wrapper')); + expect( + rendered.queryByTestId('bar-chart-stepper-button-back'), + ).not.toBeInTheDocument(); + expect( + rendered.queryByTestId('bar-chart-stepper-button-next'), + ).toBeInTheDocument(); + }); + + it('should display the correct amount of dots for the amount of resources', async () => { + const rendered = await renderWithProps({} as BarChartProps); + fireEvent.mouseEnter(rendered.getByTestId('bar-chart-wrapper')); + expect(rendered.queryAllByTestId('bar-chart-step').length).toEqual(2); + }); + + it('should not display the stepper or stepper buttons when the amount of resources is equal to the displayMax', async () => { + const MockEqualEntities = [...Array(5)].map(createMockEntity); + const MockEqualResources = MockEqualEntities.map(entity => ({ + name: entity.id, + previous: entity.aggregation[0], + current: entity.aggregation[1], + })); + + const rendered = await renderWithProps({ + displayAmount: 5, + resources: MockEqualResources, + } as BarChartProps); + expect( + rendered.queryByTestId('bar-chart-stepper'), + ).not.toBeInTheDocument(); + expect( + rendered.queryByTestId('bar-chart-stepper-button-back'), + ).not.toBeInTheDocument(); + expect( + rendered.queryByTestId('bar-chart-stepper-button-next'), + ).not.toBeInTheDocument(); + }); + + it('should display the correct resources while scrolling', async () => { + const rendered = await renderWithProps({ + displayAmount: 7, + } as BarChartProps); + + const sortedByCost = MockResources.sort(resourceSort); + + sortedByCost.slice(0, 7).forEach(resource => { + expect(rendered.getByText(resource.name!)).toBeInTheDocument(); + }); + + fireEvent.mouseEnter(rendered.getByTestId('bar-chart-wrapper')); + fireEvent.click(rendered.getByTestId('bar-chart-stepper-button-next')); + + sortedByCost.slice(7, 12).forEach(resource => { + expect(rendered.getByText(resource.name!)).toBeInTheDocument(); + }); + }); + + it('should display the correct amount of dots for a large amount of resources while scrolling', async () => { + const MockLargeEntities = [...Array(68)].map(createMockEntity); + const MockLargeResources = MockLargeEntities.map(entity => ({ + name: entity.id, + previous: entity.aggregation[0], + current: entity.aggregation[1], + })); + const rendered = await renderWithProps({ + resources: MockLargeResources, + } as BarChartProps); + + fireEvent.mouseEnter(rendered.getByTestId('bar-chart-wrapper')); + + const NextButton = rendered.getByTestId('bar-chart-stepper-button-next'); + + [...Array(9)].forEach(() => { + fireEvent.click(NextButton); + expect(rendered.queryAllByTestId('bar-chart-step').length).toEqual(10); + }); + + [...Array(2)].forEach(() => { + fireEvent.click(NextButton); + expect(rendered.queryAllByTestId('bar-chart-step').length).toEqual(2); + }); + }); + }); +}); diff --git a/plugins/cost-insights/src/components/BarChart/BarChart.tsx b/plugins/cost-insights/src/components/BarChart/BarChart.tsx new file mode 100644 index 0000000000..2f923304a8 --- /dev/null +++ b/plugins/cost-insights/src/components/BarChart/BarChart.tsx @@ -0,0 +1,175 @@ +/* + * Copyright 2020 Spotify AB + * + * 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, { useState, useCallback } from 'react'; +import { + Bar, + BarChart as RechartsBarChart, + CartesianGrid, + ContentRenderer, + TooltipProps, + ResponsiveContainer, + Tooltip as RechartsTooltip, + XAxis, + YAxis, + TooltipPayload, +} from 'recharts'; +import { Box, useTheme } from '@material-ui/core'; +import BarChartTick from './BarChartTick'; +import BarChartStepper from './BarChartStepper'; +import Tooltip, { TooltipItemProps } from '../Tooltip'; + +import { currencyFormatter } from '../../utils/formatters'; +import { + BarChartData, + Maybe, + notEmpty, + ResourceData, + DataKey, + CostInsightsTheme, +} from '../../types'; +import { useBarChartStyles } from '../../utils/styles'; +import { resourceSort } from '../../utils/sort'; + +export type BarChartProps = { + responsive?: boolean; + displayAmount?: number; + barChartData: BarChartData; + getTooltipItem: (payload: TooltipPayload) => Maybe; + resources: ResourceData[]; +}; + +const BarChart = ({ + responsive = true, + displayAmount = 6, + barChartData, + getTooltipItem, + resources, +}: BarChartProps) => { + const theme = useTheme(); + const styles = useBarChartStyles(theme); + const [activeChart, setActiveChart] = useState(false); + const [stepWindow, setStepWindow] = useState(() => [0, displayAmount]); + + const { previousFill, currentFill, previousName, currentName } = barChartData; + + const [stepStart, stepEnd] = stepWindow; + const steps = Math.ceil(resources.length / displayAmount); + const disableStepper = resources.length <= displayAmount; + + const sortedResources = resources + .sort(resourceSort) + .slice(stepStart, stepEnd); + + // Pin the domain to the largest value in the series. + // Intentially redundant - This could simply be derived from the first element in the already sorted list, + // but that may not be the case in the future when custom sorting is implemented. + const globalResourcesMax = resources.reduce( + (max, r: ResourceData) => Math.max(max, r.current, r.previous), + 0, + ); + + const onStepChange = useCallback( + (activeStep: number) => { + const start = activeStep * displayAmount; + const end = start + displayAmount; + if (end > resources.length) { + setStepWindow([start, resources.length]); + } else { + setStepWindow([start, end]); + } + }, + [setStepWindow, resources, displayAmount], + ); + + const handleChartEnter = () => setActiveChart(true); + + const handleChartLeave = () => setActiveChart(false); + + const renderTooltipContent: ContentRenderer = ({ + label, + payload, + }) => { + if (!(payload && typeof label === 'string')) return [null, null]; + const items = payload.map(getTooltipItem).filter(notEmpty); + return ; + }; + + return ( + + {/* Setting fixed values for height and width generates a console warning in testing but enables ResponsiveContainer to render its children. */} + + + + + + 0, globalResourcesMax]} + tick={{ fill: styles.axis.fill }} + /> + + + + + {!disableStepper && ( + + )} + + ); +}; + +export default BarChart; diff --git a/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx b/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx new file mode 100644 index 0000000000..3e12c18f90 --- /dev/null +++ b/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Box } from '@material-ui/core'; +import { useBarChartLabelStyles } from '../../utils/styles'; + +type BarChartLabel = { + x: number; + y: number; + height: number; + width: number; + children?: React.ReactNode; +}; + +const BarChartLabel = ({ x, y, height, width, children }: BarChartLabel) => { + const classes = useBarChartLabelStyles(); + const translateX = width * -0.5; + const childArray = React.Children.toArray(children); + + return ( + + + {childArray[0]} + {childArray.slice(1)} + + + ); +}; + +export default BarChartLabel; diff --git a/plugins/cost-insights/src/components/BarChart/BarChartStepper.tsx b/plugins/cost-insights/src/components/BarChart/BarChartStepper.tsx new file mode 100644 index 0000000000..27f7a589f3 --- /dev/null +++ b/plugins/cost-insights/src/components/BarChart/BarChartStepper.tsx @@ -0,0 +1,115 @@ +/* + * Copyright 2020 Spotify AB + * + * 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, { useEffect, useState } from 'react'; +import { Paper, Slide } from '@material-ui/core'; +import ChevronLeftIcon from '@material-ui/icons/ChevronLeft'; +import ChevronRightIcon from '@material-ui/icons/ChevronRight'; +import BarChartStepperButton from './BarChartStepperButton'; +import BarChartSteps from './BarChartSteps'; +import { useBarChartStepperStyles } from '../../utils/styles'; + +type BarChartStepperProps = { + disableScroll: boolean; + steps: number; + onChange: (activeStep: number) => void; +}; + +const BarChartStepper = ({ + steps, + disableScroll, + onChange, +}: BarChartStepperProps) => { + const classes = useBarChartStepperStyles(); + const [activeStep, setActiveStep] = useState(0); + + /* + * This calc determines how many active steps to display in the stepper. + * If the chart is displaying a large amount of resources, + * the total dots are truncated to 10. As the user clicks forward, + * eventually, there might be resources "left over" in excess of the ten dot limit. + * Once the user has reached that threshold, the difference should appear constant + * as the user clicks through the remaining resources and no extra dots should be displayed. + */ + + const diff = steps % 10; + const stepsRemaining = steps - activeStep <= diff ? diff : steps; + const displayedStep = activeStep % 10; + + useEffect(() => { + onChange(activeStep); + }, [activeStep, onChange]); + + const handleNext = () => { + setActiveStep(prevStep => prevStep + 1); + }; + + const handleBack = () => { + setActiveStep(prevStep => prevStep - 1); + }; + + const handleClick = (index: number) => { + setActiveStep(prevStep => { + const offset = index - (prevStep % 10); + return prevStep + offset; + }); + }; + + return ( + + + + + + + + + + + + + + ); +}; + +export default BarChartStepper; diff --git a/plugins/cost-insights/src/components/BarChart/BarChartStepperButton.tsx b/plugins/cost-insights/src/components/BarChart/BarChartStepperButton.tsx new file mode 100644 index 0000000000..b668ffaa5c --- /dev/null +++ b/plugins/cost-insights/src/components/BarChart/BarChartStepperButton.tsx @@ -0,0 +1,46 @@ +/* + * Copyright 2020 Spotify AB + * + * 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, { forwardRef, Ref } from 'react'; +import { ButtonBase, ButtonBaseProps } from '@material-ui/core'; +import { useBarChartStepperButtonStyles as useStyles } from '../../utils/styles'; + +interface BarChartStepperButtonProps extends ButtonBaseProps { + name: string; + children?: React.ReactNode; +} + +const BarChartStepperButton = forwardRef( + ( + { name, children, ...buttonBaseProps }: BarChartStepperButtonProps, + ref: Ref, + ) => { + const classes = useStyles(); + return ( + + {children} + + ); + }, +); + +export default BarChartStepperButton; diff --git a/plugins/cost-insights/src/components/BarChart/BarChartSteps.tsx b/plugins/cost-insights/src/components/BarChart/BarChartSteps.tsx new file mode 100644 index 0000000000..1ca0c7082a --- /dev/null +++ b/plugins/cost-insights/src/components/BarChart/BarChartSteps.tsx @@ -0,0 +1,52 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { ButtonBase } from '@material-ui/core'; +import { useBarChartStepperStyles as useStyles } from '../../utils/styles'; + +export type BarChartSteps = { + steps: number; + activeStep: number; + onClick: (index: number) => void; +}; + +const BarChartSteps = ({ steps, activeStep, onClick }: BarChartSteps) => { + const classes = useStyles(); + const handleOnClick = (index: number) => ( + event: React.MouseEvent, + ) => { + event.preventDefault(); + onClick(index); + }; + + return ( +
+ {[...new Array(steps)].map((_, index) => ( + +
+ + ))} +
+ ); +}; + +export default BarChartSteps; diff --git a/plugins/cost-insights/src/components/BarChart/BarChartTick.tsx b/plugins/cost-insights/src/components/BarChart/BarChartTick.tsx new file mode 100644 index 0000000000..99158c4573 --- /dev/null +++ b/plugins/cost-insights/src/components/BarChart/BarChartTick.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 BarChartLabel from './BarChartLabel'; + +type BarChartTickProps = { + x: number; + y: number; + height: number; + width: number; + payload: { + value: any; + }; + visibleTicksCount: number; +}; + +export const BarChartTick = ({ + x, + y, + height, + width, + payload, + visibleTicksCount, +}: BarChartTickProps) => { + const gutterWidth = 5; + const labelWidth = width / visibleTicksCount - gutterWidth * 2; + return ( + + {!payload.value ? 'Unlabeled' : payload.value} + + ); +}; + +export default BarChartTick; diff --git a/plugins/cost-insights/src/components/BarChart/index.ts b/plugins/cost-insights/src/components/BarChart/index.ts new file mode 100644 index 0000000000..255bd2b3d8 --- /dev/null +++ b/plugins/cost-insights/src/components/BarChart/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default } from './BarChart'; diff --git a/plugins/cost-insights/src/components/CopyUrlToClipboard/CopyUrlToClipboard.tsx b/plugins/cost-insights/src/components/CopyUrlToClipboard/CopyUrlToClipboard.tsx new file mode 100644 index 0000000000..c801eb86a5 --- /dev/null +++ b/plugins/cost-insights/src/components/CopyUrlToClipboard/CopyUrlToClipboard.tsx @@ -0,0 +1,70 @@ +/* + * Copyright 2020 Spotify AB + * + * 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, { useEffect, useState } from 'react'; +import { useLocation } from 'react-router-dom'; +import { useCopyToClipboard } from 'react-use'; +import { Tooltip, IconButton } from '@material-ui/core'; +import AssignmentOutlinedIcon from '@material-ui/icons/AssignmentOutlined'; +import AssignmentTurnedInOutlinedIcon from '@material-ui/icons/AssignmentTurnedInOutlined'; +import SentimentVeryDissatisfiedIcon from '@material-ui/icons/SentimentVeryDissatisfied'; + +const ClipboardMessage = { + default: 'Copy URL to clipboard', + success: 'Copied!', + error: "Couldn't copy to clipboard", +}; + +const CopyUrlToClipboard = () => { + const location = useLocation(); + const [state, copyToClipboard] = useCopyToClipboard(); + const [copied, setCopied] = useState(false); + + const origin = window.location.origin; + const pathname = location.pathname; + const search = location.search; + const url = `${origin}${pathname}${search}`; + + useEffect(() => { + if (state.error) { + setCopied(false); + } else if (state.value) { + setCopied(true); + setTimeout(setCopied, 1500, false); + } + }, [state]); + + let text = ClipboardMessage.default; + let Icon = AssignmentOutlinedIcon; + + if (state.error) { + text = ClipboardMessage.error; + Icon = SentimentVeryDissatisfiedIcon; + } else if (copied) { + text = ClipboardMessage.success; + Icon = AssignmentTurnedInOutlinedIcon; + } + + return ( + + copyToClipboard(url)}> + + + + ); +}; + +export default CopyUrlToClipboard; diff --git a/plugins/cost-insights/src/components/CopyUrlToClipboard/index.ts b/plugins/cost-insights/src/components/CopyUrlToClipboard/index.ts new file mode 100644 index 0000000000..15e2318c56 --- /dev/null +++ b/plugins/cost-insights/src/components/CopyUrlToClipboard/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default } from './CopyUrlToClipboard'; diff --git a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx new file mode 100644 index 0000000000..6302d9929f --- /dev/null +++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx @@ -0,0 +1,113 @@ +/* + * Copyright 2020 Spotify AB + * + * 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, { ReactNode } from 'react'; +import { renderInTestApp } from '@backstage/test-utils'; +import CostGrowth from './CostGrowth'; +import { + defaultCurrencies, + Currency, + CurrencyType, + Duration, + findAlways, +} from '../../types'; +import { MockConfigProvider, MockCurrencyProvider } from '../../utils/tests'; + +const engineers = findAlways(defaultCurrencies, c => c.kind === null); +const usd = findAlways(defaultCurrencies, c => c.kind === CurrencyType.USD); +const carbon = findAlways( + defaultCurrencies, + c => c.kind === CurrencyType.CarbonOffsetTons, +); + +const MockContext = ({ + children, + currency, + engineerCost, +}: { + children: ReactNode; + currency: Currency; + engineerCost: number; +}) => ( + + + {children} + + +); + +describe.each` + engineerCost | ratio | amount | expected + ${200_000} | ${0} | ${0} | ${'Negligible'} + ${200_000} | ${0} | ${8_333} | ${'Negligible'} + ${200_000} | ${0.000000001} | ${8_334} | ${`0% or ~1 ${engineers.unit}`} + ${200_000} | ${-0.000000001} | ${10_000} | ${`0% or ~1 ${engineers.unit}`} + ${200_000} | ${-0.8} | ${10_000} | ${`80% or ~1 ${engineers.unit}`} + ${200_000} | ${3} | ${600_000} | ${`300% or ~36 ${engineers.unit}s`} +`('', ({ engineerCost, ratio, amount, expected }) => { + it(`formats ${engineers.unit}s correctly for ${expected}`, async () => { + const { getByText } = await renderInTestApp( + + + , + ); + expect(getByText(expected)).toBeInTheDocument(); + }); +}); + +describe.each` + engineerCost | ratio | amount | expected + ${200_000} | ${0} | ${0} | ${'Negligible'} + ${200_000} | ${0} | ${8_333} | ${'Negligible'} + ${200_000} | ${0.000000001} | ${8_334} | ${'0% or ~$8,334'} + ${200_000} | ${-0.000000001} | ${10_000} | ${'0% or ~$10,000'} + ${200_000} | ${-0.8} | ${10_000} | ${'80% or ~$10,000'} + ${200_000} | ${3} | ${600_000} | ${'300% or ~$600,000'} +`('', ({ engineerCost, ratio, amount, expected }) => { + it(`formats ${usd.unit}s correctly for ${expected}`, async () => { + const { getByText } = await renderInTestApp( + + + , + ); + expect(getByText(expected)).toBeInTheDocument(); + }); +}); + +describe.each` + engineerCost | ratio | amount | expected + ${200_000} | ${0} | ${0} | ${'Negligible'} + ${200_000} | ${0} | ${8_333} | ${'Negligible'} + ${200_000} | ${0.000000001} | ${8_334} | ${`0% or ~2,381 ${carbon.unit}s`} + ${200_000} | ${-0.000000001} | ${10_000} | ${`0% or ~2,857 ${carbon.unit}s`} + ${200_000} | ${-0.8} | ${10_000} | ${`80% or ~2,857 ${carbon.unit}s`} + ${200_000} | ${3} | ${600_000} | ${`300% or ~171,429 ${carbon.unit}s`} +`('', ({ engineerCost, ratio, amount, expected }) => { + it(`formats ${carbon.unit}s correctly for ${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 new file mode 100644 index 0000000000..0c0db2d62c --- /dev/null +++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx @@ -0,0 +1,74 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 classnames from 'classnames'; +import { + ChangeStatistic, + CurrencyType, + Duration, + EngineerThreshold, + Growth, + growthOf, + rateOf, +} from '../../types'; +import { useCostGrowthStyles as useStyles } from '../../utils/styles'; +import { formatPercent, formatCurrency } from '../../utils/formatters'; +import { indefiniteArticleOf } from '../../utils/grammar'; +import { useConfig, useCurrency } from '../../hooks'; + +export type CostGrowthProps = { + change: ChangeStatistic; + duration: Duration; +}; + +const CostGrowth = ({ change, duration }: CostGrowthProps) => { + const styles = useStyles(); + const { engineerCost } = useConfig(); + const [currency] = useCurrency(); + + // Only display costs in absolute values + const amount = Math.abs(change.amount); + const ratio = Math.abs(change.ratio); + + const rate = rateOf(engineerCost, duration); + const engineers = amount / rate; + const converted = amount / (currency.rate ?? rate); + + // Determine if growth is significant enough to highlight + const growth = growthOf(engineers, change.ratio); + const classes = classnames({ + [styles.excess]: growth === Growth.Excess, + [styles.savings]: growth === Growth.Savings, + }); + + const percent = formatPercent(ratio); + + let cost = `${percent} or ~${formatCurrency(converted, currency.unit)}`; + // Always display the converted value but use the cost in engineers + // to determine negligibility, as costs should be time-period aware + if (engineers < EngineerThreshold) { + cost = 'Negligible'; + } else if (currency.kind === CurrencyType.USD) { + cost = `${percent} or ~${currency.prefix}${formatCurrency(converted)}`; + } else if (amount < 1) { + cost = `less than ${indefiniteArticleOf(['a', 'an'], currency.unit)}`; + } + + return {cost}; +}; + +export default CostGrowth; diff --git a/plugins/cost-insights/src/components/CostGrowth/index.ts b/plugins/cost-insights/src/components/CostGrowth/index.ts new file mode 100644 index 0000000000..3cd809ee49 --- /dev/null +++ b/plugins/cost-insights/src/components/CostGrowth/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default } from './CostGrowth'; diff --git a/plugins/cost-insights/src/components/CostInsightsHeader/CostInsightsHeader.test.tsx b/plugins/cost-insights/src/components/CostInsightsHeader/CostInsightsHeader.test.tsx new file mode 100644 index 0000000000..8af5091188 --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsHeader/CostInsightsHeader.test.tsx @@ -0,0 +1,95 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 CostInsightsHeader from './CostInsightsHeader'; +import { renderInTestApp } from '@backstage/test-utils'; +import { + ApiProvider, + ApiRegistry, + IdentityApi, + identityApiRef, +} from '@backstage/core'; +import React from 'react'; + +describe('', () => { + const identityApi: Partial = { + getProfile: () => ({ + email: 'test-email@example.com', + displayName: 'User 1', + }), + }; + + const apis = ApiRegistry.from([[identityApiRef, identityApi]]); + + it('Shows nothing to do when no alerts exist', async () => { + const rendered = await renderInTestApp( + + + , + ); + + expect(rendered.queryByText(/doing great/)).toBeInTheDocument(); + }); + + it('Shows work to do when alerts > 1', async () => { + const rendered = await renderInTestApp( + + + , + ); + expect(rendered.queryByText(/few things/)).toBeInTheDocument(); + }); + + it('Handles grammar with a single alert', async () => { + const rendered = await renderInTestApp( + + + , + ); + + expect(rendered.queryByText(/things/)).not.toBeInTheDocument(); + expect(rendered.queryByText(/one thing/)).toBeInTheDocument(); + }); + + it('Shows no costs when hasCostData is false', async () => { + const rendered = await renderInTestApp( + + + , + ); + expect(rendered.queryByText(/this is awkward/)).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/CostInsightsHeader/CostInsightsHeader.tsx b/plugins/cost-insights/src/components/CostInsightsHeader/CostInsightsHeader.tsx new file mode 100644 index 0000000000..57fa26b5fc --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsHeader/CostInsightsHeader.tsx @@ -0,0 +1,136 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Typography } from '@material-ui/core'; +import { identityApiRef, ProfileInfo, useApi } from '@backstage/core'; +import { useCostInsightsStyles } from '../../utils/styles'; +import { Group } from '../../types'; + +function name(profile: ProfileInfo | undefined): string { + return profile?.displayName || 'Mysterious Stranger'; +} + +type CostInsightsHeaderProps = { + owner: string; + groups: Group[]; + hasCostData: boolean; + alerts: number; +}; + +const CostInsightsHeader = (props: CostInsightsHeaderProps) => { + if (!props.hasCostData) { + return ; + } + if (props.alerts) { + return ; + } + return ; +}; + +const CostInsightsHeaderNoData = ({ + owner, + groups, +}: CostInsightsHeaderProps) => { + const profile = useApi(identityApiRef).getProfile(); + const classes = useCostInsightsStyles(); + const hasMultipleGroups = groups.length > 1; + + return ( + <> + + + 😳 + {' '} + Well this is awkward + + + Hey, {name(profile)}! {owner} doesn't seem to have any + cloud costs. + + {hasMultipleGroups && ( + + Maybe we picked the wrong team, choose another from the menu above? + + )} + + ); +}; + +const CostInsightsHeaderAlerts = ({ + owner, + alerts, +}: CostInsightsHeaderProps) => { + const profile = useApi(identityApiRef).getProfile(); + const classes = useCostInsightsStyles(); + + return ( + <> + + + 🔎 + {' '} + You have {alerts} thing{alerts > 1 && 's'} to look into + + + Hey, {name(profile)}! We've identified{' '} + {alerts > 1 ? 'a few things ' : 'one thing '} + {owner} should look into next. + + + ); +}; + +const CostInsightsHeaderNoAlerts = ({ owner }: CostInsightsHeaderProps) => { + const profile = useApi(identityApiRef).getProfile(); + const classes = useCostInsightsStyles(); + + return ( + <> + + + 👍 + {' '} + Your team is doing great + + + Hey, {name(profile)}! {owner} is doing well. No major + changes this month. + + + ); +}; + +export const CostInsightsHeaderNoGroups = () => { + const profile = useApi(identityApiRef).getProfile(); + const classes = useCostInsightsStyles(); + return ( + <> + + + 😳 + {' '} + Well this is awkward + + + Hey, {name(profile)}! It doesn't look like you belong to any + teams. + + + ); +}; + +export default CostInsightsHeader; diff --git a/plugins/cost-insights/src/components/CostInsightsHeader/index.ts b/plugins/cost-insights/src/components/CostInsightsHeader/index.ts new file mode 100644 index 0000000000..8e7ddcc9c8 --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsHeader/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default, CostInsightsHeaderNoGroups } from './CostInsightsHeader'; diff --git a/plugins/cost-insights/src/components/CostInsightsLayout/CostInsightsLayout.tsx b/plugins/cost-insights/src/components/CostInsightsLayout/CostInsightsLayout.tsx new file mode 100644 index 0000000000..61bf7ac22f --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsLayout/CostInsightsLayout.tsx @@ -0,0 +1,59 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { makeStyles } from '@material-ui/core'; +import { Header, Page, pageTheme } from '@backstage/core'; +import { Group } from '../../types'; +import CostInsightsTabs from '../CostInsightsTabs'; + +const useStyles = makeStyles(theme => ({ + root: { + gridArea: 'pageContent', + }, + header: { + boxShadow: 'none', + }, + content: { + padding: theme.spacing(4), + }, +})); + +type CostInsightsLayoutProps = { + title?: string; + groups: Group[]; + children?: React.ReactNode; +}; + +const CostInsightsLayout = ({ groups, children }: CostInsightsLayoutProps) => { + const classes = useStyles(); + return ( + +
+
+ +
{children}
+
+ + ); +}; + +export default CostInsightsLayout; diff --git a/plugins/cost-insights/src/components/CostInsightsLayout/index.ts b/plugins/cost-insights/src/components/CostInsightsLayout/index.ts new file mode 100644 index 0000000000..dd35eb2855 --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsLayout/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default } from './CostInsightsLayout'; diff --git a/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.test.tsx b/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.test.tsx new file mode 100644 index 0000000000..3ec2828029 --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.test.tsx @@ -0,0 +1,79 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default as HappyFace } from '@material-ui/icons/SentimentSatisfiedAlt'; +import { renderInTestApp } from '@backstage/test-utils'; +import CostInsightsNavigation from './CostInsightsNavigation'; +import { defaultCurrencies, Metric, Product, Icon } from '../../types'; +import { MockConfigProvider, MockScrollProvider } from '../../utils/tests'; +import { getDefaultNavigationItems } from '../../utils/navigation'; + +const mockIcons: Icon[] = [ + { + kind: 'some-product', + component: , + }, +]; + +const mockProducts: Product[] = [ + { + kind: 'some-product', + name: 'Some Product', + }, +]; + +const mockMetrics: Metric[] = [ + { + kind: 'some-metric', + name: 'Some Metric', + }, +]; + +const renderWrapped = (children: React.ReactNode) => + renderInTestApp( + + {children} + , + ); + +describe('', () => { + it('should render each navigation item', async () => { + const { getByText } = await renderWrapped( + , + ); + getDefaultNavigationItems(3) + .map(item => item.title) + .concat(mockProducts.map(p => p.name)) + .forEach(name => expect(getByText(name)).toBeInTheDocument()); + }); + + it('should not display action items navigation if there are no action items', async () => { + const rendered = await renderWrapped(); + expect(rendered.queryByText(/Action Items/)).not.toBeInTheDocument(); + }); + + it('should display the correct amount of action items in the badge', async () => { + const rendered = await renderWrapped(); + expect(rendered.getByText(/3/)).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx b/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx new file mode 100644 index 0000000000..fdbabe0402 --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx @@ -0,0 +1,97 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { + MenuList, + MenuItem, + ListItemIcon, + ListItemText, + Typography, + Badge, +} from '@material-ui/core'; +import { useNavigationStyles } from '../../utils/styles'; +import { useConfig, useScroll } from '../../hooks'; +import { findAlways } from '../../types'; +import { + DefaultNavigation, + NavigationItem, + getDefaultNavigationItems, +} from '../../utils/navigation'; + +type CostInsightsNavigationProps = { + alerts: number; +}; + +const CostInsightsNavigation = ({ alerts }: CostInsightsNavigationProps) => { + const classes = useNavigationStyles(); + const { products, icons } = useConfig(); + + const productNavigationItems: NavigationItem[] = products.map(product => ({ + navigation: product.kind, + icon: findAlways(icons, i => i.kind === product.kind).component, + title: product.name, + })); + + const navigationItems = getDefaultNavigationItems(alerts).concat( + productNavigationItems, + ); + + return ( + + {navigationItems.map((item: NavigationItem) => ( + + {React.cloneElement(item.icon, { + className: classes.navigationIcon, + })} + + ) : ( + React.cloneElement(item.icon, { + className: classes.navigationIcon, + }) + ) + } + title={item.title} + /> + ))} + + ); +}; + +const NavigationMenuItem = ({ navigation, icon, title }: NavigationItem) => { + const classes = useNavigationStyles(); + const { scrollIntoView } = useScroll(navigation); + return ( + + {icon} + {title}} + /> + + ); +}; + +export default CostInsightsNavigation; diff --git a/plugins/cost-insights/src/components/CostInsightsNavigation/index.ts b/plugins/cost-insights/src/components/CostInsightsNavigation/index.ts new file mode 100644 index 0000000000..6c32ba7677 --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsNavigation/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default } from './CostInsightsNavigation'; diff --git a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx new file mode 100644 index 0000000000..c426eff8bc --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx @@ -0,0 +1,249 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Box, Container, Divider, Grid } from '@material-ui/core'; +import { Progress, useApi, featureFlagsApiRef } from '@backstage/core'; +import { default as MaterialAlert } from '@material-ui/lab/Alert'; +import { costInsightsApiRef } from '../../api'; +import AlertActionCardList from '../AlertActionCardList'; +import AlertInsights from '../AlertInsights'; +import CostInsightsLayout from '../CostInsightsLayout'; +import CopyUrlToClipboard from '../CopyUrlToClipboard'; +import CurrencySelect from '../CurrencySelect'; +import WhyCostsMatter from '../WhyCostsMatter'; +import CostInsightsHeader, { + CostInsightsHeaderNoGroups, +} from '../CostInsightsHeader'; +import CostInsightsNavigation from '../CostInsightsNavigation'; +import CostOverviewCard from '../CostOverviewCard'; +import ProductInsights from '../ProductInsights'; +import CostInsightsSupportButton from '../CostInsightsSupportButton'; +import { + useLoading, + useFilters, + useGroups, + useCurrency, + useConfig, +} from '../../hooks'; +import { Alert, Cost, intervalsOf, Maybe, Project } from '../../types'; +import { mapLoadingToProps } from './selector'; + +const CostInsightsPage = () => { + const flags = useApi(featureFlagsApiRef).getFlags(); + // There is not currently a UI to set feature flags + // flags.set('cost-insights-currencies', FeatureFlagState.On); + const client = useApi(costInsightsApiRef); + const { currencies } = useConfig(); + const groups = useGroups(); + const [currency, setCurrency] = useCurrency(); + const [projects, setProjects] = useState>(null); + const [dailyCost, setDailyCost] = useState>(null); + const [alerts, setAlerts] = useState>(null); + const [error, setError] = useState>(null); + + const { pageFilters } = useFilters(p => p); + const { + loadingActions, + loadingGroups, + 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 */ + + useEffect(() => { + async function getInsights() { + setError(null); + try { + if (pageFilters.group) { + dispatchLoadingInsights(true); + const [ + fetchedProjects, + fetchedCosts, + fetchedAlerts, + ] = await Promise.all([ + client.getGroupProjects(pageFilters.group), + pageFilters.project + ? client.getProjectDailyCost( + pageFilters.project, + pageFilters.metric, + intervalsOf(pageFilters.duration), + ) + : client.getGroupDailyCost( + pageFilters.group, + pageFilters.metric, + intervalsOf(pageFilters.duration), + ), + client.getAlerts(pageFilters.group), + ]); + setProjects(fetchedProjects); + setDailyCost(fetchedCosts); + setAlerts(fetchedAlerts); + } else { + dispatchLoadingNone(loadingActions); + } + } catch (e) { + setError(e); + dispatchLoadingNone(loadingActions); + } finally { + dispatchLoadingInitial(false); + dispatchLoadingInsights(false); + } + } + + // Wait for user groups to finish loading + if (!loadingGroups) { + getInsights(); + } + }, [ + client, + pageFilters, + loadingGroups, + dispatchLoadingInsights, + dispatchLoadingInitial, + dispatchLoadingNone, + loadingActions, + ]); + + if (loadingInitial) { + return ; + } + + if (error) { + return {error.message}; + } + + // Loaded but no groups found for the user + if (!pageFilters.group) { + return ( + + + + + + + + + + + + + + ); + } + + // These should be defined, alerts can be an empty array but that's truthy + if (!dailyCost || !alerts) { + return ( + {`Error: Could not fetch cost insights data for team ${pageFilters.group}`} + ); + } + + return ( + + + + + + + + + + {!!flags.get('cost-insights-currencies') && ( + + + + )} + + + + + + + + + {!!alerts.length && ( + <> + + + + + + + + )} + + + {!!dailyCost.aggregation.length && ( + + )} + + + + + {!!alerts?.length && ( + + + + )} + + {!alerts.length && } + + + + + + + + + + + ); +}; + +export default CostInsightsPage; diff --git a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPageRoot.tsx b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPageRoot.tsx new file mode 100644 index 0000000000..b2276481b2 --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPageRoot.tsx @@ -0,0 +1,45 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 CostInsightsPage from './CostInsightsPage'; +import { FilterProvider } from '../../hooks/useFilters'; +import { LoadingProvider } from '../../hooks/useLoading'; +import { GroupsProvider } from '../../hooks/useGroups'; +import { CurrencyProvider } from '../../hooks/useCurrency'; +import { ScrollProvider } from '../../hooks/useScroll'; +import { ConfigProvider } from '../../hooks/useConfig'; +import { CostInsightsThemeProvider } from './CostInsightsThemeProvider'; + +const CostInsightsPageRoot = () => ( + + + + + + + + + + + + + + + +); + +export default CostInsightsPageRoot; diff --git a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsThemeProvider.tsx b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsThemeProvider.tsx new file mode 100644 index 0000000000..cecb076d35 --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsThemeProvider.tsx @@ -0,0 +1,50 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { createMuiTheme, ThemeProvider } from '@material-ui/core'; +import { BackstageTheme } from '@backstage/theme'; +import { + costInsightsDarkTheme, + costInsightsLightTheme, +} from '../../utils/styles'; +import { CostInsightsTheme } from '../../types'; + +interface CostInsightsThemeProviderProps { + children?: React.ReactNode; +} + +export const CostInsightsThemeProvider = ({ + children, +}: CostInsightsThemeProviderProps) => { + return ( + + createMuiTheme({ + ...theme, + palette: { + ...theme.palette, + ...(theme.palette.type === 'dark' + ? costInsightsDarkTheme.palette + : costInsightsLightTheme.palette), + }, + }) as CostInsightsTheme + } + > + {children} + + ); +}; diff --git a/plugins/cost-insights/src/components/CostInsightsPage/index.ts b/plugins/cost-insights/src/components/CostInsightsPage/index.ts new file mode 100644 index 0000000000..67d5eb5daa --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsPage/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default } from './CostInsightsPageRoot'; diff --git a/plugins/cost-insights/src/components/CostInsightsPage/selector.tsx b/plugins/cost-insights/src/components/CostInsightsPage/selector.tsx new file mode 100644 index 0000000000..51db47c082 --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsPage/selector.tsx @@ -0,0 +1,42 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { MapLoadingToProps } from '../../hooks'; +import { getResetState, DefaultLoadingAction } from '../../types'; + +type CostInsightsPageLoadingProps = { + loadingActions: Array; + loadingGroups: boolean; + loadingInitial: boolean; + dispatchInitial: (isLoading: boolean) => void; + dispatchInsights: (isLoading: boolean) => void; + dispatchNone: (loadingActions: string[]) => void; +}; + +export const mapLoadingToProps: MapLoadingToProps = ({ + state, + actions, + dispatch, +}) => ({ + loadingActions: actions, + loadingGroups: state[DefaultLoadingAction.UserGroups], + loadingInitial: state[DefaultLoadingAction.CostInsightsInitial], + dispatchInitial: (isLoading: boolean) => + dispatch({ [DefaultLoadingAction.CostInsightsInitial]: isLoading }), + dispatchInsights: (isLoading: boolean) => + dispatch({ [DefaultLoadingAction.CostInsightsPage]: isLoading }), + dispatchNone: (loadingActions: string[]) => + dispatch(getResetState(loadingActions)), +}); diff --git a/plugins/cost-insights/src/components/CostInsightsSupportButton/CostInsightsSupportButton.tsx b/plugins/cost-insights/src/components/CostInsightsSupportButton/CostInsightsSupportButton.tsx new file mode 100644 index 0000000000..c8e7fdac97 --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsSupportButton/CostInsightsSupportButton.tsx @@ -0,0 +1,28 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { SupportButton } from '@backstage/core'; + +const CostInsightsSupportButton = () => { + return ( + + Insights into cloud costs for your organization + + ); +}; + +export default CostInsightsSupportButton; diff --git a/plugins/cost-insights/src/components/CostInsightsSupportButton/index.ts b/plugins/cost-insights/src/components/CostInsightsSupportButton/index.ts new file mode 100644 index 0000000000..eab947c1be --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsSupportButton/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default } from './CostInsightsSupportButton'; diff --git a/plugins/cost-insights/src/components/CostInsightsTabs/CostInsightsTabs.test.tsx b/plugins/cost-insights/src/components/CostInsightsTabs/CostInsightsTabs.test.tsx new file mode 100644 index 0000000000..0859ad18ce --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsTabs/CostInsightsTabs.test.tsx @@ -0,0 +1,104 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 CostInsightsTabs from './CostInsightsTabs'; +import UserEvent from '@testing-library/user-event'; +import { Group, defaultCurrencies } from '../../types'; +import { MockFilterProvider, MockConfigProvider } from '../../utils/tests'; +import { LoadingContext } from '../../hooks/useLoading'; +import { renderInTestApp } from '@backstage/test-utils'; +import { mockDefaultState } from '../../utils/mockData'; + +const mockSetPageFilters = jest.fn(); +const mockLoadingDispatch = jest.fn(); + +const mockGroups: Group[] = [ + { + id: 'test-group-1', + }, + { + id: 'test-group-2', + }, + { + id: 'test-group-3', + }, +]; + +describe('', () => { + const renderWrapped = (children: React.ReactNode) => + renderInTestApp( + + + + {children} + + + , + ); + + it('Does NOT display the tabs bar if owner belongs to less than two GROUPS', async () => { + const oneGroup: Group[] = [{ id: 'test-group-1' }]; + const rendered = await renderWrapped( + , + ); + expect( + rendered.container.querySelector('.cost-insights-tabs'), + ).not.toBeInTheDocument(); + }); + + it('Displays the correct number of groups in the groups tab', async () => { + const rendered = await renderWrapped( + , + ); + expect(rendered.getByText('3 teams')).toBeInTheDocument(); + }); + + it('Applies the correct group filter when selected', async () => { + const selectedGroup = expect.objectContaining({ group: 'test-group-1' }); + const rendered = await renderWrapped( + , + ); + UserEvent.click(rendered.getByTestId('cost-insights-groups-tab')); + UserEvent.click(rendered.getByTestId('test-group-1')); + expect(mockSetPageFilters).toHaveBeenCalledWith(selectedGroup); + }); + + it('Displays the correct group names in the menu', async () => { + const rendered = await renderWrapped( + , + ); + UserEvent.click(rendered.getByTestId('cost-insights-groups-tab')); + mockGroups.forEach(group => + expect(rendered.getByText(group.id)).toBeInTheDocument(), + ); + }); +}); diff --git a/plugins/cost-insights/src/components/CostInsightsTabs/CostInsightsTabs.tsx b/plugins/cost-insights/src/components/CostInsightsTabs/CostInsightsTabs.tsx new file mode 100644 index 0000000000..3b86aa79ef --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsTabs/CostInsightsTabs.tsx @@ -0,0 +1,111 @@ +/* + * Copyright 2020 Spotify AB + * + * 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, { useState } from 'react'; +import { Menu, MenuItem, Tab, Tabs, Typography } from '@material-ui/core'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import { mapLoadingToProps, mapFiltersToProps } from './selector'; +import { Group } from '../../types'; +import { useFilters, useLoading } from '../../hooks'; +import { useCostInsightsTabsStyles as useStyles } from '../../utils/styles'; + +export type CostInsightsTabsProps = { + groups: Group[]; +}; + +const CostInsightsTabs = ({ groups }: CostInsightsTabsProps) => { + const classes = useStyles(); + const [index] = useState(0); // index is fixed for now until other tabs are added + const [groupMenuEl, setGroupMenuEl] = useState(null); + const { group, setGroup } = useFilters(mapFiltersToProps); + const { loadingActions, dispatchReset } = useLoading(mapLoadingToProps); + + const openGroupMenu = (e: any) => setGroupMenuEl(e.currentTarget as Element); + + const closeGroupMenu = () => setGroupMenuEl(null); + + const updateGroupFilterAndCloseMenu = (g: Group) => () => { + dispatchReset(loadingActions); + closeGroupMenu(); + setGroup(g); + }; + + const renderTabLabel = () => ( +
+ + {`${groups.length} teams`} + + +
+ ); + + const hasAtLeastTwoGroups = groups.length >= 2; + + if (!hasAtLeastTwoGroups) return null; + + return ( + <> + + + + + {groups.map((g: Group) => ( + + {g.id} + + ))} + + + ); +}; + +export default CostInsightsTabs; diff --git a/plugins/cost-insights/src/components/CostInsightsTabs/index.ts b/plugins/cost-insights/src/components/CostInsightsTabs/index.ts new file mode 100644 index 0000000000..7055334c2d --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsTabs/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default } from './CostInsightsTabs'; diff --git a/plugins/cost-insights/src/components/CostInsightsTabs/selector.ts b/plugins/cost-insights/src/components/CostInsightsTabs/selector.ts new file mode 100644 index 0000000000..3aa53cce21 --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsTabs/selector.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { MapFiltersToProps } from '../../hooks/useFilters'; +import { MapLoadingToProps } from '../../hooks/useLoading'; +import { Group, PageFilters, getResetStateWithoutInitial } from '../../types'; + +type CostInsightsTabsFilterProps = PageFilters & { + setGroup: (group: Group) => void; +}; + +type CostInsightsTabsLoadingProps = { + loadingActions: Array; + dispatchReset: (loadingActions: string[]) => void; +}; + +export const mapFiltersToProps: MapFiltersToProps = ({ + pageFilters, + setPageFilters, +}) => ({ + ...pageFilters, + setGroup: (group: Group) => + setPageFilters({ + ...pageFilters, + group: group.id, + project: null, + }), +}); + +export const mapLoadingToProps: MapLoadingToProps = ({ + actions, + dispatch, +}) => ({ + loadingActions: actions, + dispatchReset: (loadingActions: string[]) => + dispatch(getResetStateWithoutInitial(loadingActions)), +}); diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx new file mode 100644 index 0000000000..ef174132a3 --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx @@ -0,0 +1,93 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Box, Card, CardContent, Divider } from '@material-ui/core'; +import CostOverviewChart from '../CostOverviewChart'; +import CostOverviewChartLegend from '../CostOverviewChartLegend'; +import CostOverviewHeader from './CostOverviewHeader'; +import CostOverviewFooter from './CostOverviewFooter'; +import MetricSelect from '../MetricSelect'; +import PeriodSelect from '../PeriodSelect'; +import ProjectSelect from '../ProjectSelect'; +import { useScroll, useFilters, useConfig } from '../../hooks'; +import { mapFiltersToProps } from './selector'; +import { DefaultNavigation } from '../../utils/navigation'; +import { + ChangeStatistic, + DateAggregation, + Project, + Trendline, + findAlways, +} from '../../types'; + +type CostOverviewCardProps = { + change: ChangeStatistic; + aggregation: Array; + trendline: Trendline; + projects: Array; +}; + +const CostOverviewCard = ({ + change, + aggregation, + trendline, + projects, +}: CostOverviewCardProps) => { + const { metrics } = useConfig(); + const { ScrollAnchor } = useScroll(DefaultNavigation.CostOverviewCard); + const { setDuration, setProject, metric, setMetric, ...filters } = useFilters( + mapFiltersToProps, + ); + + const { name } = findAlways(metrics, m => m.kind === metric); + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export default CostOverviewCard; diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewFooter.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewFooter.tsx new file mode 100644 index 0000000000..03920887b8 --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewFooter.tsx @@ -0,0 +1,36 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Box } from '@material-ui/core'; + +type CostOverviewFooterProps = { + children?: React.ReactNode; +}; + +const CostOverviewFooter = ({ children }: CostOverviewFooterProps) => ( + + {React.Children.map(children, child => ( + {child} + ))} + +); + +export default CostOverviewFooter; diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewHeader.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewHeader.tsx new file mode 100644 index 0000000000..5de90d155a --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewHeader.tsx @@ -0,0 +1,53 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Box, Typography } from '@material-ui/core'; + +type CostOverviewHeaderProps = { + title: string; + subtitle?: string; + children?: React.ReactNode; +}; + +const CostOverviewHeader = ({ + title, + subtitle, + children, +}: CostOverviewHeaderProps) => ( + + + + {title} + + {!!subtitle && ( + + {subtitle} + + )} + + + {children} + + +); + +export default CostOverviewHeader; diff --git a/plugins/cost-insights/src/components/CostOverviewCard/index.ts b/plugins/cost-insights/src/components/CostOverviewCard/index.ts new file mode 100644 index 0000000000..c59fb5cc5b --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewCard/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default } from './CostOverviewCard'; diff --git a/plugins/cost-insights/src/components/CostOverviewCard/selector.tsx b/plugins/cost-insights/src/components/CostOverviewCard/selector.tsx new file mode 100644 index 0000000000..ef65f38dbb --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewCard/selector.tsx @@ -0,0 +1,46 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Duration, Maybe, PageFilters } from '../../types'; +import { MapFiltersToProps } from '../../hooks/useFilters'; + +type CostOverviewFilterProps = PageFilters & { + setDuration: (duration: Duration) => void; + setProject: (project: Maybe) => void; + setMetric: (metric: Maybe) => void; +}; + +export const mapFiltersToProps: MapFiltersToProps = ({ + pageFilters, + setPageFilters, +}) => ({ + ...pageFilters, + project: pageFilters.project || 'all', + setDuration: (duration: Duration) => + setPageFilters({ + ...pageFilters, + duration, + }), + setProject: (project: Maybe) => + setPageFilters({ + ...pageFilters, + project: project === 'all' ? null : project, + }), + setMetric: (metric: Maybe) => + setPageFilters({ + ...pageFilters, + metric: metric, + }), +}); diff --git a/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewChart.test.tsx b/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewChart.test.tsx new file mode 100644 index 0000000000..e6311d147a --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewChart.test.tsx @@ -0,0 +1,50 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 CostOverviewChart from './CostOverviewChart'; +import { renderInTestApp } from '@backstage/test-utils'; +import React from 'react'; +import { DateAggregation, Trendline } from '../../types'; +import { CostInsightsThemeProvider } from '../CostInsightsPage/CostInsightsThemeProvider'; + +const mockAggregation = [ + { date: '2020-04-01', amount: 100 }, + { date: '2020-04-02', amount: 101 }, + { date: '2020-04-03', amount: 102 }, + { date: '2020-04-04', amount: 103 }, +] as Array; + +const mockTrendline = { slope: 0.3, intercept: 101.5 } as Trendline; +const mockMetric = 'mock-metric'; + +describe('', () => { + it('Renders without exploding', async () => { + const rendered = await renderInTestApp( + + + , + ); + expect( + rendered.container.querySelector('.cost-overview-chart'), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewChart.tsx b/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewChart.tsx new file mode 100644 index 0000000000..2b40668409 --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewChart.tsx @@ -0,0 +1,120 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { + ComposedChart, + XAxis, + YAxis, + Tooltip, + CartesianGrid, + Area, + Line, + ResponsiveContainer, +} from 'recharts'; +import { + Maybe, + DateAggregation, + Trendline, + CostInsightsTheme, +} from '../../types'; +import { + overviewGraphTickFormatter, + formatGraphValue, +} from '../../utils/graphs'; +import CostOverviewTooltip from './CostOverviewTooltip'; +import { useTheme } from '@material-ui/core'; +import { useCostOverviewStyles as useStyles } from '../../utils/styles'; +import { NULL_METRIC } from '../../hooks/useConfig'; + +type CostOverviewChartProps = { + responsive: boolean; + aggregation: Array; + trendline?: Maybe; + metric: string | null; + tooltip: string; +}; + +const CostOverviewChart = ({ + responsive = true, + aggregation, + trendline, + metric, + tooltip, +}: CostOverviewChartProps) => { + const theme = useTheme(); + const styles = useStyles(theme); + + const id = metric ? metric : NULL_METRIC; + + const dailyCostData = aggregation.map((entry: DateAggregation) => ({ + date: Date.parse(entry.date), + [id]: entry.amount, + trend: trendline + ? trendline.slope * (Date.parse(entry.date) / 1000) + trendline.intercept + : null, + })); + + return ( + + + + + 0, 'dataMax']} + tick={{ fill: styles.axis.fill }} + tickFormatter={formatGraphValue} + width={styles.yAxis.width} + yAxisId={id} + /> + + + } + animationDuration={100} + /> + + + ); +}; + +export default CostOverviewChart; diff --git a/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewTooltip.tsx b/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewTooltip.tsx new file mode 100644 index 0000000000..17d466441d --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewTooltip.tsx @@ -0,0 +1,45 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 moment from 'moment'; +import { TooltipPayload, TooltipProps } from 'recharts'; +import Tooltip from '../../components/Tooltip'; +import { DEFAULT_DATE_FORMAT } from '../../types'; +import { formatGraphValue } from '../../utils/graphs'; + +type CostOverviewTooltipProps = TooltipProps & { + metric: string; + name: string; +}; + +const CostOverviewTooltip = ({ + label, + payload, + metric, + name, +}: CostOverviewTooltipProps) => { + const tooltipLabel = moment(label).format(DEFAULT_DATE_FORMAT); + const items = payload + ?.filter(data => data.name === metric) + .map((data: TooltipPayload) => ({ + label: name, + value: formatGraphValue(data.value as number), + fill: data.fill as string, + })); + return ; +}; + +export default CostOverviewTooltip; diff --git a/plugins/cost-insights/src/components/CostOverviewChart/index.ts b/plugins/cost-insights/src/components/CostOverviewChart/index.ts new file mode 100644 index 0000000000..21f345a282 --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewChart/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default } from './CostOverviewChart'; diff --git a/plugins/cost-insights/src/components/CostOverviewChartLegend/CostOverviewChartLegend.test.tsx b/plugins/cost-insights/src/components/CostOverviewChartLegend/CostOverviewChartLegend.test.tsx new file mode 100644 index 0000000000..a40ff58ec1 --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewChartLegend/CostOverviewChartLegend.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { renderInTestApp } from '@backstage/test-utils'; +import CostOverviewChartLegend from './CostOverviewChartLegend'; +import React from 'react'; +import { ChangeStatistic } from '../../types'; + +describe('', () => { + it('Correctly displays text if change is not supplied', async () => { + const rendered = await renderInTestApp( + , + ); + expect(rendered.queryByText('Unclear')).toBeInTheDocument(); + }); + it('Correctly displays formatted change percentage', async () => { + const change = { + ratio: 0.3456, + amount: 40000, + } as ChangeStatistic; + const rendered = await renderInTestApp( + , + ); + expect(rendered.queryByText('Unclear')).not.toBeInTheDocument(); + expect(rendered.queryByText('35%')).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/CostOverviewChartLegend/CostOverviewChartLegend.tsx b/plugins/cost-insights/src/components/CostOverviewChartLegend/CostOverviewChartLegend.tsx new file mode 100644 index 0000000000..538cd2a63a --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewChartLegend/CostOverviewChartLegend.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Box, useTheme } from '@material-ui/core'; +import LegendItem from '../LegendItem'; +import { formatPercent } from '../../utils/formatters'; +import { ChangeStatistic, CostInsightsTheme } from '../../types'; + +type CostOverviewChartLegendProps = { + change?: ChangeStatistic; + title: string; + tooltip?: string; +}; + +const CostOverviewChartLegend = ({ + change, + title, + tooltip, +}: CostOverviewChartLegendProps) => { + const theme = useTheme(); + + return ( + + + {change ? formatPercent(change.ratio) : 'Unclear'} + + + ); +}; + +export default CostOverviewChartLegend; diff --git a/plugins/cost-insights/src/components/CostOverviewChartLegend/index.ts b/plugins/cost-insights/src/components/CostOverviewChartLegend/index.ts new file mode 100644 index 0000000000..1b6c1fe975 --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewChartLegend/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default } from './CostOverviewChartLegend'; diff --git a/plugins/cost-insights/src/components/CurrencySelect/CurrencySelect.tsx b/plugins/cost-insights/src/components/CurrencySelect/CurrencySelect.tsx new file mode 100644 index 0000000000..24063a664e --- /dev/null +++ b/plugins/cost-insights/src/components/CurrencySelect/CurrencySelect.tsx @@ -0,0 +1,75 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { MenuItem, Select, SelectProps } from '@material-ui/core'; +import { Currency, CurrencyType, findAlways } from '../../types'; +import { useSelectStyles as useStyles } from '../../utils/styles'; + +const NULL_VALUE = 'engineers'; + +type CurrencySelectProps = { + currency: Currency; + currencies: Currency[]; + onSelect: (currency: Currency) => void; +}; + +const CurrencySelect = ({ + currency, + currencies, + onSelect, +}: CurrencySelectProps) => { + const classes = useStyles(); + + const getOption = (value: unknown) => { + const kind = (value === NULL_VALUE ? null : value) as CurrencyType; + return findAlways(currencies, c => c.kind === kind); + }; + + const handleOnChange: SelectProps['onChange'] = e => { + const option = getOption(e.target.value); + onSelect(option); + }; + + const renderValue: SelectProps['renderValue'] = value => { + const option = getOption(value); + return {option.label}; + }; + + return ( + + ); +}; + +export default CurrencySelect; diff --git a/plugins/cost-insights/src/components/CurrencySelect/index.ts b/plugins/cost-insights/src/components/CurrencySelect/index.ts new file mode 100644 index 0000000000..322b67d4ce --- /dev/null +++ b/plugins/cost-insights/src/components/CurrencySelect/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default } from './CurrencySelect'; diff --git a/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/LabelDataflowInstructionsPage.tsx b/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/LabelDataflowInstructionsPage.tsx new file mode 100644 index 0000000000..9783fadcb2 --- /dev/null +++ b/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/LabelDataflowInstructionsPage.tsx @@ -0,0 +1,96 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Box, Typography } from '@material-ui/core'; +import { CodeSnippet } from '@backstage/core'; +import AlertInstructionsLayout from '../AlertInstructionsLayout'; + +const LabelDataflowInstructionsPage = () => { + return ( + + Labeling Dataflow Jobs + + Labels in Google Cloud Platform are key-value pairs that can be added to + most types of cloud resources. Since these labels are also exported in + billing data, adding labels allows a granular breakdown of cloud cost by + software entity. + + + In Cloud Dataflow, labels can be added to a job either programmatically + or via the command-line when launching a job. Note that GCP has + + restrictions + {' '} + on the length and characters that can be used in labels. + + + Labels are not retroactive, so cost tracking is only possible from when + the labels are first added to a Dataflow job. + + + + DataflowPipelineOptions + + Dataflow jobs using Beam's{' '} + + DataflowPipelineOptions + {' '} + directly can use the setLabels function to add one or more + labels: + + + + Dataflow jobs using Scio can similarly set options on the ScioContext: + "my-dataflow-job").asJava)`} + /> + + + + + Command-line + + Dataflow jobs launched from the command-line can add labels as an + argument: + + + + For more information on specifying options, see the{' '} + + Dataflow documentation + {' '} + or{' '} + + Scio Scaladoc + + . + + + + ); +}; + +export default LabelDataflowInstructionsPage; diff --git a/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/index.ts b/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/index.ts new file mode 100644 index 0000000000..052ed379ec --- /dev/null +++ b/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default } from './LabelDataflowInstructionsPage'; diff --git a/plugins/cost-insights/src/components/LegendItem/LegendItem.tsx b/plugins/cost-insights/src/components/LegendItem/LegendItem.tsx new file mode 100644 index 0000000000..dd87ecc8a1 --- /dev/null +++ b/plugins/cost-insights/src/components/LegendItem/LegendItem.tsx @@ -0,0 +1,78 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Box, Typography, Tooltip } from '@material-ui/core'; +import LensIcon from '@material-ui/icons/Lens'; +import HelpOutlineOutlinedIcon from '@material-ui/icons/HelpOutlineOutlined'; +import { useCostGrowthLegendStyles } from '../../utils/styles'; + +type LegendItemProps = { + title: string; + tooltipText?: string; + markerColor?: string; + children?: React.ReactNode; +}; + +const LegendItem = ({ + title, + tooltipText, + markerColor, + children, +}: LegendItemProps) => { + const classes = useCostGrowthLegendStyles(); + return ( + + + {markerColor && ( +
+ +
+ )} + + {title} + + {tooltipText && ( + + {tooltipText} + + } + placement="top-start" + > + + + + + )} +
+ + + {children} + + +
+ ); +}; + +export default LegendItem; diff --git a/plugins/cost-insights/src/components/LegendItem/index.ts b/plugins/cost-insights/src/components/LegendItem/index.ts new file mode 100644 index 0000000000..763b8fc4f4 --- /dev/null +++ b/plugins/cost-insights/src/components/LegendItem/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default } from './LegendItem'; diff --git a/plugins/cost-insights/src/components/MetricSelect/MetricSelect.test.tsx b/plugins/cost-insights/src/components/MetricSelect/MetricSelect.test.tsx new file mode 100644 index 0000000000..67d1ec3ad3 --- /dev/null +++ b/plugins/cost-insights/src/components/MetricSelect/MetricSelect.test.tsx @@ -0,0 +1,71 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { waitFor } from '@testing-library/react'; +import UserEvent from '@testing-library/user-event'; +import MetricSelect, { MetricSelectProps } from './MetricSelect'; +import { renderInTestApp } from '@backstage/test-utils'; + +describe('', () => { + it('should display a metric', async () => { + const mockProps: MetricSelectProps = { + metric: 'test', + metrics: [{ kind: 'test', name: 'some-name' }], + onSelect: jest.fn(), + }; + const { getByText } = await renderInTestApp( + , + ); + expect(getByText(/some-name/)).toBeInTheDocument(); + }); + + it('should display a null metric', async () => { + const mockProps: MetricSelectProps = { + metric: null, + metrics: [{ kind: null, name: 'billie-nullish' }], + onSelect: jest.fn(), + }; + const { getByText } = await renderInTestApp( + , + ); + expect(getByText(/billie-nullish/)).toBeInTheDocument(); + }); + + it('should display all metrics', async () => { + const mockProps: MetricSelectProps = { + metric: null, + metrics: [ + { kind: null, name: 'billie-nullish' }, + { kind: 'MAU1M', name: 'Cost Per Million MAU' }, + { kind: 'my-cool-metric', name: 'metric-mcmetric-face' }, + ], + onSelect: jest.fn(), + }; + const { getAllByText, getByText, getByRole } = await renderInTestApp( + , + ); + const button = getByRole('button'); + + UserEvent.click(button); + + await waitFor(() => getAllByText(/billie-nullish/)); + + // The active metric should display in the popver list and in the input + expect(getAllByText(/billie-nullish/).length).toBe(2); + expect(getByText(/Cost Per Million MAU/)).toBeInTheDocument(); + expect(getByText(/metric-mcmetric-face/)).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/MetricSelect/MetricSelect.tsx b/plugins/cost-insights/src/components/MetricSelect/MetricSelect.tsx new file mode 100644 index 0000000000..1d3af911d4 --- /dev/null +++ b/plugins/cost-insights/src/components/MetricSelect/MetricSelect.tsx @@ -0,0 +1,67 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Select, MenuItem } from '@material-ui/core'; +import { Maybe, Metric, findAlways } from '../../types'; +import { useSelectStyles as useStyles } from '../../utils/styles'; +import { NULL_METRIC } from '../../hooks/useConfig'; + +export type MetricSelectProps = { + metric: Maybe; + metrics: Array; + onSelect: (metric: Maybe) => void; +}; + +const MetricSelect = ({ metric, metrics, onSelect }: MetricSelectProps) => { + const classes = useStyles(); + + const handleOnChange = (e: React.ChangeEvent<{ value: unknown }>) => { + if (e.target.value === NULL_METRIC) { + onSelect(null); + } else { + onSelect(e.target.value as string); + } + }; + + const renderValue = (value: unknown) => { + const kind = (value === NULL_METRIC ? null : value) as Maybe; + const { name } = findAlways(metrics, m => m.kind === kind); + return {name}; + }; + + return ( + + ); +}; + +export default MetricSelect; diff --git a/plugins/cost-insights/src/components/MetricSelect/index.ts b/plugins/cost-insights/src/components/MetricSelect/index.ts new file mode 100644 index 0000000000..f68e5031c1 --- /dev/null +++ b/plugins/cost-insights/src/components/MetricSelect/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default } from './MetricSelect'; diff --git a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx new file mode 100644 index 0000000000..c632ebad34 --- /dev/null +++ b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx @@ -0,0 +1,84 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { getByRole, waitFor } from '@testing-library/react'; +import UserEvent from '@testing-library/user-event'; +import PeriodSelect, { DEFAULT_OPTIONS as options } from './PeriodSelect'; +import { Duration, getDefaultPageFilters, Group } from '../../types'; + +import { renderInTestApp } from '@backstage/test-utils'; + +const DefaultPageFilters = getDefaultPageFilters([{ id: 'tools' }] as Group[]); + +Date.now = jest.fn(() => new Date(Date.parse('2020-05-01')).valueOf()); + +describe('', () => { + it('Renders without exploding', async () => { + const rendered = await renderInTestApp( + , + ); + expect(rendered.getByTestId('period-select')).toBeInTheDocument(); + }); + + it('Should display all costGrowth period options', async () => { + const rendered = await renderInTestApp( + , + ); + const periodSelectContainer = rendered.getByTestId('period-select'); + const button = getByRole(periodSelectContainer, 'button'); + UserEvent.click(button); + await waitFor(() => rendered.getByText('Past 60 Days')); + options.forEach(option => + expect( + rendered.getByTestId(`period-select-option-${option.value}`), + ).toBeInTheDocument(), + ); + }); + + describe.each` + duration + ${Duration.P1M} + ${Duration.P3M} + ${Duration.P90D} + ${Duration.P30D} + `('Should select the correct duration', ({ duration }) => { + it(`Should select ${duration}`, async () => { + const mockOnSelect = jest.fn(); + const mockAggregation = + DefaultPageFilters.duration === duration + ? Duration.P1M + : DefaultPageFilters.duration; + + const rendered = await renderInTestApp( + , + ); + const periodSelect = rendered.getByTestId('period-select'); + const button = getByRole(periodSelect, 'button'); + + UserEvent.click(button); + await waitFor(() => rendered.getByText('Past 60 Days')); + UserEvent.click(rendered.getByTestId(`period-select-option-${duration}`)); + expect(mockOnSelect).toHaveBeenLastCalledWith(duration); + }); + }); +}); diff --git a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx new file mode 100644 index 0000000000..688e22452e --- /dev/null +++ b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx @@ -0,0 +1,100 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { MenuItem, Select, SelectProps } from '@material-ui/core'; +import { + formatLastTwoLookaheadQuarters, + formatLastTwoMonths, +} from '../../utils/formatters'; +import { Duration, findAlways } from '../../types'; +import { useSelectStyles as useStyles } from '../../utils/styles'; + +export type PeriodOption = { + value: Duration; + label: string; +}; + +const LAST_6_MONTHS = 'Past 6 Months'; +const LAST_60_DAYS = 'Past 60 Days'; +const LAST_2_COMPLETED_MONTHS = formatLastTwoMonths(); +const LAST_2_LOOKAHEAD_QUARTERS = formatLastTwoLookaheadQuarters(); + +export const DEFAULT_OPTIONS: PeriodOption[] = [ + { + value: Duration.P90D, + label: LAST_6_MONTHS, + }, + { + value: Duration.P30D, + label: LAST_60_DAYS, + }, + { + value: Duration.P1M, + label: LAST_2_COMPLETED_MONTHS, + }, + { + value: Duration.P3M, + label: LAST_2_LOOKAHEAD_QUARTERS, + }, +]; + +type PeriodSelectProps = { + duration: Duration; + onSelect: (duration: Duration) => void; + options?: PeriodOption[]; +}; + +const PeriodSelect = ({ + duration, + onSelect, + options = DEFAULT_OPTIONS, +}: PeriodSelectProps) => { + const classes = useStyles(); + + const handleOnChange: SelectProps['onChange'] = e => { + onSelect(e.target.value as Duration); + }; + + const renderValue: SelectProps['renderValue'] = value => { + const option = findAlways(DEFAULT_OPTIONS, o => o.value === value); + return {option.label}; + }; + + return ( + + ); +}; + +export default PeriodSelect; diff --git a/plugins/cost-insights/src/components/PeriodSelect/index.ts b/plugins/cost-insights/src/components/PeriodSelect/index.ts new file mode 100644 index 0000000000..7bd70b3358 --- /dev/null +++ b/plugins/cost-insights/src/components/PeriodSelect/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default } from './PeriodSelect'; diff --git a/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx new file mode 100644 index 0000000000..cc52012543 --- /dev/null +++ b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx @@ -0,0 +1,43 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Box, Typography, Grid } from '@material-ui/core'; +import ProductInsightsCard from '../ProductInsightsCard'; +import { useConfig } from '../../hooks'; + +const ProductInsights = ({}) => { + const { products } = useConfig(); + + return ( + <> + + + Your team's product usage + + + + {products.map(product => ( + + + + ))} + + + ); +}; + +export default ProductInsights; diff --git a/plugins/cost-insights/src/components/ProductInsights/index.ts b/plugins/cost-insights/src/components/ProductInsights/index.ts new file mode 100644 index 0000000000..0a20d3c322 --- /dev/null +++ b/plugins/cost-insights/src/components/ProductInsights/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default } from './ProductInsights'; diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx new file mode 100644 index 0000000000..6d40fc276a --- /dev/null +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx @@ -0,0 +1,167 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 ProductInsightsCard from './ProductInsightsCard'; +import { + MockComputeEngine, + createMockEntity, + mockDefaultState, + createMockProductCost, +} from '../../utils/mockData'; +import { + IdentityApi, + ApiRegistry, + identityApiRef, + ApiProvider, +} from '@backstage/core'; +import { costInsightsApiRef, CostInsightsApi } from '../../api'; +import { renderInTestApp } from '@backstage/test-utils'; +import { GroupsContext } from '../../hooks/useGroups'; +import { LoadingContext } from '../../hooks/useLoading'; +import { + Product, + ProductCost, + defaultCurrencies, + findAlways, +} from '../../types'; +import { + MockConfigProvider, + MockFilterProvider, + MockCurrencyProvider, + MockScrollProvider, +} from '../../utils/tests'; + +const mockLoadingDispatch = jest.fn(); +const mockSetPageFilters = jest.fn(); +const mockSetProductFilters = jest.fn(); +const mockSetCurrency = jest.fn(); +const engineers = findAlways(defaultCurrencies, c => c.kind === null); + +const identityApi: Partial = { + getProfile: () => ({ + email: 'test-email@example.com', + displayName: 'User 1', + }), +}; + +const costInsightsApi = ( + productCost: ProductCost, +): Partial => ({ + getProductInsights: () => + Promise.resolve(productCost) as Promise, +}); + +const getApis = (productCost: ProductCost) => { + return ApiRegistry.from([ + [identityApiRef, identityApi], + [costInsightsApiRef, costInsightsApi(productCost)], + ]); +}; + +const mockProductCost = createMockProductCost(() => ({ + entities: [], + aggregation: [3000, 4000], + change: { + ratio: 0.23, + amount: 1000, + }, +})); + +const renderProductInsightsCardInTestApp = async ( + productCost: ProductCost, + product: Product, +) => + await renderInTestApp( + + + + + + + + + + + + + + + , + ); + +describe('', () => { + it('Renders the scroll anchors', async () => { + const rendered = await renderProductInsightsCardInTestApp( + mockProductCost, + MockComputeEngine, + ); + expect( + rendered.queryByTestId(`scroll-test-compute-engine`), + ).toBeInTheDocument(); + }); + + it('Should render the right subheader for products with cost data', async () => { + const productCost = { + ...mockProductCost, + entities: [...Array(1000)].map(createMockEntity), + }; + const rendered = await renderProductInsightsCardInTestApp( + productCost, + MockComputeEngine, + ); + const subheader = 'entities, sorted by cost'; + const subheaderRgx = new RegExp( + `${productCost.entities.length} ${subheader}`, + ); + expect(rendered.getByText(subheaderRgx)).toBeInTheDocument(); + }); + + it('Should render the right subheader if there is no cost data or change data', async () => { + const productCost = { entities: [], aggregation: [0, 0] } as ProductCost; + const subheader = `There are no ${MockComputeEngine.name} costs within this timeframe for your team's projects.`; + const rendered = await renderProductInsightsCardInTestApp( + productCost, + MockComputeEngine, + ); + const subheaderRgx = new RegExp(subheader); + expect(rendered.getByText(subheaderRgx)).toBeInTheDocument(); + expect( + rendered.queryByTestId('.resource-growth-chart-legend'), + ).not.toBeInTheDocument(); + expect( + rendered.queryByTestId('.insights-bar-chart'), + ).not.toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx new file mode 100644 index 0000000000..1d24139c64 --- /dev/null +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx @@ -0,0 +1,147 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { InfoCard, useApi } from '@backstage/core'; +import { Box } from '@material-ui/core'; +import Alert from '@material-ui/lab/Alert'; +import { costInsightsApiRef } from '../../api'; +import PeriodSelect from '../PeriodSelect'; +import ResourceGrowthBarChart from '../ResourceGrowthBarChart'; +import ResourceGrowthBarChartLegend from '../ResourceGrowthBarChartLegend'; +import { useFilters, useLoading, useScroll } from '../../hooks'; +import { useProductInsightsCardStyles as useStyles } from '../../utils/styles'; +import { mapFiltersToProps, mapLoadingToProps } from './selector'; +import { Duration, Maybe, Product, ProductCost } from '../../types'; +import { pluralOf } from '../../utils/grammar'; + +type ProductInsightsCardProps = { + product: Product; +}; + +const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => { + const client = useApi(costInsightsApiRef); + const classes = useStyles(); + const { ScrollAnchor } = useScroll(product.kind); + const [resource, setResource] = useState>(null); + const [error, setError] = useState>(null); + + const { group, product: productFilter, setProduct } = useFilters( + mapFiltersToProps(product.kind), + ); + const { loadingProduct, dispatchLoading } = useLoading( + mapLoadingToProps(product.kind), + ); + + // @see CostInsightsPage + // eslint-disable-next-line react-hooks/exhaustive-deps + const dispatchLoadingProduct = useCallback(dispatchLoading, [product.kind]); + + const amount = resource?.entities?.length || 0; + const hasCostsWithinTimeframe = resource?.change && amount; + + const subheader = amount + ? `${amount} ${pluralOf(amount, 'entity', 'entities')}, sorted by cost` + : `There are no ${product.name} costs within this timeframe for your team's projects.`; + + const costStart = resource?.aggregation[0] || 0; + const costEnd = resource?.aggregation[1] || 0; + + useEffect(() => { + async function load() { + if (loadingProduct) { + try { + const p: ProductCost = await client.getProductInsights( + product.kind, + group!, + productFilter!.duration, + ); + setResource(p); + } catch (e) { + setError(e); + } finally { + dispatchLoadingProduct(false); + } + } + } + load(); + }, [ + client, + product, + setResource, + loadingProduct, + dispatchLoadingProduct, + productFilter, + group, + product.kind, + ]); + + const onPeriodSelect = (duration: Duration) => { + dispatchLoadingProduct(true); + setProduct(duration); + }; + + const infoCardProps = { + headerProps: { + classes: classes, + action: ( + + ), + }, + }; + + if (error) { + return ( + + + {`Error: Could not fetch product insights for ${product.name}`} + + ); + } + + if (!resource) { + return null; + } + + return ( + + + {hasCostsWithinTimeframe && ( + <> + + + + + + + + )} + + ); +}; + +export default ProductInsightsCard; diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/index.ts b/plugins/cost-insights/src/components/ProductInsightsCard/index.ts new file mode 100644 index 0000000000..ef378004f0 --- /dev/null +++ b/plugins/cost-insights/src/components/ProductInsightsCard/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default } from './ProductInsightsCard'; diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/selector.ts b/plugins/cost-insights/src/components/ProductInsightsCard/selector.ts new file mode 100644 index 0000000000..001ff717bd --- /dev/null +++ b/plugins/cost-insights/src/components/ProductInsightsCard/selector.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { MapFiltersToProps } from '../../hooks/useFilters'; +import { MapLoadingToProps } from '../../hooks/useLoading'; +import { Duration, PageFilters, ProductPeriod, findAlways } from '../../types'; + +type ProductInsightsCardFilterProps = PageFilters & { + product: ProductPeriod; + setProduct: (duration: Duration) => void; +}; + +type ProductInsightsCardLoadingProps = { + loadingProduct: boolean; + dispatchLoading: (isLoading: boolean) => void; +}; + +export const mapFiltersToProps = ( + product: string, +): MapFiltersToProps => ({ + pageFilters, + productFilters, + setProductFilters, +}) => ({ + ...pageFilters, + product: findAlways(productFilters, p => p.productType === product), + setProduct: (duration: Duration) => + setProductFilters( + productFilters.map(period => + period.productType === product ? { ...period, duration } : period, + ), + ), +}); + +export const mapLoadingToProps = ( + product: string, +): MapLoadingToProps => ({ + state, + dispatch, +}) => ({ + loadingProduct: state[product], + dispatchLoading: (isLoading: boolean) => dispatch({ [product]: isLoading }), +}); diff --git a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx new file mode 100644 index 0000000000..5c3856cc9e --- /dev/null +++ b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx @@ -0,0 +1,85 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 ProjectGrowthAlertCard from './ProjectGrowthAlertCard'; +import { createMockProjectGrowthAlert } from '../../utils/mockData'; +import { MockCurrencyProvider, MockConfigProvider } from '../../utils/tests'; +import { AlertCost, defaultCurrencies, findAlways } from '../../types'; + +const engineers = findAlways(defaultCurrencies, c => c.kind === null); + +const MockProject = 'test-project-1'; +const MockAlertCosts: AlertCost[] = [ + { id: 'test-id-1', aggregation: [150, 200] }, + { id: 'test-id-2', aggregation: [235, 400] }, +]; + +const MockProjectGrowthAlert = createMockProjectGrowthAlert(alert => ({ + ...alert, + project: MockProject, + products: MockAlertCosts, +})); + +describe('', () => { + it('renders the correct title and subheader for multiple services', async () => { + const subheader = new RegExp( + `${MockAlertCosts.length} products, sorted by cost`, + ); + const title = new RegExp(`Project growth for ${MockProject}`); + const rendered = await renderInTestApp( + + + , + + , + ); + expect(rendered.getByText(title)).toBeInTheDocument(); + expect(rendered.getByText(subheader)).toBeInTheDocument(); + }); + + it('renders the correct title and subheader for a single service', async () => { + const subheader = new RegExp('1 product'); + const title = new RegExp(`Project growth for ${MockProject}`); + const rendered = await renderInTestApp( + + + + + , + ); + expect(rendered.getByText(title)).toBeInTheDocument(); + expect(rendered.getByText(subheader)).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx new file mode 100644 index 0000000000..c27b201202 --- /dev/null +++ b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx @@ -0,0 +1,60 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Box } from '@material-ui/core'; +import { InfoCard } from '@backstage/core'; +import ResourceGrowthBarChart from '../ResourceGrowthBarChart'; +import ResourceGrowthBarChartLegend from '../ResourceGrowthBarChartLegend'; +import { Duration, ProjectGrowthAlert } from '../../types'; +import { pluralOf } from '../../utils/grammar'; + +type ProjectGrowthAlertProps = { + alert: ProjectGrowthAlert; +}; + +const ProjectGrowthAlertCard = ({ alert }: ProjectGrowthAlertProps) => { + const [costStart, costEnd] = alert.aggregation; + + const subheader = ` + ${alert.products.length} ${pluralOf(alert.products.length, 'product')}${ + alert.products.length > 1 ? ', sorted by cost' : '' + }`; + + return ( + + + + + + + + + ); +}; + +export default ProjectGrowthAlertCard; diff --git a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/index.ts b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/index.ts new file mode 100644 index 0000000000..ab608f5f4c --- /dev/null +++ b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default } from './ProjectGrowthAlertCard'; diff --git a/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx b/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx new file mode 100644 index 0000000000..f8f4a90a9e --- /dev/null +++ b/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx @@ -0,0 +1,212 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Box, Typography } from '@material-ui/core'; +import { InfoCard } from '@backstage/core'; +import AlertInstructionsLayout from '../AlertInstructionsLayout'; +import ProjectGrowthAlertCard from '../ProjectGrowthAlertCard'; +import { + AlertType, + Duration, + Entity, + Product, + ProjectGrowthAlert, +} from '../../types'; +import ResourceGrowthBarChartLegend from '../ResourceGrowthBarChartLegend'; +import ResourceGrowthBarChart from '../ResourceGrowthBarChart'; + +const ProjectGrowthInstructionsPage = () => { + const projectGrowthAlert: ProjectGrowthAlert = { + id: AlertType.ProjectGrowth, + project: 'example-project', + periodStart: 'Q1 2020', + periodEnd: 'Q2 2020', + aggregation: [60000, 120000], + change: { + ratio: 1, + amount: 60000, + }, + products: [ + { + id: 'Compute Engine', + aggregation: [58000, 118000], + }, + { + id: 'Cloud Dataflow', + aggregation: [1200, 1500], + }, + { + id: 'Cloud Storage', + aggregation: [800, 500], + }, + ], + }; + + const product: Product = { + kind: 'ComputeEngine', + name: 'Compute Engine', + }; + + const entities: Entity[] = [ + { + id: 'service-one', + aggregation: [18200, 58500], + }, + { + id: 'service-two', + aggregation: [1200, 1300], + }, + { + id: 'service-three', + aggregation: [600, 200], + }, + ]; + + return ( + + Investigating cloud cost growth + + Cost Insights shows an alert when costs for a particular billing entity, + such as a GCP project, have grown at a rate faster than our alerting + threshold. The responsible team should follow this guide to decide + whether this warrants further investigation. + + + + Is the growth expected? + + The first question to ask is whether growth is expected. Perhaps a new + product has been deployed, or additional regions added for + reliability. + + + Many services increase cost linearly with load. Has the demand + increased? This may happen as you open new markets, or run marketing + offers. Costs should be compared against a business metric, such as + daily users, to normalize natural increases from business growth. + + + Seasonal variance may also cause cost growth; yearly campaigns, an + increase in demand during certain times of year. + + + Cloud costs will often go up before they go down, in the case of + migrations. Teams moving to new infrastructure may run in both the old + and new environment during the migration. + + + + + Is the growth significant? + + Next, evaluate whether the growth is significant. This helps avoid + premature optimization, where cost in engineering time is more than + would be saved from the optimization over a reasonable timeframe. + + + We recommend reframing the cost growth itself in terms of engineering + time. How much engineering time, for an average fully-loaded + engineer cost at the company, is being overspent each month? Compare + this to expected engineering time for optimization to decide whether + the optimization is worthwhile. + + + + + + Identifying which cloud product contributed most + + + For projects meeting the alert threshold, Cost Insights shows a cost + comparison of cloud products over the examined time period: + + + + + + This allows you to quickly see which cloud products contributed to the + growth in cloud costs. + + + + + + Identifying the responsible workload + + + After identifying the cloud product, use the corresponding product + panel in Cost Insights to find a particular workload (or entity + ) that has grown in cost: + + + {/* ProductInsightsCard without API query / PeriodSelect */} + + + + + + + + + + + + + From here, you can dig into commit history or deployment logs to find + probable causes of an unexpected spike in cost. + + + + + Optimizing the workload + + Workload optimization varies between cloud products, but there are a + few general optimization areas to consider: + + Retention + + Is the workload or storage necessary? Truly idle or unused resources + can be cleaned up for immediate cost savings. For storage, how long do + we need the data? Many cloud products support retention policies to + automatically delete data after a certain time period. + + Efficiency + + Is the workload using cloud resources efficiently? For compute + resources, do the utilization metrics look reasonable? Autoscaling + infrastructure, such as Kubernetes, can run workloads more efficiently + without comprimising reliability. + + Lifecycle + + Is the workload using an optimal pricing model? Some cloud products + offer better pricing for data that is accessed less frequently. + + + + ); +}; + +export default ProjectGrowthInstructionsPage; diff --git a/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/index.ts b/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/index.ts new file mode 100644 index 0000000000..c75240405c --- /dev/null +++ b/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default } from './ProjectGrowthInstructionsPage'; diff --git a/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx new file mode 100644 index 0000000000..92de819fe0 --- /dev/null +++ b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { getByRole, waitFor } from '@testing-library/react'; +import UserEvent from '@testing-library/user-event'; +import ProjectSelect from './ProjectSelect'; +import { MockFilterProvider } from '../../utils/tests'; +import { renderInTestApp } from '@backstage/test-utils'; + +const mockProjects = [ + { id: 'project1' }, + { id: 'project2' }, + { id: 'project3' }, +]; + +const mockSetPageFilters = jest.fn(); + +describe('', () => { + let Component: React.ReactNode; + beforeEach(() => { + Component = () => ( + + + + ); + }); + + it('Renders without exploding', async () => { + const rendered = await renderInTestApp(Component); + expect(rendered.getByText('All Projects')).toBeInTheDocument(); + }); + + it('shows all projects in the filter select', async () => { + const rendered = await renderInTestApp(Component); + const projectSelectContainer = rendered.getByTestId( + 'project-filter-select', + ); + const button = getByRole(projectSelectContainer, 'button'); + UserEvent.click(button); + await waitFor(() => rendered.getByTestId('option-all')); + mockProjects.forEach( + project => + project.id && + expect(rendered.getByText(project.id)).toBeInTheDocument(), + ); + }); +}); diff --git a/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx new file mode 100644 index 0000000000..cbcd8ede4e --- /dev/null +++ b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx @@ -0,0 +1,70 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { MenuItem, Select } from '@material-ui/core'; +import { Maybe, Project } from '../../types'; +import { useSelectStyles as useStyles } from '../../utils/styles'; + +type ProjectSelectProps = { + project: Maybe; + projects: Array; + onSelect: (project: Maybe) => void; +}; + +const ProjectSelect = ({ project, projects, onSelect }: ProjectSelectProps) => { + const classes = useStyles(); + + const projectOptions = [{ id: 'all' } as Project, ...projects] + .filter(p => p.id) + .sort((a, b) => (a.id as string).localeCompare(b.id as string)); + + const handleOnChange = (e: React.ChangeEvent<{ value: unknown }>) => { + onSelect(e.target.value as string); + }; + + const renderValue = (value: unknown) => { + const proj = value as string; + return ( + + {proj === 'all' ? 'All Projects' : proj} + + ); + }; + + return ( + + ); +}; + +export default ProjectSelect; diff --git a/plugins/cost-insights/src/components/ProjectSelect/index.ts b/plugins/cost-insights/src/components/ProjectSelect/index.ts new file mode 100644 index 0000000000..47a2b9e22c --- /dev/null +++ b/plugins/cost-insights/src/components/ProjectSelect/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default } from './ProjectSelect'; diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.test.tsx b/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.test.tsx new file mode 100644 index 0000000000..8355deab7d --- /dev/null +++ b/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.test.tsx @@ -0,0 +1,41 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 ResourceGrowthBarChart from './ResourceGrowthBarChart'; +import { Duration } from '../../types'; +import { renderInTestApp } from '@backstage/test-utils'; +import { createMockEntity } from '../../utils/mockData'; + +const MockResources = [...Array(10)].map((_, index) => + createMockEntity(() => ({ + id: `test-id-${index + 1}`, + // grow resource costs linearly for testing + aggregation: [index * 1000, (index + 1) * 1000], + })), +); + +describe('', () => { + it('Pre-renders without exploding', async () => { + const rendered = await renderInTestApp( + , + ); + expect(rendered.queryByTestId('bar-chart-wrapper')).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.tsx b/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.tsx new file mode 100644 index 0000000000..3ae35ba871 --- /dev/null +++ b/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.tsx @@ -0,0 +1,97 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { TooltipPayload } from 'recharts'; +import { + currencyFormatter, + dateRegex, + formatDuration, +} from '../../utils/formatters'; +import { + BarChartData, + CostInsightsTheme, + DataKey, + Duration, + Entity, + inclusiveEndDateOf, + inclusiveStartDateOf, + Maybe, + ResourceData, + AlertCost, +} from '../../types'; +import BarChart from '../BarChart'; +import { TooltipItemProps } from '../Tooltip'; +import { useTheme } from '@material-ui/core'; + +export type ResourceGrowthBarChartProps = { + duration: Duration; + resources: Array; +}; + +const ResourceGrowthBarChart = ({ + duration, + resources, +}: ResourceGrowthBarChartProps) => { + const theme = useTheme(); + const getTooltipItem = (payload: TooltipPayload): Maybe => { + const label = dateRegex.test(payload.name) + ? formatDuration(payload.name, duration) + : payload.name; + const value = + typeof payload.value === 'number' + ? currencyFormatter.format(payload.value) + : (payload.value as string); + const fill = payload.fill as string; + + switch (payload.dataKey) { + case DataKey.Current: + case DataKey.Previous: + return { + label: label, + value: value, + fill: fill, + }; + default: + return null; + } + }; + + const barChartData: BarChartData = { + previousFill: theme.palette.lightBlue, + currentFill: theme.palette.darkBlue, + previousName: inclusiveStartDateOf(duration), + currentName: inclusiveEndDateOf(duration), + }; + + const resourceData: ResourceData[] = resources.map(resource => { + return { + name: resource.id, + previous: resource.aggregation[0], + current: resource.aggregation[1], + }; + }); + + return ( + + ); +}; + +export default ResourceGrowthBarChart; diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChart/index.ts b/plugins/cost-insights/src/components/ResourceGrowthBarChart/index.ts new file mode 100644 index 0000000000..b1388143e4 --- /dev/null +++ b/plugins/cost-insights/src/components/ResourceGrowthBarChart/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default } from './ResourceGrowthBarChart'; diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.test.tsx b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.test.tsx new file mode 100644 index 0000000000..1e432ff3bc --- /dev/null +++ b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.test.tsx @@ -0,0 +1,93 @@ +/* + * Copyright 2020 Spotify AB + * + * 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, { ReactNode } from 'react'; +import { renderInTestApp } from '@backstage/test-utils'; +import ResourceGrowthBarChartLegend from './ResourceGrowthBarChartLegend'; +import { Currency, defaultCurrencies, Duration, findAlways } from '../../types'; +import { MockConfigProvider, MockCurrencyProvider } from '../../utils/tests'; + +const engineers = findAlways(defaultCurrencies, c => c.kind === null); + +const MockContext = ({ + children, + currency, +}: { + children: ReactNode; + currency: Currency; +}) => ( + + + {children} + + +); + +describe('', () => { + describe.each` + ratio | amount | costText | engineerTest + ${2.5} | ${300_000} | ${'Cost Growth'} | ${/\~6 engineers/} + ${-2.5} | ${-120_000} | ${'Cost Savings'} | ${/\~2 engineers/} + `( + 'Should display the cost text', + ({ ratio, amount, costText, engineerTest }) => { + it(`Should display the correct cost and engineer text for ${ratio} percent change`, async () => { + const rendered = await renderInTestApp( + + + , + ); + expect(rendered.getByText(costText)).toBeInTheDocument(); + expect(rendered.queryByText(engineerTest)).toBeInTheDocument(); + }); + }, + ); + + describe.each` + duration | periodStartText | periodEndText + ${Duration.P30D} | ${'First 30 Days'} | ${'Last 30 Days'} + ${Duration.P90D} | ${'First 90 Days'} | ${'Last 90 Days'} + `( + 'Should display the correct relative time', + ({ duration, periodStartText, periodEndText }) => { + it(`Should display the correct relative time for ${duration}`, async () => { + const rendered = await renderInTestApp( + + + , + ); + expect(rendered.getByText(periodStartText)).toBeInTheDocument(); + expect(rendered.getByText(periodEndText)).toBeInTheDocument(); + }); + }, + ); +}); diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.tsx b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.tsx new file mode 100644 index 0000000000..1466e18ee1 --- /dev/null +++ b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.tsx @@ -0,0 +1,72 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Box, useTheme } from '@material-ui/core'; +import LegendItem from '../LegendItem'; +import CostGrowth from '../CostGrowth'; +import { currencyFormatter, formatDuration } from '../../utils/formatters'; +import { + ChangeStatistic, + CostInsightsTheme, + Duration, + inclusiveEndDateOf, + inclusiveStartDateOf, +} from '../../types'; + +export type ResourceGrowthBarChartLegendProps = { + change: ChangeStatistic; + duration: Duration; + costStart: number; + costEnd: number; +}; + +const ResourceGrowthBarChartLegend = ({ + change, + duration, + costStart, + costEnd, +}: ResourceGrowthBarChartLegendProps) => { + const theme = useTheme(); + + const startOf = inclusiveStartDateOf(duration); + const endOf = inclusiveEndDateOf(duration); + const periodStartTitle = formatDuration(startOf, duration); + const periodEndTitle = formatDuration(endOf, duration); + + return ( + + + + {currencyFormatter.format(costStart)} + + + + + {currencyFormatter.format(costEnd)} + + + + + + + ); +}; + +export default ResourceGrowthBarChartLegend; diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/index.ts b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/index.ts new file mode 100644 index 0000000000..7071799805 --- /dev/null +++ b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default } from './ResourceGrowthBarChartLegend'; diff --git a/plugins/cost-insights/src/components/Tooltip/Tooltip.test.tsx b/plugins/cost-insights/src/components/Tooltip/Tooltip.test.tsx new file mode 100644 index 0000000000..8c12b34ffb --- /dev/null +++ b/plugins/cost-insights/src/components/Tooltip/Tooltip.test.tsx @@ -0,0 +1,59 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 Tooltip from './Tooltip'; +import { CostInsightsThemeProvider } from '../CostInsightsPage/CostInsightsThemeProvider'; + +const mockTooltipItems = [ + { + label: 'Cost', + value: '$1,000,000', + fill: '#FFF', + }, + { + label: 'Test Metric', + value: '100,000,000', + fill: '#FFF', + }, +]; + +describe('', () => { + it('renders without exploding', async () => { + const rendered = await renderInTestApp( + + + , + ); + expect( + rendered.container.querySelector('.tooltip-content'), + ).toBeInTheDocument(); + }); + + it('formats label and tooltip item text correctly', async () => { + const rendered = await renderInTestApp( + + + , + ); + expect(rendered.getByText('05/16/2020')).toBeInTheDocument(); + expect(rendered.getByText('Cost')).toBeInTheDocument(); + expect(rendered.getByText('Test Metric')).toBeInTheDocument(); + expect(rendered.getByText('$1,000,000')).toBeInTheDocument(); + expect(rendered.getByText('100,000,000')).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/Tooltip/Tooltip.tsx b/plugins/cost-insights/src/components/Tooltip/Tooltip.tsx new file mode 100644 index 0000000000..727b2476c8 --- /dev/null +++ b/plugins/cost-insights/src/components/Tooltip/Tooltip.tsx @@ -0,0 +1,54 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Box, Typography } from '@material-ui/core'; +import TooltipItem, { TooltipItemProps } from './TooltipItem'; +import { useTooltipStyles } from '../../utils/styles'; + +export type TooltipProps = { + label?: string; + items?: Array; +}; + +const Tooltip = ({ label, items }: TooltipProps) => { + const classes = useTooltipStyles(); + return ( + + {label && ( + + {label} + + )} + {items && + items.map((item, index) => ( + + ))} + + ); +}; + +export default Tooltip; diff --git a/plugins/cost-insights/src/components/Tooltip/TooltipItem.tsx b/plugins/cost-insights/src/components/Tooltip/TooltipItem.tsx new file mode 100644 index 0000000000..67d4eac15f --- /dev/null +++ b/plugins/cost-insights/src/components/Tooltip/TooltipItem.tsx @@ -0,0 +1,49 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Box, Typography } from '@material-ui/core'; +import LensIcon from '@material-ui/icons/Lens'; +import { useTooltipStyles as useStyles } from '../../utils/styles'; + +export type TooltipItemProps = { + value: string; + label: string; + fill: string; +}; + +const TooltipItem = ({ fill, label, value }: TooltipItemProps) => { + const classes = useStyles(); + const style = { fill: fill }; + return ( + + + + + + {label} + + {value} + + ); +}; + +export default TooltipItem; diff --git a/plugins/cost-insights/src/components/Tooltip/index.ts b/plugins/cost-insights/src/components/Tooltip/index.ts new file mode 100644 index 0000000000..ca20ab1242 --- /dev/null +++ b/plugins/cost-insights/src/components/Tooltip/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default } from './Tooltip'; +export * from './TooltipItem'; diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.test.tsx b/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.test.tsx new file mode 100644 index 0000000000..6beb204b0d --- /dev/null +++ b/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.test.tsx @@ -0,0 +1,66 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 UnlabeledDataflowAlertCard from './UnlabeledDataflowAlertCard'; +import { + createMockUnlabeledDataflowAlert, + createMockUnlabeledDataflowAlertProject, +} from '../../utils/mockData'; +import { renderInTestApp } from '@backstage/test-utils'; + +const MockUnlabeledDataflowAlertMultipleProjects = createMockUnlabeledDataflowAlert( + alert => ({ + ...alert, + projects: [...Array(10)].map(() => + createMockUnlabeledDataflowAlertProject(), + ), + }), +); + +const MockUnlabeledDataflowAlertSingleProject = createMockUnlabeledDataflowAlert( + alert => ({ + ...alert, + projects: [...Array(1)].map(() => + createMockUnlabeledDataflowAlertProject(), + ), + }), +); + +describe('', () => { + it('renders the correct subheader for multiple projects', async () => { + const subheader = new RegExp( + `Showing costs from ${MockUnlabeledDataflowAlertMultipleProjects.projects.length} ` + + 'projects with unlabeled Dataflow jobs in the last 30 days.', + ); + const rendered = await renderInTestApp( + , + ); + expect(rendered.getByText(subheader)).toBeInTheDocument(); + }); + + it('renders the correct subheader for a single project', async () => { + const subheader = new RegExp('1 project'); + const rendered = await renderInTestApp( + , + ); + expect(rendered.getByText(subheader)).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.tsx b/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.tsx new file mode 100644 index 0000000000..8dff85e867 --- /dev/null +++ b/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Box } from '@material-ui/core'; +import { InfoCard } from '@backstage/core'; +import UnlabeledDataflowBarChart from '../UnlabeledDataflowBarChart'; +import UnlabeledDataflowBarChartLegend from '../UnlabeledDataflowBarChartLegend'; +import { UnlabeledDataflowAlert } from '../../types'; +import { pluralOf } from '../../utils/grammar'; + +type UnlabeledDataflowAlertProps = { + alert: UnlabeledDataflowAlert; +}; + +const UnlabeledDataflowAlertCard = ({ alert }: UnlabeledDataflowAlertProps) => { + const projects = pluralOf(alert.projects.length, 'project'); + const subheader = ` + Showing costs from ${alert.projects.length} ${projects} with unlabeled Dataflow jobs in the last 30 days. + `; + return ( + + + + + + + + + + + ); +}; + +export default UnlabeledDataflowAlertCard; diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/index.ts b/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/index.ts new file mode 100644 index 0000000000..6f22abc062 --- /dev/null +++ b/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default } from './UnlabeledDataflowAlertCard'; diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowBarChart/UnlabeledDataflowBarChart.tsx b/plugins/cost-insights/src/components/UnlabeledDataflowBarChart/UnlabeledDataflowBarChart.tsx new file mode 100644 index 0000000000..7b1f4e31ec --- /dev/null +++ b/plugins/cost-insights/src/components/UnlabeledDataflowBarChart/UnlabeledDataflowBarChart.tsx @@ -0,0 +1,73 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { TooltipPayload } from 'recharts'; +import BarChart from '../BarChart'; +import { TooltipItemProps } from '../Tooltip'; +import { + BarChartData, + CostInsightsTheme, + ResourceData, + UnlabeledDataflowAlertProject, +} from '../../types'; +import { currencyFormatter } from '../../utils/formatters'; +import { useTheme } from '@material-ui/core'; + +type UnlabeledDataflowBarChartProps = { + projects: Array; +}; + +export const UnlabeledDataflowBarChart = ({ + projects, +}: UnlabeledDataflowBarChartProps) => { + const theme = useTheme(); + const barChartData: BarChartData = { + previousFill: theme.palette.lightBlue, + currentFill: theme.palette.darkBlue, + previousName: 'Unlabeled Cost', + currentName: 'Labeled Cost', + }; + + const getTooltipItem = (payload: TooltipPayload): TooltipItemProps => { + return { + label: payload.name, + value: + typeof payload.value === 'number' + ? currencyFormatter.format(payload.value) + : (payload.value as string), + fill: payload.fill as string, + }; + }; + + const resources: ResourceData[] = projects.map(project => { + return { + name: project.id, + previous: project.unlabeledCost || 0, + current: project.labeledCost || 0, + }; + }); + + return ( + + ); +}; + +export default UnlabeledDataflowBarChart; diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowBarChart/index.ts b/plugins/cost-insights/src/components/UnlabeledDataflowBarChart/index.ts new file mode 100644 index 0000000000..6f475d5a80 --- /dev/null +++ b/plugins/cost-insights/src/components/UnlabeledDataflowBarChart/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default } from './UnlabeledDataflowBarChart'; diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/UnlabeledDataflowBarChartLegend.test.tsx b/plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/UnlabeledDataflowBarChartLegend.test.tsx new file mode 100644 index 0000000000..9ab4e90001 --- /dev/null +++ b/plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/UnlabeledDataflowBarChartLegend.test.tsx @@ -0,0 +1,34 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 UnlabeledDataflowBarChartLegend from './UnlabeledDataflowBarChartLegend'; +import { renderInTestApp } from '@backstage/test-utils'; + +describe('', () => { + it('Displays the correct text', async () => { + const rendered = await renderInTestApp( + , + ); + expect(rendered.getByText('Total Unlabeled Cost')).toBeInTheDocument(); + expect(rendered.getByText('Total Labeled Cost')).toBeInTheDocument(); + expect(rendered.getByText('$9,843')).toBeInTheDocument(); + expect(rendered.getByText('$2,309')).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/UnlabeledDataflowBarChartLegend.tsx b/plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/UnlabeledDataflowBarChartLegend.tsx new file mode 100644 index 0000000000..68caaed6d0 --- /dev/null +++ b/plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/UnlabeledDataflowBarChartLegend.tsx @@ -0,0 +1,59 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Box, useTheme } from '@material-ui/core'; +import LegendItem from '../LegendItem'; +import { currencyFormatter } from '../../utils/formatters'; +import { CostInsightsTheme } from '../../types'; + +type UnlabeledDataflowBarChartLegendProps = { + labeledCost: number; + unlabeledCost: number; +}; + +const UnlabeledDataflowBarChartLegend = ({ + unlabeledCost, + labeledCost, +}: UnlabeledDataflowBarChartLegendProps) => { + const theme = useTheme(); + return ( + + + + {currencyFormatter.format(unlabeledCost)} + + + + + {currencyFormatter.format(labeledCost)} + + + + ); +}; + +export default UnlabeledDataflowBarChartLegend; diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/index.ts b/plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/index.ts new file mode 100644 index 0000000000..470722e60a --- /dev/null +++ b/plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default } from './UnlabeledDataflowBarChartLegend'; diff --git a/plugins/cost-insights/src/components/WhyCostsMatter/WhyCostsMatter.tsx b/plugins/cost-insights/src/components/WhyCostsMatter/WhyCostsMatter.tsx new file mode 100644 index 0000000000..69de854b00 --- /dev/null +++ b/plugins/cost-insights/src/components/WhyCostsMatter/WhyCostsMatter.tsx @@ -0,0 +1,77 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Typography, Box, Grid, Container, Divider } from '@material-ui/core'; + +const WhyCostsMatter = () => { + return ( + + + + + Why cloud costs matter + + + + + + Sustainability{' '} + + 🌎 + + + + Reducing cloud usage improves our carbon footprint. + + + + + + + + Revenue{' '} + + 💸 + + + + Keeping cloud costs well-tuned prevents infrastructure from eating + into revenue. + + + + + + + + Innovation{' '} + + 🥇 + + + + The more we save, the more we can reinvest in speed and + innovation. + + + + + + ); +}; + +export default WhyCostsMatter; diff --git a/plugins/cost-insights/src/components/WhyCostsMatter/index.ts b/plugins/cost-insights/src/components/WhyCostsMatter/index.ts new file mode 100644 index 0000000000..ed60207fde --- /dev/null +++ b/plugins/cost-insights/src/components/WhyCostsMatter/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { default } from './WhyCostsMatter'; diff --git a/plugins/cost-insights/src/hooks/index.ts b/plugins/cost-insights/src/hooks/index.ts new file mode 100644 index 0000000000..7065bcf91b --- /dev/null +++ b/plugins/cost-insights/src/hooks/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 * from './useConfig'; +export * from './useCurrency'; +export * from './useFilters'; +export * from './useCurrency'; +export * from './useGroups'; +export * from './useLoading'; +export * from './useQueryParams'; +export * from './useScroll'; diff --git a/plugins/cost-insights/src/hooks/useConfig.tsx b/plugins/cost-insights/src/hooks/useConfig.tsx new file mode 100644 index 0000000000..aee6567056 --- /dev/null +++ b/plugins/cost-insights/src/hooks/useConfig.tsx @@ -0,0 +1,162 @@ +/* + * Copyright 2020 Spotify AB + * + * 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, { + ReactNode, + createContext, + useContext, + useEffect, + useState, +} from 'react'; +import { useApi, configApiRef } from '@backstage/core'; +import { Config as BackstageConfig } from '@backstage/config'; +import { Currency, defaultCurrencies, Product, Icon, Metric } from '../types'; +import { getIcon } from '../utils/navigation'; + +export const NULL_METRIC = 'dailyCost'; +export const NULL_METRIC_NAME = 'Daily Cost'; + +/* + * Config schema 2020-09-28 + * + * costInsights: + * engineerCost: 200000 + * products: + * productA: + * name: Product A + * icon: storage + * productB: + * name: Product B + * icon: data + * metrics: + * metricA: + * name: Metric A + * metricB: + * name: Metric B + */ + +export type ConfigContextProps = { + metrics: Metric[]; + products: Product[]; + icons: Icon[]; + engineerCost: number; + currencies: Currency[]; +}; + +export const ConfigContext = createContext( + undefined, +); + +const defaultState: ConfigContextProps = { + metrics: [{ kind: null, name: NULL_METRIC_NAME }], + products: [], + icons: [], + engineerCost: 0, + currencies: defaultCurrencies, +}; + +export const ConfigProvider = ({ children }: { children: ReactNode }) => { + const c: BackstageConfig = useApi(configApiRef); + const [config, setConfig] = useState(defaultState); + const [loading, setLoading] = useState(true); + + useEffect(() => { + function getProducts(): Product[] { + const products = c.getConfig('costInsights.products'); + return products.keys().map(key => ({ + kind: key, + name: products.getString(`${key}.name`), + aggregation: [0, 0], + })); + } + + function getMetrics(): Metric[] { + const metrics = c.getOptionalConfig('costInsights.metrics'); + if (metrics) { + return metrics.keys().map(key => ({ + kind: key === NULL_METRIC ? null : key, + name: metrics.getString(`${key}.name`), + })); + } + + return []; + } + + function getIcons(): Icon[] { + const products = c.getConfig('costInsights.products'); + const keys = products.keys(); + + return keys.map(k => ({ + kind: k, + component: getIcon(products.getOptionalString(`${k}.icon`)), + })); + } + + function getEngineerCost(): number { + return c.getNumber('costInsights.engineerCost'); + } + + function getConfig() { + const products = getProducts(); + const metrics = getMetrics(); + const engineerCost = getEngineerCost(); + const icons = getIcons(); + + if (metrics.find((m: Metric) => m.kind === null)) { + setConfig(prevState => ({ + ...prevState, + metrics, + products, + engineerCost, + icons, + })); + } else { + setConfig(prevState => ({ + ...prevState, + metrics: [...prevState.metrics, ...metrics], + products, + engineerCost, + icons, + })); + } + setLoading(false); + } + + getConfig(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + if (loading) { + return null; + } + + return ( + {children} + ); +}; + +export function useConfig(): ConfigContextProps { + const config = useContext(ConfigContext); + + if (!config) { + assertNever(); + } + + return config; +} + +function assertNever(): never { + throw new Error('Cannot use useConfig outside of ConfigProvider'); +} diff --git a/plugins/cost-insights/src/hooks/useCurrency.tsx b/plugins/cost-insights/src/hooks/useCurrency.tsx new file mode 100644 index 0000000000..f5d0df929a --- /dev/null +++ b/plugins/cost-insights/src/hooks/useCurrency.tsx @@ -0,0 +1,60 @@ +/* + * Copyright 2020 Spotify AB + * + * 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, { + Dispatch, + SetStateAction, + ReactNode, + useState, + useContext, +} from 'react'; +import { Currency, defaultCurrencies, findAlways } from '../types'; + +export type CurrencyContextProps = { + currency: Currency; + setCurrency: Dispatch>; +}; + +export type CurrencyProviderProps = { + children: ReactNode; +}; + +export const CurrencyContext = React.createContext< + CurrencyContextProps | undefined +>(undefined); + +export const CurrencyProvider = ({ children }: CurrencyProviderProps) => { + const engineers = findAlways(defaultCurrencies, c => c.kind === null); + const [currency, setCurrency] = useState(engineers); + return ( + + {children} + + ); +}; + +export function useCurrency(): [Currency, Dispatch>] { + const context = useContext(CurrencyContext); + + if (!context) { + assertNever(); + } + + return [context.currency, context.setCurrency]; +} + +function assertNever(): never { + throw Error('Cannot use useCurrency outside of CurrencyProvider'); +} diff --git a/plugins/cost-insights/src/hooks/useFilters.tsx b/plugins/cost-insights/src/hooks/useFilters.tsx new file mode 100644 index 0000000000..c2053d6b0f --- /dev/null +++ b/plugins/cost-insights/src/hooks/useFilters.tsx @@ -0,0 +1,148 @@ +/* + * Copyright 2020 Spotify AB + * + * 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, { + Dispatch, + ReactNode, + SetStateAction, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import { + getDefaultPageFilters, + PageFilters, + ProductFilters, + Duration, + Group, +} from '../types'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useQueryParams } from './useQueryParams'; +import { stringify } from '../utils/history'; +import { useGroups } from './useGroups'; +import { useConfig } from './useConfig'; + +const getInitialPageState = ( + groups: Group[], + queryParams?: Partial, +) => { + // The group is written initially to queryParams as null, since user groups are asynchronously + // loaded. We preserve nulls in queryParams for other parameters where null is meaningful; for + // group, avoid overwriting the default with null after groups are loaded. + const { group, ...otherParams } = queryParams || {}; + return { + ...getDefaultPageFilters(groups), + ...otherParams, + ...(group ? { group: group } : {}), + }; +}; + +export type FilterContextProps = { + pageFilters: PageFilters; + productFilters: ProductFilters; + setPageFilters: Dispatch>; + setProductFilters: Dispatch>; +}; + +export type MapFiltersToProps = (props: FilterContextProps) => T; + +export type FilterProviderProps = { + children: ReactNode; +}; + +export const FilterContext = React.createContext< + FilterContextProps | undefined +>(undefined); + +export const FilterProvider = ({ children }: FilterProviderProps) => { + const navigate = useNavigate(); + const location = useLocation(); + const queryParams = useQueryParams(); + const qsRef = useRef(''); + const groups = useGroups(); + const { products } = useConfig(); + + const defaultProductFilters = products.map(product => ({ + productType: product.kind, + duration: Duration.P1M, + })); + + const getInitialProductState = (productFilters?: ProductFilters) => { + if (!productFilters) return defaultProductFilters; + return defaultProductFilters.map(product => { + return ( + productFilters.find( + param => param.productType === product.productType, + ) || product + ); + }); + }; + + const [productFilters, setProductFilters] = useState( + getInitialProductState(queryParams.productFilters), + ); + const [pageFilters, setPageFilters] = useState( + getInitialPageState(groups, queryParams.pageFilters), + ); + + // TODO: Figure out why pageFilters doesn't get updated by the above when groups are loaded. + useEffect(() => { + setPageFilters(getInitialPageState(groups, queryParams.pageFilters)); + }, [groups]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + const queryString = stringify({ ...pageFilters, products: productFilters }); + if (queryString === qsRef.current) return; + qsRef.current = queryString; + // TODO Remove workaround once issue is resolved in react-router + // (https://github.com/ReactTraining/react-router/issues/7496) + // navigate({ ...location, search: queryString }); + navigate({ ...location, search: `?${queryString}` }); + }, [pageFilters, productFilters]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + + {children} + + ); +}; + +export function useFilters(mapFiltersToProps: MapFiltersToProps): T { + const context = useContext(FilterContext); + + if (!context) { + assertNever(); + } + + return mapFiltersToProps({ + pageFilters: context.pageFilters, + productFilters: context.productFilters, + setPageFilters: context.setPageFilters, + setProductFilters: context.setProductFilters, + }); +} + +function assertNever(): never { + throw Error('Cannot use useFilters outside of FilterProvider'); +} diff --git a/plugins/cost-insights/src/hooks/useGroups.tsx b/plugins/cost-insights/src/hooks/useGroups.tsx new file mode 100644 index 0000000000..02c32e7920 --- /dev/null +++ b/plugins/cost-insights/src/hooks/useGroups.tsx @@ -0,0 +1,84 @@ +/* + * Copyright 2020 Spotify AB + * + * 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, { ReactNode, useContext, useEffect, useState } from 'react'; +import { useApi, identityApiRef } from '@backstage/core'; +import { costInsightsApiRef } from '../api'; +import { MapLoadingToProps, useLoading } from './useLoading'; +import { DefaultLoadingAction, Group } from '../types'; + +type GroupsProviderLoadingProps = { + dispatchLoadingGroups: (isLoading: boolean) => void; +}; + +export const mapLoadingToProps: MapLoadingToProps = ({ + dispatch, +}) => ({ + dispatchLoadingGroups: (isLoading: boolean) => + dispatch({ [DefaultLoadingAction.UserGroups]: isLoading }), +}); + +type GroupsContextProps = { + groups: Group[]; +}; + +export type GroupsProviderProps = { + children: ReactNode; +}; + +export const GroupsContext = React.createContext({ + groups: [], +}); + +export const GroupsProvider = ({ children }: GroupsProviderProps) => { + const userId = useApi(identityApiRef).getUserId(); + const client = useApi(costInsightsApiRef); + const { dispatchLoadingGroups } = useLoading(mapLoadingToProps); + + const [groups, setGroups] = useState([]); + + useEffect(() => { + dispatchLoadingGroups(true); + + async function getUserGroups() { + const g = await client.getUserGroups(userId); + setGroups(g); + dispatchLoadingGroups(false); + } + + getUserGroups(); + }, [userId, client]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + + {children} + + ); +}; + +export function useGroups(): Group[] { + const { groups } = useContext(GroupsContext); + + if (!groups) { + assertNever(); + } + + return groups; +} + +function assertNever(): never { + throw Error('Cannot use useGroups outside of GroupsProvider'); +} diff --git a/plugins/cost-insights/src/hooks/useLoading.tsx b/plugins/cost-insights/src/hooks/useLoading.tsx new file mode 100644 index 0000000000..bc0df4bbc7 --- /dev/null +++ b/plugins/cost-insights/src/hooks/useLoading.tsx @@ -0,0 +1,107 @@ +/* + * Copyright 2020 Spotify AB + * + * 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, { + Dispatch, + ReactNode, + SetStateAction, + createContext, + useContext, + useEffect, + useMemo, + useReducer, + useState, +} from 'react'; +import { Backdrop, CircularProgress } from '@material-ui/core'; +import { + Loading, + getDefaultState, + DefaultLoadingAction, + getLoadingActions, +} from '../types'; +import { useBackdropStyles as useStyles } from '../utils/styles'; +import { useConfig } from './useConfig'; + +export type LoadingContextProps = { + state: Loading; + dispatch: Dispatch>>; + actions: Array; +}; + +export type LoadingProviderProps = { + children: ReactNode; +}; + +export type MapLoadingToProps = (props: LoadingContextProps) => T; + +export const LoadingContext = createContext( + undefined, +); + +function reducer(prevState: Loading, action: Partial): Loading { + return { + ...prevState, + ...action, + } as Record; +} + +export const LoadingProvider = ({ children }: LoadingProviderProps) => { + const classes = useStyles(); + const { products } = useConfig(); + const actions = useMemo(() => getLoadingActions(products.map(p => p.kind)), [ + products, + ]); + const [state, dispatch] = useReducer(reducer, getDefaultState(actions)); + const [isBackdropVisible, setBackdropVisible] = useState(false); + + useEffect(() => { + function displayLoadingBackdrop() { + // Initial page loading is handled by progress bar + setBackdropVisible( + !state[DefaultLoadingAction.CostInsightsInitial] && + Object.values(state).some(l => l), + ); + } + displayLoadingBackdrop(); + }, [state, setBackdropVisible]); + + return ( + + {children} + + + + + ); +}; + +export function useLoading(mapLoadingToProps: MapLoadingToProps): T { + const context = useContext(LoadingContext); + + if (!context) { + assertNever(); + } + + return mapLoadingToProps({ + state: context.state, + actions: context.actions, + dispatch: context.dispatch, + }); +} + +function assertNever(): never { + throw Error('useLoading cannot be used outside of LoadingProvider'); +} diff --git a/plugins/cost-insights/src/hooks/useQueryParams.tsx b/plugins/cost-insights/src/hooks/useQueryParams.tsx new file mode 100644 index 0000000000..1e6ce8f0fd --- /dev/null +++ b/plugins/cost-insights/src/hooks/useQueryParams.tsx @@ -0,0 +1,35 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { useLocation } from 'react-router-dom'; +import { Location } from 'history'; +import { PageFilters, ProductFilters } from '../types'; +import { parse } from '../utils/history'; + +export type FilterParams = { + pageFilters?: Partial; + productFilters?: ProductFilters; +}; + +export function useQueryParams(): FilterParams { + const location: Location = useLocation(); + const { products: productFilters, ...pageFilters } = parse(location.search); + + return { + productFilters: productFilters, + pageFilters: pageFilters, + }; +} diff --git a/plugins/cost-insights/src/hooks/useScroll.tsx b/plugins/cost-insights/src/hooks/useScroll.tsx new file mode 100644 index 0000000000..480495a257 --- /dev/null +++ b/plugins/cost-insights/src/hooks/useScroll.tsx @@ -0,0 +1,126 @@ +/* + * Copyright 2020 Spotify AB + * + * 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, { + Dispatch, + ReactNode, + SetStateAction, + useState, + useContext, + useEffect, + useRef, +} from 'react'; +import { CSSProperties } from '@material-ui/styles'; +import { Maybe } from '../types'; + +export type ScrollTo = Maybe; + +export type ScrollContextProps = { + scrollTo: ScrollTo; + setScrollTo: Dispatch>; +}; + +export type ScrollProviderProps = { + children: ReactNode; +}; + +export type ScrollUtils = { + ScrollAnchor: (props: Omit) => JSX.Element; + scrollIntoView: () => void; +}; + +export interface ScrollAnchorProps extends ScrollIntoViewOptions { + id: ScrollTo; + top?: number; + left?: number; +} + +export const ScrollContext = React.createContext< + ScrollContextProps | undefined +>(undefined); + +export const ScrollAnchor = ({ + id, + top, + left, + behavior, + block, + inline, +}: ScrollAnchorProps) => { + const divRef = useRef(null); + const context = useContext(ScrollContext); + + if (!context) { + assertNever(); + } + + const { scrollTo, setScrollTo } = context; + + const styles: CSSProperties = { + position: 'absolute', + height: 0, + width: 0, + top: top || 0, + left: left || 0, + }; + + useEffect(() => { + function scrollIntoView() { + const options = { + behavior: behavior || 'auto', + block: block || 'start', + inline: inline || 'nearest', + }; + + if (divRef.current && scrollTo === id) { + divRef.current.scrollIntoView(options); + setScrollTo(null); + } + } + + scrollIntoView(); + }, [scrollTo, setScrollTo, id, behavior, block, inline]); + + return
; +}; + +export const ScrollProvider = ({ children }: ScrollProviderProps) => { + const [scrollTo, setScrollTo] = useState(null); + + return ( + + {children} + + ); +}; + +export function useScroll(id: ScrollTo): ScrollUtils { + const context = useContext(ScrollContext); + + if (!context) { + assertNever(); + } + + return { + ScrollAnchor: props => , + scrollIntoView: () => context.setScrollTo(id), + }; +} + +function assertNever(): never { + throw new Error( + `Cannot use useScroll or ScrollAnchor outside ScrollProvider`, + ); +} diff --git a/plugins/cost-insights/src/index.ts b/plugins/cost-insights/src/index.ts new file mode 100644 index 0000000000..cf7aa3ea8c --- /dev/null +++ b/plugins/cost-insights/src/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { plugin } from './plugin'; +export * from './api'; +export * from './types'; diff --git a/plugins/cost-insights/src/plugin.test.ts b/plugins/cost-insights/src/plugin.test.ts new file mode 100644 index 0000000000..6210e11d4c --- /dev/null +++ b/plugins/cost-insights/src/plugin.test.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { plugin } from './plugin'; + +describe('cost-insights', () => { + it('should export plugin', () => { + expect(plugin).toBeDefined(); + }); +}); diff --git a/plugins/cost-insights/src/plugin.ts b/plugins/cost-insights/src/plugin.ts new file mode 100644 index 0000000000..d8d54ae71b --- /dev/null +++ b/plugins/cost-insights/src/plugin.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { createPlugin, createRouteRef } from '@backstage/core'; +import CostInsightsPage from './components/CostInsightsPage'; +import ProjectGrowthInstructionsPage from './components/ProjectGrowthInstructionsPage'; +import LabelDataflowInstructionsPage from './components/LabelDataflowInstructionsPage'; + +export const rootRouteRef = createRouteRef({ + path: '/cost-insights', + title: 'Cost Insights', +}); + +export const projectGrowthAlertRef = createRouteRef({ + path: '/cost-insights/investigating-growth', + title: 'Investigating Growth', +}); + +export const unlabeledDataflowAlertRef = createRouteRef({ + path: '/cost-insights/labeling-jobs', + title: 'Labeling Dataflow Jobs', +}); + +export const plugin = createPlugin({ + id: 'cost-insights', + register({ router, featureFlags }) { + router.addRoute(rootRouteRef, CostInsightsPage); + router.addRoute(projectGrowthAlertRef, ProjectGrowthInstructionsPage); + router.addRoute(unlabeledDataflowAlertRef, LabelDataflowInstructionsPage); + featureFlags.register('cost-insights-currencies'); + }, +}); diff --git a/plugins/cost-insights/src/setupTests.ts b/plugins/cost-insights/src/setupTests.ts new file mode 100644 index 0000000000..8553642152 --- /dev/null +++ b/plugins/cost-insights/src/setupTests.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 '@testing-library/jest-dom'; + +require('jest-fetch-mock').enableMocks(); diff --git a/plugins/cost-insights/src/types/Alert.ts b/plugins/cost-insights/src/types/Alert.ts new file mode 100644 index 0000000000..8d560096e3 --- /dev/null +++ b/plugins/cost-insights/src/types/Alert.ts @@ -0,0 +1,77 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { ChangeStatistic } from './ChangeStatistic'; +import { Maybe } from './Maybe'; + +export type Alert = ProjectGrowthAlert | UnlabeledDataflowAlert; + +export interface AlertProps { + alert: Alert; +} + +export enum AlertType { + ProjectGrowth = 'projectGrowth', + UnlabeledDataflow = 'unlabeledDataflow', +} + +export interface AlertCost { + id: string; + aggregation: [number, number]; +} + +export interface ResourceData { + previous: number; + current: number; + name: Maybe; +} + +export interface BarChartData { + previousFill: string; + currentFill: string; + previousName: string; + currentName: string; +} + +export enum DataKey { + Previous = 'previous', + Current = 'current', + Name = 'name', +} + +export interface ProjectGrowthAlert { + id: AlertType.ProjectGrowth; + project: string; + periodStart: string; + periodEnd: string; + aggregation: [number, number]; + change: ChangeStatistic; + products: Array; +} + +export interface UnlabeledDataflowAlert { + id: AlertType.UnlabeledDataflow; + periodStart: string; + periodEnd: string; + projects: Array; + unlabeledCost: number; + labeledCost: number; +} + +export interface UnlabeledDataflowAlertProject { + id: string; + unlabeledCost: number; + labeledCost: number; +} diff --git a/plugins/cost-insights/src/types/ChangeStatistic.ts b/plugins/cost-insights/src/types/ChangeStatistic.ts new file mode 100644 index 0000000000..538692bb1d --- /dev/null +++ b/plugins/cost-insights/src/types/ChangeStatistic.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 interface ChangeStatistic { + // The ratio of change from one duration to another, expressed as: (newSum - oldSum) / oldSum + ratio: number; + // The actual USD change between time periods (can be negative if costs decreased) + amount: number; +} + +export const EngineerThreshold = 0.5; + +export enum ChangeThreshold { + upper = 0.05, + lower = -0.05, +} + +export enum Growth { + Negligible, + Savings, + Excess, +} + +// Used by for displaying status colors +export function growthOf(amount: number, ratio: number) { + if (amount >= EngineerThreshold && ratio >= ChangeThreshold.upper) { + return Growth.Excess; + } + + if (amount >= EngineerThreshold && ratio <= ChangeThreshold.lower) { + return Growth.Savings; + } + + return Growth.Negligible; +} diff --git a/plugins/cost-insights/src/types/Cost.ts b/plugins/cost-insights/src/types/Cost.ts new file mode 100644 index 0000000000..84a8bfae51 --- /dev/null +++ b/plugins/cost-insights/src/types/Cost.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { DateAggregation } from './DateAggregation'; +import { ChangeStatistic } from './ChangeStatistic'; +import { Trendline } from './Trendline'; + +export interface Cost { + id: string; + aggregation: DateAggregation[]; + change: ChangeStatistic; + trendline: Trendline; +} diff --git a/plugins/cost-insights/src/types/Currency.ts b/plugins/cost-insights/src/types/Currency.ts new file mode 100644 index 0000000000..633e405893 --- /dev/null +++ b/plugins/cost-insights/src/types/Currency.ts @@ -0,0 +1,78 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Duration } from './Duration'; +import { assertNever } from './Maybe'; + +export enum CurrencyType { + USD = 'USD', + CarbonOffsetTons = 'CARBON_OFFSET_TONS', + Beers = 'BEERS', + IceCream = 'PINTS_OF_ICE_CREAM', +} + +export interface Currency { + kind: string | null; + label: string; + unit: string; + prefix?: string; + rate?: number; +} + +export const rateOf = (cost: number, duration: Duration) => { + switch (duration) { + case Duration.P1M: + case Duration.P30D: + return cost / 12; + case Duration.P90D: + case Duration.P3M: + return cost / 4; + default: + return assertNever(duration); + } +}; + +export const defaultCurrencies: Currency[] = [ + { + kind: null, + label: 'Engineers 🛠', + unit: 'engineer', + }, + { + kind: CurrencyType.USD, + label: 'US Dollars 💵', + unit: 'dollar', + prefix: '$', + rate: 1, + }, + { + kind: CurrencyType.CarbonOffsetTons, + label: 'Carbon Offset Tons ♻️⚖️s', + unit: 'carbon offset ton', + rate: 3.5, + }, + { + kind: CurrencyType.Beers, + label: 'Beers 🍺', + unit: 'beer', + rate: 4.5, + }, + { + kind: CurrencyType.IceCream, + label: 'Pints of Ice Cream 🍦', + unit: 'ice cream pint', + rate: 5.5, + }, +]; diff --git a/plugins/cost-insights/src/types/DateAggregation.ts b/plugins/cost-insights/src/types/DateAggregation.ts new file mode 100644 index 0000000000..807387a265 --- /dev/null +++ b/plugins/cost-insights/src/types/DateAggregation.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 type DateAggregation = { + date: string; // YYYY-MM-DD + amount: number; +}; diff --git a/plugins/cost-insights/src/types/Duration.test.ts b/plugins/cost-insights/src/types/Duration.test.ts new file mode 100644 index 0000000000..5b9f7b4a9b --- /dev/null +++ b/plugins/cost-insights/src/types/Duration.test.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Duration, inclusiveEndDateOf, inclusiveStartDateOf } from './Duration'; + +Date.now = jest.fn(() => new Date(Date.parse('2020-06-05')).valueOf()); + +describe.each` + duration | startDate | endDate + ${Duration.P30D} | ${'2020-04-06'} | ${'2020-06-05'} + ${Duration.P90D} | ${'2019-12-08'} | ${'2020-06-05'} + ${Duration.P1M} | ${'2020-04-01'} | ${'2020-05-31'} + ${Duration.P3M} | ${'2019-10-01'} | ${'2020-03-31'} +`('Calculates interval dates correctly', ({ duration, startDate, endDate }) => { + it(`Calculates dates correctly for ${duration}`, () => { + expect(inclusiveStartDateOf(duration)).toBe(startDate); + expect(inclusiveEndDateOf(duration)).toBe(endDate); + }); +}); diff --git a/plugins/cost-insights/src/types/Duration.ts b/plugins/cost-insights/src/types/Duration.ts new file mode 100644 index 0000000000..6e6c5b1100 --- /dev/null +++ b/plugins/cost-insights/src/types/Duration.ts @@ -0,0 +1,85 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 moment from 'moment'; +import { assertNever } from './Maybe'; + +/** + * Time periods for cost comparison; slight abuse of ISO 8601 periods. We take P1M and P3M to mean + * 'last completed [month|quarter]', and P30D/P90D to be '[month|quarter] relative to today'. So if + * it's September 15, P1M represents costs for the month of August and P30D represents August 16 - + * September 15. + */ +export enum Duration { + P30D = 'P30D', + P90D = 'P90D', + P1M = 'P1M', + P3M = 'P3M', +} + +export const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD'; + +// Derive the start date of a given period, assuming two repeating intervals +export function inclusiveStartDateOf(duration: Duration): string { + switch (duration) { + case Duration.P30D: + case Duration.P90D: + return moment() + .utc() + .subtract(moment.duration(duration).add(moment.duration(duration))) + .format(DEFAULT_DATE_FORMAT); + case Duration.P1M: + return moment() + .utc() + .startOf('month') + .subtract(moment.duration(duration).add(moment.duration(duration))) + .format(DEFAULT_DATE_FORMAT); + case Duration.P3M: + return moment() + .utc() + .startOf('quarter') + .subtract(moment.duration(duration).add(moment.duration(duration))) + .format(DEFAULT_DATE_FORMAT); + default: + return assertNever(duration); + } +} + +export function exclusiveEndDateOf(duration: Duration): string { + switch (duration) { + case Duration.P30D: + case Duration.P90D: + return moment().utc().add(1, 'day').format(DEFAULT_DATE_FORMAT); + case Duration.P1M: + return moment().utc().startOf('month').format(DEFAULT_DATE_FORMAT); + case Duration.P3M: + return moment().utc().startOf('quarter').format(DEFAULT_DATE_FORMAT); + default: + return assertNever(duration); + } +} + +export function inclusiveEndDateOf(duration: Duration): string { + return moment(exclusiveEndDateOf(duration)) + .utc() + .subtract(1, 'day') + .format(DEFAULT_DATE_FORMAT); +} + +// https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals +export function intervalsOf(duration: Duration) { + return `R2/${duration}/${exclusiveEndDateOf(duration)}`; +} diff --git a/plugins/cost-insights/src/types/Entity.ts b/plugins/cost-insights/src/types/Entity.ts new file mode 100644 index 0000000000..f5ac620289 --- /dev/null +++ b/plugins/cost-insights/src/types/Entity.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { ChangeStatistic } from './ChangeStatistic'; +import { Maybe } from './Maybe'; + +export interface Entity { + id: Maybe; + aggregation: [number, number]; + change?: Maybe; +} diff --git a/plugins/cost-insights/src/types/Filters.ts b/plugins/cost-insights/src/types/Filters.ts new file mode 100644 index 0000000000..def1d74d1a --- /dev/null +++ b/plugins/cost-insights/src/types/Filters.ts @@ -0,0 +1,44 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Maybe } from './Maybe'; +import { Duration } from './Duration'; +import { Group } from './Group'; + +export interface PageFilters { + group: Maybe; + project: Maybe; + duration: Duration; + metric: Maybe; +} + +export type ProductFilters = Array; + +export type QueryParams = PageFilters & { products: ProductFilters }; + +export interface ProductPeriod { + duration: Duration; + productType: string; +} + +export function getDefaultPageFilters(groups: Group[]): PageFilters { + return { + group: groups.length ? groups[0].id : null, + project: null, + duration: Duration.P90D, + metric: null, + }; +} diff --git a/plugins/cost-insights/src/types/Group.ts b/plugins/cost-insights/src/types/Group.ts new file mode 100644 index 0000000000..3aef92e5b9 --- /dev/null +++ b/plugins/cost-insights/src/types/Group.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 type Group = { + id: string; +}; diff --git a/plugins/cost-insights/src/types/Icon.ts b/plugins/cost-insights/src/types/Icon.ts new file mode 100644 index 0000000000..70f43a6813 --- /dev/null +++ b/plugins/cost-insights/src/types/Icon.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 type Icon = { + kind: string; + component: JSX.Element; +}; + +export enum IconType { + Compute = 'compute', + Data = 'data', + Database = 'database', + Storage = 'storage', + Search = 'search', + ML = 'ml', +} diff --git a/plugins/cost-insights/src/types/Loading.ts b/plugins/cost-insights/src/types/Loading.ts new file mode 100644 index 0000000000..f5da1d2b07 --- /dev/null +++ b/plugins/cost-insights/src/types/Loading.ts @@ -0,0 +1,63 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 type Loading = Record; + +export enum DefaultLoadingAction { + UserGroups = 'user-groups', + CostInsightsInitial = 'cost-insights-initial', + CostInsightsPage = 'cost-insights-page', +} + +export const INITIAL_LOADING_ACTIONS = [ + DefaultLoadingAction.UserGroups, + DefaultLoadingAction.CostInsightsInitial, +]; + +export const getDefaultState = (loadingActions: string[]): Loading => { + return loadingActions.reduce( + (defaultState, action) => ({ ...defaultState, [action]: true }), + {}, + ); +}; + +export const getResetState = (loadingActions: string[]): Loading => { + return loadingActions.reduce( + (defaultState, action) => ({ ...defaultState, [action]: false }), + {}, + ); +}; + +export const getResetStateWithoutInitial = ( + loadingActions: string[], +): Loading => { + return loadingActions.reduce((defaultState, action) => { + const loadingActionState = (INITIAL_LOADING_ACTIONS as string[]).includes( + action, + ) + ? false + : true; + return { ...defaultState, [action]: loadingActionState }; + }, {}); +}; + +export function getLoadingActions(products: string[]): string[] { + return ([ + DefaultLoadingAction.UserGroups, + DefaultLoadingAction.CostInsightsInitial, + DefaultLoadingAction.CostInsightsPage, + ] as string[]).concat(products); +} diff --git a/plugins/cost-insights/src/types/Maybe.ts b/plugins/cost-insights/src/types/Maybe.ts new file mode 100644 index 0000000000..d721f28d75 --- /dev/null +++ b/plugins/cost-insights/src/types/Maybe.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 type Maybe = T | null; + +export function notEmpty( + value: TValue | null | undefined, +): value is TValue { + return value !== null && value !== undefined; +} + +// Utility for exhaustiveness checking in switch statements +export function assertNever(x: never): never { + throw new Error(`Exhaustiveness check failed: ${x}`); +} + +export function assertAlways(argument: T | undefined): T { + if (argument === undefined) { + throw new TypeError( + 'Expected to always find a value but received undefined', + ); + } + return argument; +} + +// Utility for working with static lists; asserts a value will always be found or +// throws an error +export function findAlways( + collection: T[], + callback: (el: T) => boolean, +): T { + return assertAlways(collection.find(callback)); +} diff --git a/plugins/cost-insights/src/types/Metric.ts b/plugins/cost-insights/src/types/Metric.ts new file mode 100644 index 0000000000..5a440a8d1f --- /dev/null +++ b/plugins/cost-insights/src/types/Metric.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Maybe } from './Maybe'; + +export type Metric = { + kind: Maybe; + name: string; +}; diff --git a/plugins/cost-insights/src/types/Product.ts b/plugins/cost-insights/src/types/Product.ts new file mode 100644 index 0000000000..d2087644cb --- /dev/null +++ b/plugins/cost-insights/src/types/Product.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Entity } from './Entity'; +import { ChangeStatistic } from './ChangeStatistic'; +import { Maybe } from './Maybe'; + +export interface Product { + kind: string; + name: string; +} + +export interface ProductCost { + entities?: Array; + aggregation: [number, number]; + change?: Maybe; +} diff --git a/plugins/cost-insights/src/types/Project.ts b/plugins/cost-insights/src/types/Project.ts new file mode 100644 index 0000000000..24259f6910 --- /dev/null +++ b/plugins/cost-insights/src/types/Project.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 interface Project { + id: string; +} diff --git a/plugins/cost-insights/src/types/Theme.ts b/plugins/cost-insights/src/types/Theme.ts new file mode 100644 index 0000000000..9053283351 --- /dev/null +++ b/plugins/cost-insights/src/types/Theme.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { BackstagePalette, BackstageTheme } from '@backstage/theme'; +import { PaletteOptions } from '@material-ui/core/styles/createPalette'; + +type CostInsightsTooltipOptions = { + background: string; + color: string; +}; + +type CostInsightsPaletteAdditions = { + blue: string; + lightBlue: string; + darkBlue: string; + magenta: string; + yellow: string; + tooltip: CostInsightsTooltipOptions; + navigationText: string; + alertBackground: string; +}; + +export type CostInsightsPalette = BackstagePalette & + CostInsightsPaletteAdditions; + +export type CostInsightsPaletteOptions = PaletteOptions & + CostInsightsPaletteAdditions; + +export interface CostInsightsThemeOptions extends PaletteOptions { + palette: CostInsightsPaletteOptions; +} + +export interface CostInsightsTheme extends BackstageTheme { + palette: CostInsightsPalette; +} diff --git a/plugins/cost-insights/src/types/Trendline.ts b/plugins/cost-insights/src/types/Trendline.ts new file mode 100644 index 0000000000..aad7ba0bac --- /dev/null +++ b/plugins/cost-insights/src/types/Trendline.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 type Trendline = { + slope: number; + intercept: number; +}; diff --git a/plugins/cost-insights/src/types/index.ts b/plugins/cost-insights/src/types/index.ts new file mode 100644 index 0000000000..c3d9a87886 --- /dev/null +++ b/plugins/cost-insights/src/types/index.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 * from './Alert'; +export * from './ChangeStatistic'; +export * from './Cost'; +export * from './DateAggregation'; +export * from './Duration'; +export * from './Currency'; +export * from './Entity'; +export * from './Icon'; +export * from './Filters'; +export * from './Group'; +export * from './Loading'; +export * from './Maybe'; +export * from './Metric'; +export * from './Product'; +export * from './Project'; +export * from './Theme'; +export * from './Trendline'; diff --git a/plugins/cost-insights/src/utils/alerts.tsx b/plugins/cost-insights/src/utils/alerts.tsx new file mode 100644 index 0000000000..07602be191 --- /dev/null +++ b/plugins/cost-insights/src/utils/alerts.tsx @@ -0,0 +1,82 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Alert, AlertType, assertNever } from '../types'; +import ProjectGrowthAlertCard from '../components/ProjectGrowthAlertCard'; +import UnlabeledDataflowAlertCard from '../components/UnlabeledDataflowAlertCard'; + +export function getAlertText(alert: Alert) { + switch (alert.id) { + case AlertType.ProjectGrowth: + return { + title: `Investigate cost growth in project ${alert.project}`, + subtitle: + 'Cost growth outpacing business growth is unsustainable long-term.', + } as AlertText; + case AlertType.UnlabeledDataflow: + return { + title: 'Add labels to workflows', + subtitle: + 'Labels show in billing data, enabling cost insights for each workflow.', + }; + default: + return assertNever(alert); + } +} + +export function getAlertUrl(alert: Alert) { + switch (alert.id) { + case AlertType.ProjectGrowth: + return '/cost-insights/investigating-growth' as AlertUrl; + case AlertType.UnlabeledDataflow: + return '/cost-insights/labeling-jobs' as AlertUrl; + default: + return assertNever(alert); + } +} + +export function getAlertButtonText(alert: Alert) { + switch (alert.id) { + case AlertType.ProjectGrowth: + case AlertType.UnlabeledDataflow: + return 'View Instructions' as AlertButtonText; + default: + return assertNever(alert); + } +} + +export function getAlertNavigation(alert: Alert, number: number) { + return `${alert.id}-${number}`; +} + +export function renderAlert(alert: Alert) { + switch (alert.id) { + case AlertType.ProjectGrowth: + return ; + case AlertType.UnlabeledDataflow: + return ; + default: + return assertNever(alert); + } +} + +export type AlertUrl = string; +export type AlertButtonText = string; + +export interface AlertText { + title: string; + subtitle: string; +} diff --git a/plugins/cost-insights/src/utils/formatters.test.ts b/plugins/cost-insights/src/utils/formatters.test.ts new file mode 100644 index 0000000000..a76e838b53 --- /dev/null +++ b/plugins/cost-insights/src/utils/formatters.test.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { lengthyCurrencyFormatter, quarterOf } from './formatters'; + +Date.now = jest.fn(() => new Date(Date.parse('2019-12-07')).valueOf()); + +describe('date formatters', () => { + it('Formats quarters', () => { + expect(quarterOf('2020-01-01')).toBe('Q1 2020'); + expect(quarterOf('2020-02-29')).toBe('Q1 2020'); + expect(quarterOf('2020-05-11')).toBe('Q2 2020'); + expect(quarterOf('2020-06-30')).toBe('Q2 2020'); + expect(quarterOf('2020-07-01')).toBe('Q3 2020'); + expect(quarterOf('2020-10-04')).toBe('Q4 2020'); + }); + + it('Correctly formats values to two significant digits', () => { + const values = [ + 0.00000040925, + 0.21, + 0.0000004, + 0.4139877878, + 0.00000234566, + ]; + const formattedValues = values.map(val => + lengthyCurrencyFormatter.format(val), + ); + expect(formattedValues).toEqual([ + '$0.00000041', + '$0.21', + '$0.00000040', + '$0.41', + '$0.0000023', + ]); + }); +}); diff --git a/plugins/cost-insights/src/utils/formatters.ts b/plugins/cost-insights/src/utils/formatters.ts new file mode 100644 index 0000000000..f3790f315e --- /dev/null +++ b/plugins/cost-insights/src/utils/formatters.ts @@ -0,0 +1,126 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 moment from 'moment'; +import { + Duration, + inclusiveEndDateOf, + inclusiveStartDateOf, +} from '../types/Duration'; +import { pluralOf } from '../utils/grammar'; + +export type Period = { + periodStart: string; + periodEnd: string; +}; + +export const currencyFormatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0, +}); + +export const lengthyCurrencyFormatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + minimumSignificantDigits: 2, + maximumSignificantDigits: 2, +}); + +export const numberFormatter = new Intl.NumberFormat('en-US', { + minimumFractionDigits: 0, + maximumFractionDigits: 0, +}); + +export const monthFormatter = new Intl.DateTimeFormat('en-US', { + timeZone: 'UTC', + month: 'long', + year: 'numeric', +}); + +export const dateFormatter = new Intl.DateTimeFormat('en-US', { + timeZone: 'UTC', + day: 'numeric', + month: 'short', +}); + +export const monthOf = (date: string): string => { + return monthFormatter.format(Date.parse(date)); +}; + +export const quarterOf = (date: string): string => { + // Supports formatting YYYY-MM-DD and YYYY-[Q]Q returned in alerts + const d = moment(date).isValid() ? moment(date) : moment(date, 'YYYY-[Q]Q'); + return d.format('[Q]Q YYYY'); +}; + +export function formatCurrency(amount: number, currency?: string): string { + const n = Math.round(amount); + const numString = numberFormatter.format(n); + + return currency ? `${numString} ${pluralOf(n, currency)}` : numString; +} + +export function formatPercent(n: number): string { + // Number.toFixed shows scientific notation for extreme numbers + if (isNaN(n) || Math.abs(n) < 0.01) { + return '0%'; + } + if (Math.abs(n) >= 1e19) { + return '∞%'; + } + return `${(n * 100).toFixed(0)}%`; +} + +export const formatLastTwoLookaheadQuarters = () => { + const start = moment(inclusiveStartDateOf(Duration.P3M)).format('[Q]Q YYYY'); + const end = moment(inclusiveEndDateOf(Duration.P3M)).format('[Q]Q YYYY'); + return `${start} vs ${end}`; +}; + +export const formatLastTwoMonths = () => { + const start = moment(inclusiveStartDateOf(Duration.P1M)).utc().format('MMMM'); + const end = moment(inclusiveEndDateOf(Duration.P1M)).utc().format('MMMM'); + return `${start} vs ${end}`; +}; + +export const dateRegex: RegExp = /^\d{4}-\d{2}-\d{2}$/; + +export const formatRelativeDuration = ( + date: string, + duration: Duration, +): string => { + const periodStart = inclusiveStartDateOf(duration); + const periodEnd = inclusiveEndDateOf(duration); + const days = moment.duration(duration).asDays(); + if (![periodStart, periodEnd].includes(date)) { + throw new Error(`Invalid relative date ${date} for duration ${duration}`); + } + return date === periodStart ? `First ${days} Days` : `Last ${days} Days`; +}; + +export function formatDuration(date: string, duration: Duration) { + switch (duration) { + case Duration.P1M: + return monthOf(date); + case Duration.P3M: + return quarterOf(date); + default: + return formatRelativeDuration(date, duration); + } +} diff --git a/plugins/cost-insights/src/utils/grammar.ts b/plugins/cost-insights/src/utils/grammar.ts new file mode 100644 index 0000000000..653c567d0a --- /dev/null +++ b/plugins/cost-insights/src/utils/grammar.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2020 Spotify AB + * + * 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. + */ + +const vowels = { + a: 'A', + e: 'E', + i: 'I', + o: 'O', + u: 'U', +}; + +export const pluralOf = ( + n: number, + string: string, + plural?: string, +): string => { + if (n !== 1) { + if (plural) { + return plural; + } + return string.concat('s'); + } + return string; +}; + +export const indefiniteArticleOf = ( + articles: [string, string], + word: string, +) => { + const firstChar = word.charAt(0).toLowerCase(); + return firstChar in vowels + ? `${articles[1]} ${word}` + : `${articles[0]} ${word}`; +}; diff --git a/plugins/cost-insights/src/utils/graphs.tsx b/plugins/cost-insights/src/utils/graphs.tsx new file mode 100644 index 0000000000..5200b14b1f --- /dev/null +++ b/plugins/cost-insights/src/utils/graphs.tsx @@ -0,0 +1,31 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { + currencyFormatter, + dateFormatter, + lengthyCurrencyFormatter, +} from './formatters'; + +export function formatGraphValue(value: number) { + if (value < 1) { + return lengthyCurrencyFormatter.format(value); + } + return currencyFormatter.format(value); +} + +export const overviewGraphTickFormatter = (millis: string | number) => + typeof millis === 'number' ? dateFormatter.format(millis) : millis; diff --git a/plugins/cost-insights/src/utils/history.ts b/plugins/cost-insights/src/utils/history.ts new file mode 100644 index 0000000000..71e8e2c219 --- /dev/null +++ b/plugins/cost-insights/src/utils/history.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 qs from 'qs'; +import { QueryParams } from '../types'; + +export const stringify = (queryParams: Partial) => + qs.stringify(queryParams, { strictNullHandling: true }); +export const parse = (queryString: string): Partial => + qs.parse(queryString, { ignoreQueryPrefix: true, strictNullHandling: true }); diff --git a/plugins/cost-insights/src/utils/mockData.ts b/plugins/cost-insights/src/utils/mockData.ts new file mode 100644 index 0000000000..87dbcf8158 --- /dev/null +++ b/plugins/cost-insights/src/utils/mockData.ts @@ -0,0 +1,194 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { + AlertType, + Entity, + ProjectGrowthAlert, + Product, + UnlabeledDataflowAlert, + UnlabeledDataflowAlertProject, + getDefaultState, + DefaultLoadingAction, + Duration, + ProductCost, + findAlways, +} from '../types'; +import { Config } from '@backstage/config'; +import { ConfigApi } from '@backstage/core'; + +type mockAlertRenderer = (alert: T) => T; +type mockEntityRenderer = (entity: T) => T; + +export const createMockEntity = ( + callback?: mockEntityRenderer, +): Entity => { + const defaultEntity: Entity = { + id: 'test-entity', + aggregation: [100, 200], + }; + + if (typeof callback === 'function') { + return callback({ ...defaultEntity }); + } + return { ...defaultEntity }; +}; + +export const createMockProduct = ( + callback?: mockEntityRenderer, +): Product => { + const defaultProduct: Product = { + kind: 'compute-engine', + name: 'Compute Engine', + }; + if (typeof callback === 'function') { + return callback({ ...defaultProduct }); + } + return { ...defaultProduct }; +}; + +export const createMockProductCost = ( + callback?: mockEntityRenderer, +): ProductCost => { + const defaultProduct: ProductCost = { + entities: [], + aggregation: [0, 0], + }; + if (typeof callback === 'function') { + return callback({ ...defaultProduct }); + } + return { ...defaultProduct }; +}; + +export const createMockProjectGrowthAlert = ( + callback?: mockAlertRenderer, +): ProjectGrowthAlert => { + const defaultAlert: ProjectGrowthAlert = { + id: AlertType.ProjectGrowth, + project: 'test-project-growth-alert', + periodStart: '2019-10-01', + periodEnd: '2020-03-31', + aggregation: [670532.1, 970502.8], + change: { + ratio: 0.5, + amount: 180000, + }, + products: [], + }; + + if (typeof callback === 'function') { + return callback({ ...defaultAlert }); + } + + return { ...defaultAlert }; +}; + +export const createMockUnlabeledDataflowAlert = ( + callback?: mockAlertRenderer, +): UnlabeledDataflowAlert => { + const defaultAlert: UnlabeledDataflowAlert = { + id: AlertType.UnlabeledDataflow, + periodStart: '2020-05-01', + periodEnd: '2020-06-1', + projects: [], + unlabeledCost: 0, + labeledCost: 0, + }; + + if (typeof callback === 'function') { + return callback({ ...defaultAlert }); + } + + return { ...defaultAlert }; +}; + +export const createMockUnlabeledDataflowAlertProject = ( + callback?: mockEntityRenderer, +): UnlabeledDataflowAlertProject => { + const defaultProject: UnlabeledDataflowAlertProject = { + id: 'test-alert-project', + unlabeledCost: 2000.0, + labeledCost: 3200.0, + }; + if (typeof callback === 'function') { + return callback({ ...defaultProject }); + } + return { ...defaultProject }; +}; + +export const MockProductTypes: Record = { + 'compute-engine': 'Compute Engine', + 'cloud-dataflow': 'Cloud Dataflow', + 'cloud-storage': 'Cloud Storage', + 'big-query': 'Big Query', + 'big-table': 'BigTable', + 'cloud-pub-sub': 'Cloud Pub/Sub', +}; + +export const MockProductFilters = Object.keys( + MockProductTypes, +).map(productType => ({ duration: Duration.P1M, productType })); + +export const MockProducts: Product[] = Object.keys(MockProductTypes).map( + productType => + createMockProduct(() => ({ + kind: productType, + name: MockProductTypes[productType], + })), +); + +export const MockLoadingActions = ([ + DefaultLoadingAction.UserGroups, + DefaultLoadingAction.CostInsightsInitial, + DefaultLoadingAction.CostInsightsPage, +] as string[]).concat(MockProducts.map(product => product.kind)); + +export const mockDefaultState = getDefaultState(MockLoadingActions); + +export const MockComputeEngine = findAlways( + MockProducts, + p => p.kind === 'compute-engine', +); +export const MockCloudDataflow = findAlways( + MockProducts, + p => p.kind === 'cloud-dataflow', +); +export const MockCloudStorage = findAlways( + MockProducts, + p => p.kind === 'cloud-storage', +); +export const MockBigQuery = findAlways( + MockProducts, + p => p.kind === 'big-query', +); +export const MockBigtable = findAlways( + MockProducts, + p => p.kind === 'big-table', +); + +export const MockProductsConfig: Partial = { + keys: () => Object.keys(MockProductTypes), +}; + +export const MockMetricsConfig: Partial = { + getOptionalString: () => 'daily-cost', + keys: () => ['daily-cost'], +}; + +export const MockCostInsightsConfig: Partial = { + getConfig: () => MockProductsConfig as Config, + getOptionalConfig: () => MockMetricsConfig as Config, +}; diff --git a/plugins/cost-insights/src/utils/navigation.tsx b/plugins/cost-insights/src/utils/navigation.tsx new file mode 100644 index 0000000000..d2cb1d20bb --- /dev/null +++ b/plugins/cost-insights/src/utils/navigation.tsx @@ -0,0 +1,74 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 MoneyIcon from '@material-ui/icons/MonetizationOn'; +import ActionIcon from '@material-ui/icons/Whatshot'; +import Settings from '@material-ui/icons/Settings'; +import AccountTree from '@material-ui/icons/AccountTree'; +import Storage from '@material-ui/icons/Storage'; +import Search from '@material-ui/icons/Search'; +import CloudQueue from '@material-ui/icons/CloudQueue'; +import School from '@material-ui/icons/School'; +import ViewHeadline from '@material-ui/icons/ViewHeadline'; +import { IconType } from '../types'; + +export enum DefaultNavigation { + CostOverviewCard = 'cost-overview-card', + AlertInsightsHeader = 'alert-insights-header', +} + +export type NavigationItem = { + navigation: string; + icon: JSX.Element; + title: string; +}; + +export const getDefaultNavigationItems = (alerts: number): NavigationItem[] => { + const items = [ + { + navigation: DefaultNavigation.CostOverviewCard, + icon: , + title: 'Cost Overview', + }, + ]; + if (alerts > 0) { + items.push({ + navigation: DefaultNavigation.AlertInsightsHeader, + icon: , + title: 'Action Items', + }); + } + return items; +}; + +export function getIcon(icon?: string): JSX.Element { + switch (icon) { + case IconType.Compute: + return ; + case IconType.Data: + return ; + case IconType.Database: + return ; + case IconType.Storage: + return ; + case IconType.Search: + return ; + case IconType.ML: + return ; + default: + return ; + } +} diff --git a/plugins/cost-insights/src/utils/sort.test.ts b/plugins/cost-insights/src/utils/sort.test.ts new file mode 100644 index 0000000000..2f4ca87c7e --- /dev/null +++ b/plugins/cost-insights/src/utils/sort.test.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { aggregationSort } from './sort'; + +describe('aggregationSort', () => { + const aggregation = [ + { date: '2020-07-05', amount: 0.250991 }, + { date: '2020-07-06', amount: 0.253284 }, + { date: '2020-07-01', amount: 0.260798 }, + ]; + + it('sorts in ascending order', () => { + const sortedAggregation = aggregation.sort(aggregationSort); + expect(sortedAggregation[0].date).toBe('2020-07-01'); + expect(sortedAggregation[1].date).toBe('2020-07-05'); + expect(sortedAggregation[2].date).toBe('2020-07-06'); + }); +}); diff --git a/plugins/cost-insights/src/utils/sort.ts b/plugins/cost-insights/src/utils/sort.ts new file mode 100644 index 0000000000..944f5fc806 --- /dev/null +++ b/plugins/cost-insights/src/utils/sort.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { DateAggregation, ResourceData } from '../types'; + +export const aggregationSort = ( + a: DateAggregation, + b: DateAggregation, +): number => a.date.localeCompare(b.date); +export const resourceSort = (a: ResourceData, b: ResourceData) => + b.previous + b.current - (a.previous + a.current); diff --git a/plugins/cost-insights/src/utils/styles.ts b/plugins/cost-insights/src/utils/styles.ts new file mode 100644 index 0000000000..e6b38ee719 --- /dev/null +++ b/plugins/cost-insights/src/utils/styles.ts @@ -0,0 +1,455 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { + createStyles, + darken, + getLuminance, + lighten, + makeStyles, +} from '@material-ui/core'; +import { BackstageTheme } from '@backstage/theme'; +import { CostInsightsTheme, CostInsightsThemeOptions } from '../types'; + +export const costInsightsLightTheme = { + palette: { + blue: '#509AF5', + lightBlue: '#9BF0E1', + darkBlue: '#4101F5', + magenta: '#DC148C', + yellow: '#FFC864', + tooltip: { + background: '#171717', + color: '#DDD', + }, + navigationText: '#b5b5b5', + alertBackground: 'rgba(219, 219, 219, 0.13)', + }, +} as CostInsightsThemeOptions; + +// Higher tonal values from light theme (Google recommends 600 -> 200) +// https://material.io/design/color/the-color-system.html#tools-for-picking-colors +export const costInsightsDarkTheme = { + palette: { + blue: '#77b8f9', + lightBlue: '#d8f9f4', + darkBlue: '#b595fd', + magenta: '#ee93cd', + yellow: '#fff2da', + tooltip: { + background: '#EEE', + color: '#424242', + }, + navigationText: '#b5b5b5', + alertBackground: 'rgba(32, 32, 32, 0.13)', + }, +} as CostInsightsThemeOptions; + +// The opposite of MUI's emphasize function - darken darks, lighten lights +export function brighten(color: string, coefficient = 0.2) { + return getLuminance(color) > 0.5 + ? lighten(color, coefficient) + : darken(color, coefficient); +} + +export const useCostOverviewStyles = (theme: CostInsightsTheme) => ({ + axis: { + fill: theme.palette.text.primary, + }, + container: { + height: 450, + width: 1200, + }, + cartesianGrid: { + stroke: theme.palette.textVerySubtle, + }, + chart: { + margin: { + right: 30, + top: 16, + }, + }, + yAxis: { + width: 75, + }, +}); + +export const useBarChartStyles = (theme: CostInsightsTheme) => ({ + axis: { + fill: theme.palette.text.primary, + }, + barChart: { + margin: { + left: 16, + right: 16, + }, + }, + cartesianGrid: { + stroke: theme.palette.textVerySubtle, + }, + cursor: { + fill: theme.palette.textVerySubtle, + fillOpacity: 0.3, + }, + container: { + height: 400, + width: 1200, + }, + infoIcon: { + marginLeft: 2, + fontSize: '1.25em', + }, + xAxis: { + height: 50, + }, +}); + +export const useBarChartStepperButtonStyles = makeStyles( + (theme: CostInsightsTheme) => + createStyles({ + root: { + ...theme.typography.button, + boxSizing: 'border-box', + transition: theme.transitions.create( + ['background-color', 'box-shadow', 'border'], + { + duration: theme.transitions.duration.short, + }, + ), + borderRadius: '50%', + padding: 0, + width: 40, + height: 40, + boxShadow: theme.shadows[6], + '&:active': { + boxShadow: theme.shadows[12], + }, + color: theme.palette.text.primary, + backgroundColor: lighten(theme.palette.background.default, 0.1), + '&:hover': { + backgroundColor: lighten(theme.palette.background.default, 0.2), + textDecoration: 'none', + }, + }, + }), +); + +export const useBarChartLabelStyles = makeStyles(() => + createStyles({ + foreignObject: { + textAlign: 'center', + }, + label: { + display: 'block', + textDecoration: 'none', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + infoIcon: { + marginLeft: 2, + fontSize: '1.25em', + }, + }), +); + +export const useCostInsightsStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + h6Subtle: { + ...theme.typography.h6, + fontWeight: 'normal', + color: theme.palette.textSubtle, + }, + }), +); + +export const useCostInsightsTabsStyles = makeStyles( + (theme: BackstageTheme) => ({ + tabs: { + borderBottom: `1px solid ${theme.palette.textVerySubtle}`, + backgroundColor: brighten(theme.palette.background.default), + padding: theme.spacing(0, 4), + }, + tab: { + minHeight: 68, + minWidth: 180, + padding: theme.spacing(1, 4), + '&:hover': { + color: 'inherit', + backgroundColor: 'inherit', + }, + }, + indicator: { + backgroundColor: theme.palette.navigation.indicator, + height: 4, + }, + tabLabel: { + display: 'flex', + alignItems: 'center', + }, + tabLabelText: { + fontSize: theme.typography.fontSize, + fontWeight: theme.typography.fontWeightBold, + }, + menuItem: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + minWidth: 180, + padding: theme.spacing(2, 2), + }, + menuItemSelected: { + backgroundColor: lighten(theme.palette.background.default, 0.3), + }, + }), +); + +export const useAlertCardActionHeaderStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + root: { + paddingBottom: theme.spacing(2), + borderRadius: 'unset', + }, + title: { + fontSize: theme.typography.fontSize, + fontWeight: theme.typography.fontWeightBold, + lineHeight: 1.5, + }, + action: { + margin: 0, + }, + }), +); + +export const useCostGrowthStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + excess: { + color: theme.palette.status.error, + }, + savings: { + color: theme.palette.status.ok, + }, + }), +); + +export const useCostGrowthLegendStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + h5: { + ...theme.typography.h5, + fontWeight: 500, + padding: 0, + }, + marker: { + display: 'inherit', + marginRight: theme.spacing(1), + }, + helpIcon: { + display: 'inherit', + }, + title: { + ...theme.typography.overline, + fontWeight: 500, + lineHeight: 0, + marginRight: theme.spacing(1), + color: theme.palette.textSubtle, + }, + tooltip: { + display: 'block', + padding: theme.spacing(1), + backgroundColor: theme.palette.navigation.background, + }, + tooltipText: { + color: theme.palette.background.default, + fontSize: theme.typography.fontSize, + lineHeight: 1.5, + }, + }), +); + +export const useBarChartStepperStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + paper: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-around', + alignItems: 'center', + background: 'transparent', + padding: 8, + }, + step: { + backgroundColor: theme.palette.action.disabled, + borderRadius: '50%', + width: 9, + height: 9, + margin: '0 2px', + }, + stepActive: { + backgroundColor: theme.palette.primary.main, + }, + steps: { + display: 'flex', + flexDirection: 'row', + }, + backButton: { + position: 'absolute', + left: 0, + top: 'calc(50% - 60px)', + zIndex: 100, + }, + nextButton: { + position: 'absolute', + right: 0, + top: 'calc(50% - 60px)', + zIndex: 100, + }, + }), +); + +export const useNavigationStyles = makeStyles( + (theme: CostInsightsTheme) => + createStyles({ + menuList: { + borderRadius: theme.shape.borderRadius, + backgroundColor: theme.palette.navigation.background, + }, + menuItem: { + background: 'transparent', + border: 0, + textTransform: 'none', + width: '100%', + minHeight: theme.spacing(6), + margin: theme.spacing(0.5, 2, 0.5, 0), + }, + listItemIcon: { + minWidth: 40, + }, + navigationIcon: { + fill: theme.palette.navigationText, + }, + title: { + whiteSpace: 'nowrap', + lineHeight: 1, + color: theme.palette.navigationText, + fontWeight: theme.typography.fontWeightBold, + }, + }), +); + +export const useTooltipStyles = makeStyles( + (theme: CostInsightsTheme) => + createStyles({ + tooltip: { + padding: theme.spacing(1.5), + backgroundColor: theme.palette.tooltip.background, + borderRadius: theme.shape.borderRadius, + boxShadow: theme.shadows[1], + color: theme.palette.tooltip.color, + fontSize: theme.typography.fontSize, + }, + lensIcon: { + fontSize: `.75rem`, + }, + }), +); + +export const useAlertInsightsSectionStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + button: { + backgroundColor: theme.palette.textVerySubtle, + color: theme.palette.text.primary, + }, + }), +); + +export const useSelectStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + select: { + minWidth: 200, + textAlign: 'start', + }, + menuItem: { + minWidth: 200, + padding: theme.spacing(2), + '&.compact': { + padding: theme.spacing(1, 2), + }, + }, + }), +); + +export const useAlertActionCardStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + card: { + boxShadow: 'none', + }, + avatar: { + backgroundColor: theme.palette.textVerySubtle, + color: theme.palette.text.primary, + }, + }), +); + +export const useAlertActionCardHeader = makeStyles( + (theme: CostInsightsTheme) => + createStyles({ + root: { + paddingBottom: theme.spacing(2), + borderRadius: theme.shape.borderRadius, + cursor: 'pointer', + transition: theme.transitions.create('background', { + duration: theme.transitions.duration.short, + }), + '&:hover': { + background: theme.palette.alertBackground, + }, + }, + action: { + margin: 0, + }, + title: { + fontSize: theme.typography.fontSize, + fontWeight: theme.typography.fontWeightBold, + }, + }), +); + +export const useProductInsightsCardStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + root: { + padding: theme.spacing(2, 2, 2, 2.5), // restore backstage default padding + }, + action: { + margin: 0, // unset negative margins + }, + }), +); + +export const useBackdropStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + root: { + zIndex: theme.zIndex.modal, + }, + }), +); diff --git a/plugins/cost-insights/src/utils/tests.tsx b/plugins/cost-insights/src/utils/tests.tsx new file mode 100644 index 0000000000..807232555b --- /dev/null +++ b/plugins/cost-insights/src/utils/tests.tsx @@ -0,0 +1,86 @@ +/* + * Copyright 2020 Spotify AB + * + * 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, { Dispatch, ReactNode, SetStateAction } from 'react'; +import { + getDefaultPageFilters, + PageFilters, + ProductFilters, + Group, +} from '../types'; +import { FilterContext } from '../hooks/useFilters'; +import { ConfigContext, ConfigContextProps } from '../hooks/useConfig'; +import { CurrencyContext, CurrencyContextProps } from '../hooks/useCurrency'; +import { ScrollContext, ScrollProviderProps } from '../hooks/useScroll'; +import { MockProductFilters } from './mockData'; + +export const MockGroups: Group[] = [{ id: 'tech' }, { id: 'mock-group' }]; + +type MockFilterProviderProps = { + setPageFilters: Dispatch>; + setProductFilters: Dispatch>; + children: ReactNode; +}; + +export const MockFilterProvider = ({ + setPageFilters, + setProductFilters, + children, +}: MockFilterProviderProps) => { + const pageFilters = getDefaultPageFilters(MockGroups); + return ( + + {children} + + ); +}; + +export const MockConfigProvider = ({ + metrics, + products, + icons, + engineerCost, + currencies, + children, +}: ConfigContextProps & { children: React.ReactNode }) => ( + + {children} + +); + +export const MockCurrencyProvider = ({ + currency, + setCurrency, + children, +}: CurrencyContextProps & { children: React.ReactNode }) => ( + + {children} + +); + +export const MockScrollProvider = ({ children }: ScrollProviderProps) => ( + + {children} + +); diff --git a/plugins/explore/src/components/ExplorePluginPage.tsx b/plugins/explore/src/components/ExplorePluginPage.tsx index 4365157f62..83402d8062 100644 --- a/plugins/explore/src/components/ExplorePluginPage.tsx +++ b/plugins/explore/src/components/ExplorePluginPage.tsx @@ -79,6 +79,13 @@ const toolsCards = [ 'https://storage.googleapis.com/wf-blogs-engineering-media/2018/09/fe13bb32-wf-tech-radar-hero-1024x597.png', tags: ['standards', 'landscape'], }, + { + title: 'Cost Insights', + description: 'Insights into cloud costs for your organization.', + url: '/cost-insights', + image: 'https://cloud.google.com/images/press/logo-cloud.png', + tags: ['cloud', 'finops'], + }, { title: 'GraphiQL', description: diff --git a/yarn.lock b/yarn.lock index 804eb9dc10..c29734cc93 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3257,7 +3257,7 @@ react-transition-group "^4.0.0" rifm "^0.7.0" -"@material-ui/styles@^4.10.0": +"@material-ui/styles@^4.10.0", "@material-ui/styles@^4.9.6": version "4.10.0" resolved "https://registry.npmjs.org/@material-ui/styles/-/styles-4.10.0.tgz#2406dc23aa358217aa8cc772e6237bd7f0544071" integrity sha512-XPwiVTpd3rlnbfrgtEJ1eJJdFCXZkHxy8TrdieaTvwxNYj42VnnCyFzxYeNW9Lhj4V1oD8YtQ6S5Gie7bZDf7Q== @@ -4782,6 +4782,18 @@ resolved "https://registry.npmjs.org/@types/d3-force/-/d3-force-1.2.1.tgz#c28803ea36fe29788db69efa0ad6c2dc09544e83" integrity sha512-jqK+I36uz4kTBjyk39meed5y31Ab+tXYN/x1dn3nZEus9yOHCLc+VrcIYLc/aSQ0Y7tMPRlIhLetulME76EiiA== +"@types/d3-path@*": + version "1.0.8" + resolved "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.8.tgz#48e6945a8ff43ee0a1ce85c8cfa2337de85c7c79" + integrity sha512-AZGHWslq/oApTAHu9+yH/Bnk63y9oFOMROtqPAtxl5uB6qm1x2lueWdVEjsjjV3Qc2+QfuzKIwIR5MvVBakfzA== + +"@types/d3-shape@*": + version "1.3.2" + resolved "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.2.tgz#a41d9d6b10d02e221696b240caf0b5d0f5a588ec" + integrity sha512-LtD8EaNYCaBRzHzaAiIPrfcL3DdIysc81dkGlQvv7WQP3+YXV7b0JJTtR1U3bzeRieS603KF4wUo+ZkJVenh8w== + dependencies: + "@types/d3-path" "*" + "@types/diff@^4.0.2": version "4.0.2" resolved "https://registry.npmjs.org/@types/diff/-/diff-4.0.2.tgz#2e9bb89f9acc3ab0108f0f3dc4dbdcf2fff8a99c" @@ -5413,6 +5425,14 @@ dependencies: "@types/react" "*" +"@types/recharts@^1.8.14": + version "1.8.15" + resolved "https://registry.npmjs.org/@types/recharts/-/recharts-1.8.15.tgz#02bc06085c9a31a58c00194d15377b45cf506bbf" + integrity sha512-ApCfDT/R8RCbZTcl0iqLiKsxVdzE3GzoawTgJUHuQOz6ZXhsPVfp7CNSKI2s3zFwrRRsgmpv2AEcbcZceNHg4w== + dependencies: + "@types/d3-shape" "*" + "@types/react" "*" + "@types/recursive-readdir@^2.2.0": version "2.2.0" resolved "https://registry.npmjs.org/@types/recursive-readdir/-/recursive-readdir-2.2.0.tgz#b39cd5474fd58ea727fe434d5c68b7a20ba9121c" @@ -7213,6 +7233,11 @@ bail@^1.0.0: resolved "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz#b6fa133404a392cbc1f8c4bf63f5953351e7a776" integrity sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ== +balanced-match@^0.4.2: + version "0.4.2" + resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz#cb3f3e3c732dc0f01ee70b403f302e61d7709838" + integrity sha1-yz8+PHMtwPAe5wtAPzAuYddwmDg= + balanced-match@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" @@ -8763,7 +8788,7 @@ core-js-pure@^3.0.0, core-js-pure@^3.0.1: resolved "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.6.4.tgz#4bf1ba866e25814f149d4e9aaa08c36173506e3a" integrity sha512-epIhRLkXdgv32xIUFaaAry2wdxZYBi6bgM7cB136dzzXXa+dFyRLTZeLUJxnd8ShrmyVXBub63n2NHo2JAt8Cw== -core-js@^2.4.0, core-js@^2.4.1, core-js@^2.5.7, core-js@^2.6.11, core-js@^2.6.5: +core-js@^2.4.0, core-js@^2.4.1, core-js@^2.5.7, core-js@^2.6.10, core-js@^2.6.11, core-js@^2.6.5: version "2.6.11" resolved "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c" integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg== @@ -9281,6 +9306,21 @@ cypress@*, cypress@^4.2.0: url "^0.11.0" yauzl "^2.10.0" +d3-array@^1.2.0: + version "1.2.4" + resolved "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f" + integrity sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw== + +d3-collection@1: + version "1.0.7" + resolved "https://registry.npmjs.org/d3-collection/-/d3-collection-1.0.7.tgz#349bd2aa9977db071091c13144d5e4f16b5b310e" + integrity sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A== + +d3-color@1: + version "1.4.1" + resolved "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz#c52002bf8846ada4424d55d97982fef26eb3bc8a" + integrity sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q== + "d3-dispatch@1 - 2": version "2.0.0" resolved "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz#8a18e16f76dd3fcaef42163c97b926aa9b55e7cf" @@ -9295,11 +9335,59 @@ d3-force@^2.0.1: d3-quadtree "1 - 2" d3-timer "1 - 2" +d3-format@1: + version "1.4.5" + resolved "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz#374f2ba1320e3717eb74a9356c67daee17a7edb4" + integrity sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ== + +d3-interpolate@1, d3-interpolate@^1.3.0: + version "1.4.0" + resolved "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.4.0.tgz#526e79e2d80daa383f9e0c1c1c7dcc0f0583e987" + integrity sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA== + dependencies: + d3-color "1" + +d3-path@1: + version "1.0.9" + resolved "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf" + integrity sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg== + "d3-quadtree@1 - 2": version "2.0.0" resolved "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-2.0.0.tgz#edbad045cef88701f6fee3aee8e93fb332d30f9d" integrity sha512-b0Ed2t1UUalJpc3qXzKi+cPGxeXRr4KU9YSlocN74aTzp6R/Ud43t79yLLqxHRWZfsvWXmbDWPpoENK1K539xw== +d3-scale@^2.1.0: + version "2.2.2" + resolved "https://registry.npmjs.org/d3-scale/-/d3-scale-2.2.2.tgz#4e880e0b2745acaaddd3ede26a9e908a9e17b81f" + integrity sha512-LbeEvGgIb8UMcAa0EATLNX0lelKWGYDQiPdHj+gLblGVhGLyNbaCn3EvrJf0A3Y/uOOU5aD6MTh5ZFCdEwGiCw== + dependencies: + d3-array "^1.2.0" + d3-collection "1" + d3-format "1" + d3-interpolate "1" + d3-time "1" + d3-time-format "2" + +d3-shape@^1.2.0: + version "1.3.7" + resolved "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz#df63801be07bc986bc54f63789b4fe502992b5d7" + integrity sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw== + dependencies: + d3-path "1" + +d3-time-format@2: + version "2.3.0" + resolved "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.3.0.tgz#107bdc028667788a8924ba040faf1fbccd5a7850" + integrity sha512-guv6b2H37s2Uq/GefleCDtbe0XZAuy7Wa49VGkPVPMfLL9qObgBST3lEHJBMUp8S7NdLQAGIvr2KXk8Hc98iKQ== + dependencies: + d3-time "1" + +d3-time@1: + version "1.1.0" + resolved "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz#b1e19d307dae9c900b7e5b25ffc5dcc249a8a0f1" + integrity sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA== + "d3-timer@1 - 2": version "2.0.0" resolved "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz#055edb1d170cfe31ab2da8968deee940b56623e6" @@ -9426,6 +9514,11 @@ decamelize@^1.1.0, decamelize@^1.1.1, decamelize@^1.1.2, decamelize@^1.2.0: resolved "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= +decimal.js-light@^2.4.1: + version "2.5.0" + resolved "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.0.tgz#ca7faf504c799326df94b0ab920424fdfc125348" + integrity sha512-b3VJCbd2hwUpeRGG3Toob+CRo8W22xplipNhP3tN7TSVB/cyMX71P1vM2Xjc9H74uV6dS2hDDmo/rHq8L87Upg== + decimal.js@^10.2.0: version "10.2.0" resolved "https://registry.npmjs.org/decimal.js/-/decimal.js-10.2.0.tgz#39466113a9e036111d02f82489b5fd6b0b5ed231" @@ -9833,6 +9926,13 @@ dom-converter@^0.2: dependencies: utila "~0.4" +dom-helpers@^3.4.0: + version "3.4.0" + resolved "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8" + integrity sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA== + dependencies: + "@babel/runtime" "^7.1.2" + dom-helpers@^5.0.0, dom-helpers@^5.0.1: version "5.1.4" resolved "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.1.4.tgz#4609680ab5c79a45f2531441f1949b79d6587f4b" @@ -15096,7 +15196,7 @@ lodash.clonedeep@^4.5.0: resolved "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8= -lodash.debounce@^4: +lodash.debounce@^4, lodash.debounce@^4.0.8: version "4.0.8" resolved "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168= @@ -15191,6 +15291,11 @@ lodash.templatesettings@^4.0.0: dependencies: lodash._reinterpolate "^3.0.0" +lodash.throttle@^4.1.1: + version "4.1.1" + resolved "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4" + integrity sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ= + lodash.uniq@^4.5.0: version "4.5.0" resolved "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" @@ -15206,7 +15311,7 @@ lodash@4.17.15: resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== -lodash@^4.0.1, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.4, lodash@^4.2.1: +lodash@^4.0.1, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.1, lodash@~4.17.4: version "4.17.20" resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== @@ -15534,6 +15639,11 @@ material-table@1.68.0: react-beautiful-dnd "^13.0.0" react-double-scrollbar "0.0.15" +math-expression-evaluator@^1.2.14: + version "1.2.22" + resolved "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.2.22.tgz#c14dcb3d8b4d150e5dcea9c68c8dad80309b0d5e" + integrity sha512-L0j0tFVZBQQLeEjmWOvDLoRciIY8gQGWahvkztXUal8jH8R5Rlqo9GCvgqvXcy9LQhEWdQCVvzqAbxgYNt4blQ== + md5.js@^1.3.4: version "1.3.5" resolved "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" @@ -18612,7 +18722,7 @@ raf-schd@^4.0.2: resolved "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.2.tgz#bd44c708188f2e84c810bf55fcea9231bcaed8a0" integrity sha512-VhlMZmGy6A6hrkJWHLNTGl5gtgMUm+xfGza6wbwnE914yeQ5Ybm18vgM734RZhMgfw4tacUrWseGZlpUrrakEQ== -raf@^3.1.0, raf@^3.4.1: +raf@^3.1.0, raf@^3.4.0, raf@^3.4.1: version "3.4.1" resolved "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39" integrity sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA== @@ -19035,6 +19145,16 @@ react-redux@^7.1.1: prop-types "^15.7.2" react-is "^16.9.0" +react-resize-detector@^2.3.0: + version "2.3.0" + resolved "https://registry.npmjs.org/react-resize-detector/-/react-resize-detector-2.3.0.tgz#57bad1ae26a28a62a2ddb678ba6ffdf8fa2b599c" + integrity sha512-oCAddEWWeFWYH5FAcHdBYcZjAw9fMzRUK9sWSx6WvSSOPVRxcHd5zTIGy/mOus+AhN/u6T4TMiWxvq79PywnJQ== + dependencies: + lodash.debounce "^4.0.8" + lodash.throttle "^4.1.1" + prop-types "^15.6.0" + resize-observer-polyfill "^1.5.0" + react-router-dom@6.0.0-beta.0: version "6.0.0-beta.0" resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.0.0-beta.0.tgz#9dcc8555365f22f7fbd09f26b6b82543f3eb97d6" @@ -19094,6 +19214,16 @@ react-sizeme@^2.6.7: shallowequal "^1.1.0" throttle-debounce "^2.1.0" +react-smooth@^1.0.5: + version "1.0.5" + resolved "https://registry.npmjs.org/react-smooth/-/react-smooth-1.0.5.tgz#94ae161d7951cdd893ccb7099d031d342cb762ad" + integrity sha512-eW057HT0lFgCKh8ilr0y2JaH2YbNcuEdFpxyg7Gf/qDKk9hqGMyXryZJ8iMGJEuKH0+wxS0ccSsBBB3W8yCn8w== + dependencies: + lodash "~4.17.4" + prop-types "^15.6.0" + raf "^3.4.0" + react-transition-group "^2.5.0" + react-sparklines@^1.7.0: version "1.7.0" resolved "https://registry.npmjs.org/react-sparklines/-/react-sparklines-1.7.0.tgz#9b1d97e8c8610095eeb2ad658d2e1fcf91f91a60" @@ -19149,6 +19279,16 @@ react-textarea-autosize@^8.1.1: use-composed-ref "^1.0.0" use-latest "^1.0.0" +react-transition-group@^2.5.0: + version "2.9.0" + resolved "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz#df9cdb025796211151a436c69a8f3b97b5b07c8d" + integrity sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg== + dependencies: + dom-helpers "^3.4.0" + loose-envify "^1.4.0" + prop-types "^15.6.2" + react-lifecycles-compat "^3.0.4" + react-transition-group@^4.0.0, react-transition-group@^4.4.0: version "4.4.1" resolved "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz#63868f9325a38ea5ee9535d828327f85773345c9" @@ -19416,6 +19556,30 @@ recast@^0.14.7: private "~0.1.5" source-map "~0.6.1" +recharts-scale@^0.4.2: + version "0.4.3" + resolved "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.3.tgz#040b4f638ed687a530357292ecac880578384b59" + integrity sha512-t8p5sccG9Blm7c1JQK/ak9O8o95WGhNXD7TXg/BW5bYbVlr6eCeRBNpgyigD4p6pSSMehC5nSvBUPj6F68rbFA== + dependencies: + decimal.js-light "^2.4.1" + +recharts@^1.8.5: + version "1.8.5" + resolved "https://registry.npmjs.org/recharts/-/recharts-1.8.5.tgz#ca94a3395550946334a802e35004ceb2583fdb12" + integrity sha512-tM9mprJbXVEBxjM7zHsIy6Cc41oO/pVYqyAsOHLxlJrbNBuLs0PHB3iys2M+RqCF0//k8nJtZF6X6swSkWY3tg== + dependencies: + classnames "^2.2.5" + core-js "^2.6.10" + d3-interpolate "^1.3.0" + d3-scale "^2.1.0" + d3-shape "^1.2.0" + lodash "^4.17.5" + prop-types "^15.6.0" + react-resize-detector "^2.3.0" + react-smooth "^1.0.5" + recharts-scale "^0.4.2" + reduce-css-calc "^1.3.0" + rechoir@^0.6.2: version "0.6.2" resolved "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384" @@ -19454,6 +19618,22 @@ redent@^3.0.0: indent-string "^4.0.0" strip-indent "^3.0.0" +reduce-css-calc@^1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-1.3.0.tgz#747c914e049614a4c9cfbba629871ad1d2927716" + integrity sha1-dHyRTgSWFKTJz7umKYca0dKSdxY= + dependencies: + balanced-match "^0.4.2" + math-expression-evaluator "^1.2.14" + reduce-function-call "^1.0.1" + +reduce-function-call@^1.0.1: + version "1.0.3" + resolved "https://registry.npmjs.org/reduce-function-call/-/reduce-function-call-1.0.3.tgz#60350f7fb252c0a67eb10fd4694d16909971300f" + integrity sha512-Hl/tuV2VDgWgCSEeWMLwxLZqX7OK59eU1guxXsRKTAyeYimivsKdtcV4fu3r710tpG5GmDKDhQ0HSZLExnNmyQ== + dependencies: + balanced-match "^1.0.0" + redux-immutable@3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/redux-immutable/-/redux-immutable-3.1.0.tgz#cafbd686e0711261119b9c28960935dc47a49d0a" @@ -19781,7 +19961,7 @@ reselect@^4.0.0: resolved "https://registry.npmjs.org/reselect/-/reselect-4.0.0.tgz#f2529830e5d3d0e021408b246a206ef4ea4437f7" integrity sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA== -resize-observer-polyfill@^1.5.1: +resize-observer-polyfill@^1.5.0, resize-observer-polyfill@^1.5.1: version "1.5.1" resolved "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==