Merge pull request #3147 from spotify/ryanv/cost-insights/migrate-non-type-exports

Migrate type utilities for Cost Insights
This commit is contained in:
Ryan Vazquez
2020-10-30 15:17:05 -04:00
committed by GitHub
46 changed files with 487 additions and 356 deletions
@@ -0,0 +1,5 @@
---
'@backstage/plugin-cost-insights': patch
---
migrate type utilities out of type definition files
+1 -1
View File
@@ -106,7 +106,7 @@ costInsights:
## Alerts
The CostInsightsApi `getAlerts` method may return any type of alert or recommendation (called collectively "Action Items" in Cost Insights) that implements the [Alert type](https://github.com/spotify/backstage/blob/master/plugins/cost-insights/src/types/Alert.tsx). This allows you to deliver any alerts or recommendations specific to your infrastructure or company migrations.
The CostInsightsApi `getAlerts` method may return any type of alert or recommendation (called collectively "Action Items" in Cost Insights) that implements the [Alert type](https://github.com/spotify/backstage/blob/master/plugins/cost-insights/src/types/Alert.ts). This allows you to deliver any alerts or recommendations specific to your infrastructure or company migrations.
The Alert type includes an `element` field to supply the JSX Element that will be rendered in the Cost Insights "Action Items" section; we recommend using Backstage's [InfoCard](https://backstage.io/storybook/?path=/story/layout-information-card--default) and [Recharts](http://recharts.org/en-US/) to show actionable visualizations.
+2 -2
View File
@@ -15,8 +15,8 @@
*/
import { createDevApp } from '@backstage/dev-utils';
import { createPlugin, createApiFactory } from '@backstage/core';
import { ExampleCostInsightsClient } from '../src/api';
import { costInsightsApiRef } from '../src';
import { costInsightsApiRef } from '../src/api';
import { ExampleCostInsightsClient } from '../src/client';
import { pluginConfig } from '../src/plugin';
const devPlugin = createPlugin({
-1
View File
@@ -15,4 +15,3 @@
*/
export * from './CostInsightsApi';
export * from './ExampleCostInsightsClient';
@@ -17,7 +17,7 @@
import dayjs from 'dayjs';
import regression, { DataPoint } from 'regression';
import { CostInsightsApi } from './CostInsightsApi';
import { CostInsightsApi } from '../src/api';
import {
Alert,
ChangeStatistic,
@@ -25,19 +25,23 @@ import {
DateAggregation,
DEFAULT_DATE_FORMAT,
Duration,
exclusiveEndDateOf,
Group,
inclusiveStartDateOf,
Maybe,
MetricData,
ProductCost,
Project,
ProjectGrowthAlert,
ProjectGrowthData,
Trendline,
UnlabeledDataflowAlert,
UnlabeledDataflowData,
} from '../types';
} from '../src/types';
import {
ProjectGrowthAlert,
UnlabeledDataflowAlert,
} from '../src/utils/alerts';
import {
exclusiveEndDateOf,
inclusiveStartDateOf,
} from '../src/utils/duration';
type IntervalFields = {
duration: Duration;
@@ -17,8 +17,9 @@
import React from 'react';
import { renderInTestApp } from '@backstage/test-utils';
import { AlertActionCard } from './AlertActionCard';
import { ProjectGrowthAlert, ProjectGrowthData } from '../../types';
import { MockScrollProvider } from '../../utils/tests';
import { ProjectGrowthAlert } from '../../utils/alerts';
import { ProjectGrowthData } from '../../types';
const data: ProjectGrowthData = {
aggregation: [500000.8, 970502.8],
@@ -36,11 +36,11 @@ import { currencyFormatter } from '../../utils/formatters';
import {
BarChartData,
Maybe,
notEmpty,
ResourceData,
DataKey,
CostInsightsTheme,
} from '../../types';
import { notEmpty } from '../../utils/assert';
import { useBarChartStyles } from '../../utils/styles';
import { resourceSort } from '../../utils/sort';
@@ -17,14 +17,10 @@
import React, { PropsWithChildren } from 'react';
import { renderInTestApp } from '@backstage/test-utils';
import { CostGrowth } from './CostGrowth';
import {
defaultCurrencies,
Currency,
CurrencyType,
Duration,
findAlways,
} from '../../types';
import { Currency, CurrencyType, Duration } from '../../types';
import { findAlways } from '../../utils/assert';
import { MockConfigProvider, MockCurrencyProvider } from '../../utils/tests';
import { defaultCurrencies } from '../../utils/currency';
const engineers = findAlways(defaultCurrencies, c => c.kind === null);
const usd = findAlways(defaultCurrencies, c => c.kind === CurrencyType.USD);
@@ -21,10 +21,10 @@ import {
CurrencyType,
Duration,
EngineerThreshold,
Growth,
growthOf,
rateOf,
GrowthType,
} from '../../types';
import { rateOf } from '../../utils/currency';
import { growthOf } from '../../utils/change';
import { useCostGrowthStyles as useStyles } from '../../utils/styles';
import { formatPercent, formatCurrency } from '../../utils/formatters';
import { indefiniteArticleOf } from '../../utils/grammar';
@@ -51,8 +51,8 @@ export const CostGrowth = ({ change, duration }: CostGrowthProps) => {
// 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,
[styles.excess]: growth === GrowthType.Excess,
[styles.savings]: growth === GrowthType.Savings,
});
const percent = formatPercent(ratio);
@@ -25,7 +25,7 @@ import {
} from '@material-ui/core';
import { useNavigationStyles } from '../../utils/styles';
import { useConfig, useScroll } from '../../hooks';
import { findAlways } from '../../types';
import { findAlways } from '../../utils/assert';
import {
DefaultNavigation,
NavigationItem,
@@ -41,16 +41,10 @@ import {
useLastCompleteBillingDate,
useLoading,
} from '../../hooks';
import {
Alert,
Cost,
intervalsOf,
Maybe,
MetricData,
Project,
} from '../../types';
import { Alert, Cost, Maybe, MetricData, Project } from '../../types';
import { mapLoadingToProps } from './selector';
import { ProjectSelect } from '../ProjectSelect';
import { intervalsOf } from '../../utils/duration';
import { useSubtleTypographyStyles } from '../../utils/styles';
export const CostInsightsPage = () => {
@@ -18,7 +18,7 @@ import {
getResetState,
DefaultLoadingAction,
getResetStateWithoutInitial,
} from '../../types';
} from '../../utils/loading';
type CostInsightsPageLoadingProps = {
loadingActions: Array<string>;
@@ -16,7 +16,8 @@
import { MapFiltersToProps } from '../../hooks/useFilters';
import { MapLoadingToProps } from '../../hooks/useLoading';
import { Group, PageFilters, getResetStateWithoutInitial } from '../../types';
import { Group, PageFilters } from '../../types';
import { getResetStateWithoutInitial } from '../../utils/loading';
type CostInsightsTabsFilterProps = PageFilters & {
setGroup: (group: Group) => void;
@@ -26,13 +26,9 @@ import { useScroll, useFilters, useConfig } from '../../hooks';
import { mapFiltersToProps } from './selector';
import { DefaultNavigation } from '../../utils/navigation';
import { formatPercent } from '../../utils/formatters';
import {
Cost,
CostInsightsTheme,
MetricData,
findAlways,
getComparedChange,
} from '../../types';
import { findAlways } from '../../utils/assert';
import { getComparedChange } from '../../utils/change';
import { Cost, CostInsightsTheme, MetricData } from '../../types';
export type CostOverviewCardProps = {
dailyCostData: Cost;
@@ -16,7 +16,8 @@
import React from 'react';
import { MenuItem, Select, SelectProps } from '@material-ui/core';
import { Currency, CurrencyType, findAlways } from '../../types';
import { Currency, CurrencyType } from '../../types';
import { findAlways } from '../../utils/assert';
import { useSelectStyles as useStyles } from '../../utils/styles';
const NULL_VALUE = 'engineers';
@@ -19,8 +19,9 @@ import { getByRole, waitFor } from '@testing-library/react';
import { renderInTestApp } from '@backstage/test-utils';
import UserEvent from '@testing-library/user-event';
import { PeriodSelect, getDefaultOptions } from './PeriodSelect';
import { Duration, getDefaultPageFilters, Group } from '../../types';
import { getDefaultPageFilters } from '../../utils/filters';
import { MockBillingDateProvider } from '../../utils/tests';
import { Group, Duration } from '../../types';
const DefaultPageFilters = getDefaultPageFilters([{ id: 'tools' }] as Group[]);
const lastCompleteBillingDate = '2020-05-01';
@@ -16,11 +16,12 @@
import React from 'react';
import { MenuItem, Select, SelectProps } from '@material-ui/core';
import { Duration } from '../../types';
import {
formatLastTwoLookaheadQuarters,
formatLastTwoMonths,
} from '../../utils/formatters';
import { Duration, findAlways } from '../../types';
import { findAlways } from '../../utils/assert';
import { useSelectStyles as useStyles } from '../../utils/styles';
import { useLastCompleteBillingDate } from '../../hooks';
@@ -16,7 +16,8 @@
import { MapFiltersToProps } from '../../hooks/useFilters';
import { MapLoadingToProps } from '../../hooks/useLoading';
import { Duration, PageFilters, ProductPeriod, findAlways } from '../../types';
import { Duration, PageFilters, ProductPeriod } from '../../types';
import { findAlways } from '../../utils/assert';
type ProductInsightsCardFilterProps = PageFilters & {
product: ProductPeriod;
@@ -23,7 +23,9 @@ import {
MockConfigProvider,
MockBillingDateProvider,
} from '../../utils/tests';
import { AlertCost, defaultCurrencies, findAlways } from '../../types';
import { AlertCost } from '../../types';
import { defaultCurrencies } from '../../utils/currency';
import { findAlways } from '../../utils/assert';
const engineers = findAlways(defaultCurrencies, c => c.kind === null);
@@ -23,9 +23,9 @@ import {
Duration,
Entity,
Product,
ProjectGrowthAlert,
ProjectGrowthData,
} from '../../types';
import { ProjectGrowthAlert } from '../../utils/alerts';
import { ResourceGrowthBarChartLegend } from '../ResourceGrowthBarChartLegend';
import { ResourceGrowthBarChart } from '../ResourceGrowthBarChart';
@@ -17,8 +17,10 @@
import React, { PropsWithChildren } from 'react';
import { renderInTestApp } from '@backstage/test-utils';
import { ResourceGrowthBarChartLegend } from './ResourceGrowthBarChartLegend';
import { defaultCurrencies, Duration, findAlways } from '../../types';
import { defaultCurrencies } from '../../utils/currency';
import { findAlways } from '../../utils/assert';
import { MockConfigProvider, MockCurrencyProvider } from '../../utils/tests';
import { Duration } from '../../types';
const engineers = findAlways(defaultCurrencies, c => c.kind === null);
@@ -23,9 +23,10 @@ import React, {
} from 'react';
import { configApiRef, useApi } from '@backstage/core';
import { Config as BackstageConfig } from '@backstage/config';
import { Currency, defaultCurrencies, Icon, Metric, Product } from '../types';
import { Currency, Icon, Metric, Product } from '../types';
import { getIcon } from '../utils/navigation';
import { validateMetrics } from '../utils/config';
import { defaultCurrencies } from '../utils/currency';
/*
* Config schema 2020-10-15
@@ -20,7 +20,9 @@ import React, {
useContext,
PropsWithChildren,
} from 'react';
import { Currency, defaultCurrencies, findAlways } from '../types';
import { Currency } from '../types';
import { findAlways } from '../utils/assert';
import { defaultCurrencies } from '../utils/currency';
export type CurrencyContextProps = {
currency: Currency;
@@ -24,7 +24,8 @@ import { Alert } from '@material-ui/lab';
import { useApi, identityApiRef } from '@backstage/core';
import { costInsightsApiRef } from '../api';
import { MapLoadingToProps, useLoading } from './useLoading';
import { DefaultLoadingAction, Group, Maybe } from '../types';
import { Group, Maybe } from '../types';
import { DefaultLoadingAction } from '../utils/loading';
type GroupsProviderLoadingProps = {
dispatchLoadingGroups: (isLoading: boolean) => void;
@@ -25,7 +25,8 @@ import { Alert } from '@material-ui/lab';
import { useApi } from '@backstage/core';
import { costInsightsApiRef } from '../api';
import { MapLoadingToProps, useLoading } from './useLoading';
import { DefaultLoadingAction, Maybe } from '../types';
import { DefaultLoadingAction } from '../utils/loading';
import { Maybe } from '../types';
type BillingDateProviderLoadingProps = {
dispatchLoadingBillingDate: (isLoading: boolean) => void;
@@ -26,12 +26,12 @@ import React, {
useState,
} from 'react';
import { Backdrop, CircularProgress } from '@material-ui/core';
import { Loading } from '../types';
import {
DefaultLoadingAction,
getDefaultState,
getLoadingActions,
Loading,
} from '../types';
} from '../utils/loading';
import { useBackdropStyles as useStyles } from '../utils/styles';
import { useConfig } from './useConfig';
+2
View File
@@ -15,8 +15,10 @@
*/
export { plugin } from './plugin';
export * from './client';
export * from './api';
export * from './components';
export { useCurrency } from './hooks';
export * from './types';
export * from './utils/tests';
export * from './utils/duration';
@@ -14,11 +14,8 @@
* limitations under the License.
*/
import React from 'react';
import { ChangeStatistic } from './ChangeStatistic';
import { Maybe } from './Maybe';
import { UnlabeledDataflowAlertCard } from '../components/UnlabeledDataflowAlertCard';
import { ProjectGrowthAlertCard } from '../components/ProjectGrowthAlertCard';
/**
* Generic alert type with required fields for display. The `element` field will be rendered in
@@ -57,11 +54,6 @@ export enum DataKey {
Name = 'name',
}
/**
* The alerts below are examples of Alert implementation; the CostInsightsApi permits returning
* any implementation of the Alert type, so adopters can create their own. The CostInsightsApi
* fetches alert data from the backend, then creates Alert classes with the data.
*/
export interface ProjectGrowthData {
project: string;
periodStart: string;
@@ -71,26 +63,6 @@ export interface ProjectGrowthData {
products: Array<AlertCost>;
}
export class ProjectGrowthAlert implements Alert {
data: ProjectGrowthData;
constructor(data: ProjectGrowthData) {
this.data = data;
}
get title() {
return `Investigate cost growth in project ${this.data.project}`;
}
subtitle =
'Cost growth outpacing business growth is unsustainable long-term.';
url = '/cost-insights/investigating-growth';
get element() {
return <ProjectGrowthAlertCard alert={this.data} />;
}
}
export interface UnlabeledDataflowData {
periodStart: string;
periodEnd: string;
@@ -99,23 +71,6 @@ export interface UnlabeledDataflowData {
labeledCost: number;
}
export class UnlabeledDataflowAlert implements Alert {
data: UnlabeledDataflowData;
constructor(data: UnlabeledDataflowData) {
this.data = data;
}
title = 'Add labels to workflows';
subtitle =
'Labels show in billing data, enabling cost insights for each workflow.';
url = '/cost-insights/labeling-jobs';
get element() {
return <UnlabeledDataflowAlertCard alert={this.data} />;
}
}
export interface UnlabeledDataflowAlertProject {
id: string;
unlabeledCost: number;
@@ -14,10 +14,6 @@
* limitations under the License.
*/
import { Cost } from './Cost';
import { MetricData } from './MetricData';
import { aggregationSort } from '../utils/sort';
export interface ChangeStatistic {
// The ratio of change from one duration to another, expressed as: (newSum - oldSum) / oldSum
ratio: number;
@@ -32,34 +28,8 @@ export enum ChangeThreshold {
lower = -0.05,
}
export enum Growth {
export enum GrowthType {
Negligible,
Savings,
Excess,
}
// Used by <CostGrowth /> 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;
}
// Used by <CostOverviewCard /> for displaying engineer totals
export function getComparedChange(
dailyCost: Cost,
metricData: MetricData,
): ChangeStatistic {
const ratio = dailyCost.change.ratio - metricData.change.ratio;
const amount = dailyCost.aggregation.slice().sort(aggregationSort)[0].amount;
return {
ratio: ratio,
amount: amount * ratio,
};
}
+6 -54
View File
@@ -13,15 +13,6 @@
* 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;
@@ -31,48 +22,9 @@ export interface Currency {
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,
},
];
export enum CurrencyType {
USD = 'USD',
CarbonOffsetTons = 'CARBON_OFFSET_TONS',
Beers = 'BEERS',
IceCream = 'PINTS_OF_ICE_CREAM',
}
@@ -14,9 +14,6 @@
* 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
@@ -31,72 +28,3 @@ export enum Duration {
}
export const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD';
/**
* Derive the start date of a given period, assuming two repeating intervals.
*
* @param duration see comment on Duration enum
* @param endDate from CostInsightsApi.getLastCompleteBillingDate
*/
export function inclusiveStartDateOf(
duration: Duration,
endDate: string,
): string {
switch (duration) {
case Duration.P30D:
case Duration.P90D:
return moment(endDate)
.utc()
.subtract(moment.duration(duration).add(moment.duration(duration)))
.format(DEFAULT_DATE_FORMAT);
case Duration.P1M:
return moment(endDate)
.utc()
.startOf('month')
.subtract(moment.duration(duration).add(moment.duration(duration)))
.format(DEFAULT_DATE_FORMAT);
case Duration.P3M:
return moment(endDate)
.utc()
.startOf('quarter')
.subtract(moment.duration(duration).add(moment.duration(duration)))
.format(DEFAULT_DATE_FORMAT);
default:
return assertNever(duration);
}
}
export function exclusiveEndDateOf(
duration: Duration,
endDate: string,
): string {
switch (duration) {
case Duration.P30D:
case Duration.P90D:
return moment(endDate).utc().add(1, 'day').format(DEFAULT_DATE_FORMAT);
case Duration.P1M:
return moment(endDate).utc().startOf('month').format(DEFAULT_DATE_FORMAT);
case Duration.P3M:
return moment(endDate)
.utc()
.startOf('quarter')
.format(DEFAULT_DATE_FORMAT);
default:
return assertNever(duration);
}
}
export function inclusiveEndDateOf(
duration: Duration,
endDate: string,
): string {
return moment(exclusiveEndDateOf(duration, endDate))
.utc()
.subtract(1, 'day')
.format(DEFAULT_DATE_FORMAT);
}
// https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals
export function intervalsOf(duration: Duration, endDate: string) {
return `R2/${duration}/${exclusiveEndDateOf(duration, endDate)}`;
}
@@ -16,7 +16,6 @@
import { Maybe } from './Maybe';
import { Duration } from './Duration';
import { Group } from './Group';
export interface PageFilters {
group: Maybe<string>;
@@ -31,12 +30,3 @@ 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,
};
}
@@ -15,50 +15,3 @@
*/
export type Loading = Record<string, boolean>;
export enum DefaultLoadingAction {
UserGroups = 'user-groups',
LastCompleteBillingDate = 'billing-date',
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);
}
+1 -29
View File
@@ -13,33 +13,5 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export type Maybe<T> = T | null;
export function notEmpty<TValue>(
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<T>(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<T>(
collection: T[],
callback: (el: T) => boolean,
): T {
return assertAlways(collection.find(callback));
}
@@ -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.
*/
import React from 'react';
import { Alert, UnlabeledDataflowData, ProjectGrowthData } from '../types';
import { UnlabeledDataflowAlertCard } from '../components/UnlabeledDataflowAlertCard';
import { ProjectGrowthAlertCard } from '../components/ProjectGrowthAlertCard';
/**
* The alerts below are examples of Alert implementation; the CostInsightsApi permits returning
* any implementation of the Alert type, so adopters can create their own. The CostInsightsApi
* fetches alert data from the backend, then creates Alert classes with the data.
*/
export class UnlabeledDataflowAlert implements Alert {
data: UnlabeledDataflowData;
constructor(data: UnlabeledDataflowData) {
this.data = data;
}
title = 'Add labels to workflows';
subtitle =
'Labels show in billing data, enabling cost insights for each workflow.';
url = '/cost-insights/labeling-jobs';
get element() {
return <UnlabeledDataflowAlertCard alert={this.data} />;
}
}
export class ProjectGrowthAlert implements Alert {
data: ProjectGrowthData;
constructor(data: ProjectGrowthData) {
this.data = data;
}
get title() {
return `Investigate cost growth in project ${this.data.project}`;
}
subtitle =
'Cost growth outpacing business growth is unsustainable long-term.';
url = '/cost-insights/investigating-growth';
get element() {
return <ProjectGrowthAlertCard alert={this.data} />;
}
}
+44
View File
@@ -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.
*/
export function notEmpty<TValue>(
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<T>(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<T>(
collection: T[],
callback: (el: T) => boolean,
): T {
return assertAlways(collection.find(callback));
}
+51
View File
@@ -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 {
Cost,
ChangeStatistic,
ChangeThreshold,
EngineerThreshold,
GrowthType,
MetricData,
} from '../types';
import { aggregationSort } from '../utils/sort';
// Used by <CostGrowth /> for displaying status colors
export function growthOf(amount: number, ratio: number) {
if (amount >= EngineerThreshold && ratio >= ChangeThreshold.upper) {
return GrowthType.Excess;
}
if (amount >= EngineerThreshold && ratio <= ChangeThreshold.lower) {
return GrowthType.Savings;
}
return GrowthType.Negligible;
}
// Used by <CostOverviewCard /> for displaying engineer totals
export function getComparedChange(
dailyCost: Cost,
metricData: MetricData,
): ChangeStatistic {
const ratio = dailyCost.change.ratio - metricData.change.ratio;
const amount = dailyCost.aggregation.slice().sort(aggregationSort)[0].amount;
return {
ratio: ratio,
amount: amount * ratio,
};
}
@@ -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.
*/
import { Currency, CurrencyType, Duration } from '../types';
import { assertNever } from '../utils/assert';
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,
},
];
@@ -14,7 +14,8 @@
* limitations under the License.
*/
import { Duration, inclusiveEndDateOf, inclusiveStartDateOf } from './Duration';
import { Duration } from '../types';
import { inclusiveEndDateOf, inclusiveStartDateOf } from './duration';
const lastCompleteBillingDate = '2020-06-05';
@@ -0,0 +1,88 @@
/*
* 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, DEFAULT_DATE_FORMAT } from '../types';
import { assertNever } from './assert';
/**
* Derive the start date of a given period, assuming two repeating intervals.
*
* @param duration see comment on Duration enum
* @param endDate from CostInsightsApi.getLastCompleteBillingDate
*/
export function inclusiveStartDateOf(
duration: Duration,
endDate: string,
): string {
switch (duration) {
case Duration.P30D:
case Duration.P90D:
return moment(endDate)
.utc()
.subtract(moment.duration(duration).add(moment.duration(duration)))
.format(DEFAULT_DATE_FORMAT);
case Duration.P1M:
return moment(endDate)
.utc()
.startOf('month')
.subtract(moment.duration(duration).add(moment.duration(duration)))
.format(DEFAULT_DATE_FORMAT);
case Duration.P3M:
return moment(endDate)
.utc()
.startOf('quarter')
.subtract(moment.duration(duration).add(moment.duration(duration)))
.format(DEFAULT_DATE_FORMAT);
default:
return assertNever(duration);
}
}
export function exclusiveEndDateOf(
duration: Duration,
endDate: string,
): string {
switch (duration) {
case Duration.P30D:
case Duration.P90D:
return moment(endDate).utc().add(1, 'day').format(DEFAULT_DATE_FORMAT);
case Duration.P1M:
return moment(endDate).utc().startOf('month').format(DEFAULT_DATE_FORMAT);
case Duration.P3M:
return moment(endDate)
.utc()
.startOf('quarter')
.format(DEFAULT_DATE_FORMAT);
default:
return assertNever(duration);
}
}
export function inclusiveEndDateOf(
duration: Duration,
endDate: string,
): string {
return moment(exclusiveEndDateOf(duration, endDate))
.utc()
.subtract(1, 'day')
.format(DEFAULT_DATE_FORMAT);
}
// https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals
export function intervalsOf(duration: Duration, endDate: string) {
return `R2/${duration}/${exclusiveEndDateOf(duration, endDate)}`;
}
@@ -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 { Duration, Group, PageFilters } from '../types';
export function getDefaultPageFilters(groups: Group[]): PageFilters {
return {
group: groups.length ? groups[0].id : null,
project: null,
duration: Duration.P90D,
metric: null,
};
}
@@ -15,11 +15,8 @@
*/
import moment from 'moment';
import {
Duration,
inclusiveEndDateOf,
inclusiveStartDateOf,
} from '../types/Duration';
import { Duration } from '../types';
import { inclusiveEndDateOf, inclusiveStartDateOf } from '../utils/duration';
import { pluralOf } from '../utils/grammar';
export type Period = {
+2 -1
View File
@@ -16,7 +16,8 @@
import qs from 'qs';
import * as yup from 'yup';
import { Group, PageFilters, getDefaultPageFilters, Duration } from '../types';
import { Duration, Group, PageFilters } from '../types';
import { getDefaultPageFilters } from '../utils/filters';
import { ConfigContextProps } from '../hooks/useConfig';
const schema = yup
@@ -0,0 +1,64 @@
/*
* 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 { Loading } from '../types';
export enum DefaultLoadingAction {
UserGroups = 'user-groups',
LastCompleteBillingDate = 'billing-date',
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);
}
+7 -5
View File
@@ -14,12 +14,11 @@
* limitations under the License.
*/
import { Config } from '@backstage/config';
import { ConfigApi } from '@backstage/core';
import {
DefaultLoadingAction,
Duration,
Entity,
findAlways,
getDefaultState as getDefaultLoadingState,
Product,
ProductCost,
ProductFilters,
@@ -27,8 +26,11 @@ import {
UnlabeledDataflowAlertProject,
UnlabeledDataflowData,
} from '../types';
import { Config } from '@backstage/config';
import { ConfigApi } from '@backstage/core';
import {
DefaultLoadingAction,
getDefaultState as getDefaultLoadingState,
} from '../utils/loading';
import { findAlways } from '../utils/assert';
type mockAlertRenderer<T> = (alert: T) => T;
type mockEntityRenderer<T> = (entity: T) => T;
+1 -1
View File
@@ -32,7 +32,7 @@ import {
BillingDateContextProps,
} from '../hooks/useLastCompleteBillingDate';
import { ScrollContext, ScrollContextProps } from '../hooks/useScroll';
import { Duration, Group } from '../types';
import { Group, Duration } from '../types';
/*
Mock Providers and types are exposed publicly to allow users to test custom implementations