Merge branch 'continue_dependencies_deprecations' of github.com:Znarvl/backstage into continue_dependencies_deprecations
This commit is contained in:
@@ -79,7 +79,7 @@ describe('assignGroupsToUsers', () => {
|
||||
spec: {
|
||||
type: 'team',
|
||||
children: [],
|
||||
members: ['u1', 'u2'],
|
||||
members: ['default/u1', 'default/u2'],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
import {
|
||||
DEFAULT_NAMESPACE,
|
||||
GroupEntity,
|
||||
parseEntityRef,
|
||||
stringifyEntityRef,
|
||||
UserEntity,
|
||||
} from '@backstage/catalog-model';
|
||||
|
||||
@@ -65,14 +67,20 @@ export function assignGroupsToUsers(
|
||||
group.metadata.namespace !== DEFAULT_NAMESPACE
|
||||
? `${group.metadata.namespace}/${group.metadata.name}`
|
||||
: group.metadata.name;
|
||||
return [groupKey, group.spec.members || []];
|
||||
// Fully qualify member refs so they can be keyed off of since they may contain namespace prefixes
|
||||
return [
|
||||
groupKey,
|
||||
group.spec.members?.map(m =>
|
||||
stringifyEntityRef(parseEntityRef(m, { defaultKind: 'user' })),
|
||||
) || [],
|
||||
];
|
||||
}),
|
||||
);
|
||||
|
||||
const usersByName = new Map(users.map(u => [u.metadata.name, u]));
|
||||
for (const [groupName, userNames] of groupMemberUsers.entries()) {
|
||||
for (const userName of userNames) {
|
||||
const user = usersByName.get(userName);
|
||||
const usersByRef = new Map(users.map(u => [stringifyEntityRef(u), u]));
|
||||
for (const [groupName, userRefs] of groupMemberUsers.entries()) {
|
||||
for (const ref of userRefs) {
|
||||
const user = usersByRef.get(ref);
|
||||
if (user && !user.spec.memberOf?.includes(groupName)) {
|
||||
if (!user.spec.memberOf) {
|
||||
user.spec.memberOf = [];
|
||||
|
||||
+38
-9
@@ -14,7 +14,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { GroupEntity } from '@backstage/catalog-model';
|
||||
import {
|
||||
DEFAULT_NAMESPACE,
|
||||
GroupEntity,
|
||||
stringifyEntityRef,
|
||||
UserEntity,
|
||||
} from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import {
|
||||
DefaultGithubCredentialsProvider,
|
||||
@@ -36,6 +41,7 @@ import {
|
||||
assignGroupsToUsers,
|
||||
buildOrgHierarchy,
|
||||
defaultOrganizationTeamTransformer,
|
||||
defaultUserTransformer,
|
||||
getOrganizationTeams,
|
||||
getOrganizationUsers,
|
||||
GithubMultiOrgConfig,
|
||||
@@ -141,8 +147,19 @@ export class GithubMultiOrgReaderProcessor implements CatalogProcessor {
|
||||
client,
|
||||
orgConfig.name,
|
||||
tokenType,
|
||||
this.options.userTransformer,
|
||||
async (githubUser, ctx): Promise<UserEntity | undefined> => {
|
||||
const result = this.options.userTransformer
|
||||
? await this.options.userTransformer(githubUser, ctx)
|
||||
: await defaultUserTransformer(githubUser, ctx);
|
||||
|
||||
if (result) {
|
||||
result.metadata.namespace = orgConfig.userNamespace;
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
||||
const { groups } = await getOrganizationTeams(
|
||||
client,
|
||||
orgConfig.name,
|
||||
@@ -153,6 +170,13 @@ export class GithubMultiOrgReaderProcessor implements CatalogProcessor {
|
||||
|
||||
if (result) {
|
||||
result.metadata.namespace = orgConfig.groupNamespace;
|
||||
// Group `spec.members` inherits the namespace of it's group so need to explicitly specify refs here
|
||||
result.spec.members = team.members.map(
|
||||
user =>
|
||||
`${orgConfig.userNamespace ?? DEFAULT_NAMESPACE}/${
|
||||
user.login
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -164,15 +188,18 @@ export class GithubMultiOrgReaderProcessor implements CatalogProcessor {
|
||||
`Read ${users.length} GitHub users and ${groups.length} GitHub teams from ${orgConfig.name} in ${duration} seconds`,
|
||||
);
|
||||
|
||||
let prefix: string = orgConfig.userNamespace ?? '';
|
||||
if (prefix.length > 0) prefix += '/';
|
||||
|
||||
users.forEach(u => {
|
||||
if (!allUsersMap.has(prefix + u.metadata.name)) {
|
||||
allUsersMap.set(prefix + u.metadata.name, u);
|
||||
// Grab current users from `allUsersMap` if they already exist in our
|
||||
// pending users so we can append to their group membership relations
|
||||
const pendingUsers = users.map(u => {
|
||||
const userRef = stringifyEntityRef(u);
|
||||
if (!allUsersMap.has(userRef)) {
|
||||
allUsersMap.set(userRef, u);
|
||||
}
|
||||
|
||||
return allUsersMap.get(userRef);
|
||||
});
|
||||
assignGroupsToUsers(users, groups);
|
||||
|
||||
assignGroupsToUsers(pendingUsers, groups);
|
||||
buildOrgHierarchy(groups);
|
||||
|
||||
for (const group of groups) {
|
||||
@@ -185,6 +212,8 @@ export class GithubMultiOrgReaderProcessor implements CatalogProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
// Emit all users at the end after all orgs have been processed
|
||||
// so all memberships across org groups are accounted for
|
||||
const allUsers = Array.from(allUsersMap.values());
|
||||
for (const user of allUsers) {
|
||||
emit(processingResult.entity(location, user));
|
||||
|
||||
+20
-4
@@ -85,7 +85,10 @@ describe('GithubOrgReaderProcessor', () => {
|
||||
mockClient
|
||||
.mockResolvedValueOnce({
|
||||
organization: {
|
||||
membersWithRole: { pageInfo: { hasNextPage: false }, nodes: [{}] },
|
||||
membersWithRole: {
|
||||
pageInfo: { hasNextPage: false },
|
||||
nodes: [{ login: 'foo' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
@@ -93,7 +96,12 @@ describe('GithubOrgReaderProcessor', () => {
|
||||
teams: {
|
||||
pageInfo: { hasNextPage: false },
|
||||
nodes: [
|
||||
{ members: { pageInfo: { hasNextPage: false }, nodes: [{}] } },
|
||||
{
|
||||
members: {
|
||||
pageInfo: { hasNextPage: false },
|
||||
nodes: [{ login: 'foo' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -134,7 +142,10 @@ describe('GithubOrgReaderProcessor', () => {
|
||||
mockClient
|
||||
.mockResolvedValueOnce({
|
||||
organization: {
|
||||
membersWithRole: { pageInfo: { hasNextPage: false }, nodes: [{}] },
|
||||
membersWithRole: {
|
||||
pageInfo: { hasNextPage: false },
|
||||
nodes: [{ login: 'foo' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
@@ -142,7 +153,12 @@ describe('GithubOrgReaderProcessor', () => {
|
||||
teams: {
|
||||
pageInfo: { hasNextPage: false },
|
||||
nodes: [
|
||||
{ members: { pageInfo: { hasNextPage: false }, nodes: [{}] } },
|
||||
{
|
||||
members: {
|
||||
pageInfo: { hasNextPage: false },
|
||||
nodes: [{ login: 'foo' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
permissionsServiceRef,
|
||||
urlReaderServiceRef,
|
||||
httpRouterServiceRef,
|
||||
lifecycleServiceRef,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { CatalogBuilder } from './CatalogBuilder';
|
||||
import {
|
||||
@@ -78,6 +79,7 @@ export const catalogPlugin = createBackendPlugin({
|
||||
permissions: permissionsServiceRef,
|
||||
database: databaseServiceRef,
|
||||
httpRouter: httpRouterServiceRef,
|
||||
lifecycle: lifecycleServiceRef,
|
||||
},
|
||||
async init({
|
||||
logger,
|
||||
@@ -86,6 +88,7 @@ export const catalogPlugin = createBackendPlugin({
|
||||
database,
|
||||
permissions,
|
||||
httpRouter,
|
||||
lifecycle,
|
||||
}) {
|
||||
const winstonLogger = loggerToWinstonLogger(logger);
|
||||
const builder = await CatalogBuilder.create({
|
||||
@@ -100,7 +103,11 @@ export const catalogPlugin = createBackendPlugin({
|
||||
const { processingEngine, router } = await builder.build();
|
||||
|
||||
await processingEngine.start();
|
||||
|
||||
lifecycle.addShutdownHook({
|
||||
fn: async () => {
|
||||
await processingEngine.stop();
|
||||
},
|
||||
});
|
||||
httpRouter.use(router);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"@material-ui/core": "^4.9.10",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "^4.0.0-alpha.57",
|
||||
"rc-progress": "3.4.0",
|
||||
"rc-progress": "3.4.1",
|
||||
"react-use": "^17.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -178,6 +178,21 @@ costInsights:
|
||||
name: Metric C
|
||||
```
|
||||
|
||||
### Base Currency (Optional)
|
||||
|
||||
In the case you would like to show your baseline costs on the graph on other currency than US dollars.
|
||||
|
||||
```yaml
|
||||
## ./app-config.yaml
|
||||
costInsights:
|
||||
engineerCost: 200000
|
||||
baseCurrency:
|
||||
locale: nl-NL
|
||||
options:
|
||||
currency: EUR
|
||||
minimumFractionDigits: 3
|
||||
```
|
||||
|
||||
### Currencies (Optional)
|
||||
|
||||
In the `Cost Overview` panel, users can choose from a dropdown of currencies to see costs in, such as Engineers or USD. Currencies must be defined as keys on the `currencies` field. A user-friendly label and unit are **required**. If not set, the `defaultCurrencies` in `currency.ts` will be used.
|
||||
|
||||
@@ -232,6 +232,7 @@ export type ChartData = {
|
||||
|
||||
// @public (undocumented)
|
||||
export type ConfigContextProps = {
|
||||
baseCurrency: Intl.NumberFormat;
|
||||
metrics: Metric[];
|
||||
products: Product[];
|
||||
icons: Icon[];
|
||||
|
||||
Vendored
+52
@@ -21,6 +21,58 @@ export interface Config {
|
||||
*/
|
||||
engineerCost: number;
|
||||
|
||||
/**
|
||||
* @visibility frontend
|
||||
*/
|
||||
baseCurrency?: {
|
||||
/**
|
||||
* @visibility frontend
|
||||
*/
|
||||
locale?: string;
|
||||
options?: {
|
||||
/**
|
||||
* @visibility frontend
|
||||
*/
|
||||
localeMatcher?: string | undefined;
|
||||
/**
|
||||
* @visibility frontend
|
||||
*/
|
||||
style?: string | undefined;
|
||||
/**
|
||||
* @visibility frontend
|
||||
*/
|
||||
currency?: string | undefined;
|
||||
/**
|
||||
* @visibility frontend
|
||||
*/
|
||||
currencySign?: string | undefined;
|
||||
/**
|
||||
* @visibility frontend
|
||||
*/
|
||||
useGrouping?: boolean | undefined;
|
||||
/**
|
||||
* @visibility frontend
|
||||
*/
|
||||
minimumIntegerDigits?: number | undefined;
|
||||
/**
|
||||
* @visibility frontend
|
||||
*/
|
||||
minimumFractionDigits?: number | undefined;
|
||||
/**
|
||||
* @visibility frontend
|
||||
*/
|
||||
maximumFractionDigits?: number | undefined;
|
||||
/**
|
||||
* @visibility frontend
|
||||
*/
|
||||
minimumSignificantDigits?: number | undefined;
|
||||
/**
|
||||
* @visibility frontend
|
||||
*/
|
||||
maximumSignificantDigits?: number | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
products?: {
|
||||
[kind: string]: {
|
||||
/**
|
||||
|
||||
@@ -18,7 +18,7 @@ import React from 'react';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { BarChart, BarChartProps } from './BarChart';
|
||||
import { ResourceData } from '../../types';
|
||||
import { createMockEntity } from '../../testUtils';
|
||||
import { createMockEntity, MockConfigProvider } from '../../testUtils';
|
||||
import { resourceSort } from '../../utils/sort';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
|
||||
@@ -46,11 +46,13 @@ const renderWithProps = ({
|
||||
resources = MockResources,
|
||||
}: BarChartProps) => {
|
||||
return renderInTestApp(
|
||||
<BarChart
|
||||
responsive={responsive}
|
||||
displayAmount={displayAmount}
|
||||
resources={resources}
|
||||
/>,
|
||||
<MockConfigProvider>
|
||||
<BarChart
|
||||
responsive={responsive}
|
||||
displayAmount={displayAmount}
|
||||
resources={resources}
|
||||
/>
|
||||
</MockConfigProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -40,20 +40,24 @@ import { notEmpty } from '../../utils/assert';
|
||||
import { useBarChartStyles } from '../../utils/styles';
|
||||
import { resourceSort } from '../../utils/sort';
|
||||
import { isInvalid, titleOf, tooltipItemOf } from '../../utils/graphs';
|
||||
import { TooltipRenderer } from '../../types/Tooltip';
|
||||
import { TooltipRenderer } from '../../types';
|
||||
import { useConfig } from '../../hooks';
|
||||
|
||||
export const defaultTooltip: TooltipRenderer = ({ label, payload = [] }) => {
|
||||
if (isInvalid({ label, payload })) return null;
|
||||
const defaultTooltip = (baseCurrency: Intl.NumberFormat) => {
|
||||
const tooltip: TooltipRenderer = ({ label, payload = [] }) => {
|
||||
if (isInvalid({ label, payload })) return null;
|
||||
|
||||
const title = titleOf(label);
|
||||
const items = payload.map(tooltipItemOf).filter(notEmpty);
|
||||
return (
|
||||
<BarChartTooltip title={title}>
|
||||
{items.map((item, index) => (
|
||||
<BarChartTooltipItem key={`${item.label}-${index}`} item={item} />
|
||||
))}
|
||||
</BarChartTooltip>
|
||||
);
|
||||
const title = titleOf(label);
|
||||
const items = payload.map(tooltipItemOf(baseCurrency)).filter(notEmpty);
|
||||
return (
|
||||
<BarChartTooltip title={title}>
|
||||
{items.map((item, index) => (
|
||||
<BarChartTooltipItem key={`${item.label}-${index}`} item={item} />
|
||||
))}
|
||||
</BarChartTooltip>
|
||||
);
|
||||
};
|
||||
return tooltip;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
@@ -69,12 +73,14 @@ export type BarChartProps = {
|
||||
|
||||
/** @public */
|
||||
export const BarChart = (props: BarChartProps) => {
|
||||
const { baseCurrency } = useConfig();
|
||||
|
||||
const {
|
||||
resources,
|
||||
responsive = true,
|
||||
displayAmount = 6,
|
||||
options = {},
|
||||
tooltip = defaultTooltip,
|
||||
tooltip = defaultTooltip(baseCurrency),
|
||||
onClick,
|
||||
onMouseMove,
|
||||
} = props;
|
||||
@@ -164,7 +170,7 @@ export const BarChart = (props: BarChartProps) => {
|
||||
tick={BarChartTick}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={currencyFormatter.format}
|
||||
tickFormatter={currencyFormatter(baseCurrency).format}
|
||||
domain={[() => 0, globalResourcesMax]}
|
||||
tick={styles.axis}
|
||||
/>
|
||||
|
||||
@@ -17,11 +17,14 @@
|
||||
import React from 'react';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { BarChartLegend } from './BarChartLegend';
|
||||
import { MockConfigProvider } from '../../testUtils';
|
||||
|
||||
describe('<BarChartLegend />', () => {
|
||||
it(`Should display the correct cost start and end`, async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<BarChartLegend costStart={1000} costEnd={5000} />,
|
||||
<MockConfigProvider>
|
||||
<BarChartLegend costStart={1000} costEnd={5000} />,
|
||||
</MockConfigProvider>,
|
||||
);
|
||||
expect(rendered.getByText(/\$1,000/)).toBeInTheDocument();
|
||||
expect(rendered.queryByText(/\$5,000/)).toBeInTheDocument();
|
||||
|
||||
@@ -20,6 +20,7 @@ import { LegendItem } from '../LegendItem';
|
||||
import { currencyFormatter } from '../../utils/formatters';
|
||||
import { CostInsightsTheme } from '../../types';
|
||||
import { useBarChartLayoutStyles as useStyles } from '../../utils/styles';
|
||||
import { useConfig } from '../../hooks';
|
||||
|
||||
/** @public */
|
||||
export type BarChartLegendOptions = {
|
||||
@@ -45,6 +46,7 @@ export const BarChartLegend = (
|
||||
|
||||
const theme = useTheme<CostInsightsTheme>();
|
||||
const classes = useStyles();
|
||||
const { baseCurrency } = useConfig();
|
||||
|
||||
const data = Object.assign(
|
||||
{
|
||||
@@ -63,7 +65,7 @@ export const BarChartLegend = (
|
||||
title={data.previousName}
|
||||
markerColor={options.hideMarker ? undefined : data.previousFill}
|
||||
>
|
||||
{currencyFormatter.format(costStart)}
|
||||
{currencyFormatter(baseCurrency).format(costStart)}
|
||||
</LegendItem>
|
||||
</Box>
|
||||
<Box marginRight={2}>
|
||||
@@ -71,7 +73,7 @@ export const BarChartLegend = (
|
||||
title={data.currentName}
|
||||
markerColor={options.hideMarker ? undefined : data.currentFill}
|
||||
>
|
||||
{currencyFormatter.format(costEnd)}
|
||||
{currencyFormatter(baseCurrency).format(costEnd)}
|
||||
</LegendItem>
|
||||
</Box>
|
||||
{children}
|
||||
|
||||
+15
-13
@@ -16,42 +16,42 @@
|
||||
import React, { useState } from 'react';
|
||||
import { DateTime } from 'luxon';
|
||||
import {
|
||||
useTheme,
|
||||
Box,
|
||||
Typography,
|
||||
Divider,
|
||||
emphasize,
|
||||
Typography,
|
||||
useTheme,
|
||||
} from '@material-ui/core';
|
||||
import { default as FullScreenIcon } from '@material-ui/icons/Fullscreen';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip as RechartsTooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip as RechartsTooltip,
|
||||
Area,
|
||||
ResponsiveContainer,
|
||||
CartesianGrid,
|
||||
} from 'recharts';
|
||||
import { DEFAULT_DATE_FORMAT, CostInsightsTheme } from '../../types';
|
||||
import { Cost } from '@backstage/plugin-cost-insights-common';
|
||||
import {
|
||||
BarChartLegend,
|
||||
BarChartTooltip as Tooltip,
|
||||
BarChartTooltipItem as TooltipItem,
|
||||
BarChartLegend,
|
||||
} from '../BarChart';
|
||||
import {
|
||||
overviewGraphTickFormatter,
|
||||
formatGraphValue,
|
||||
isInvalid,
|
||||
overviewGraphTickFormatter,
|
||||
} from '../../utils/graphs';
|
||||
import { useCostOverviewStyles as useStyles } from '../../utils/styles';
|
||||
import { useFilters, useLastCompleteBillingDate } from '../../hooks';
|
||||
import { useConfig, useFilters, useLastCompleteBillingDate } from '../../hooks';
|
||||
import { mapFiltersToProps } from './selector';
|
||||
import { getPreviousPeriodTotalCost } from '../../utils/change';
|
||||
import { formatPeriod } from '../../utils/formatters';
|
||||
import { aggregationSum } from '../../utils/sum';
|
||||
import { BarChartLegendOptions } from '../BarChart/BarChartLegend';
|
||||
import { TooltipRenderer } from '../../types/Tooltip';
|
||||
import { BarChartLegendOptions } from '../BarChart';
|
||||
import { TooltipRenderer } from '../../types';
|
||||
|
||||
export type CostOverviewBreakdownChartProps = {
|
||||
costBreakdown: Cost[];
|
||||
@@ -66,6 +66,7 @@ export const CostOverviewBreakdownChart = ({
|
||||
}: CostOverviewBreakdownChartProps) => {
|
||||
const theme = useTheme<CostInsightsTheme>();
|
||||
const classes = useStyles(theme);
|
||||
const { baseCurrency } = useConfig();
|
||||
const lastCompleteBillingDate = useLastCompleteBillingDate();
|
||||
const { duration } = useFilters(mapFiltersToProps);
|
||||
const [isExpanded, setExpanded] = useState(false);
|
||||
@@ -186,9 +187,10 @@ export const CostOverviewBreakdownChart = ({
|
||||
? DateTime.fromMillis(label)
|
||||
: DateTime.fromISO(label!);
|
||||
const dateTitle = date.toUTC().toFormat(DEFAULT_DATE_FORMAT);
|
||||
const formatGraphValueWith = formatGraphValue(baseCurrency);
|
||||
const items = payload.map((p, i) => ({
|
||||
label: p.dataKey as string,
|
||||
value: formatGraphValue(Number(p.value), i),
|
||||
value: formatGraphValueWith(Number(p.value), i),
|
||||
fill: p.color!,
|
||||
}));
|
||||
const expandText = (
|
||||
@@ -254,7 +256,7 @@ export const CostOverviewBreakdownChart = ({
|
||||
<YAxis
|
||||
domain={[() => 0, 'dataMax']}
|
||||
tick={{ fill: classes.axis.fill }}
|
||||
tickFormatter={formatGraphValue}
|
||||
tickFormatter={formatGraphValue(baseCurrency)}
|
||||
width={classes.yAxis.width}
|
||||
/>
|
||||
{renderAreas()}
|
||||
|
||||
@@ -46,7 +46,8 @@ import { useCostOverviewStyles as useStyles } from '../../utils/styles';
|
||||
import { groupByDate, toDataMax, trendFrom } from '../../utils/charts';
|
||||
import { aggregationSort } from '../../utils/sort';
|
||||
import { CostOverviewLegend } from './CostOverviewLegend';
|
||||
import { TooltipRenderer } from '../../types/Tooltip';
|
||||
import { TooltipRenderer } from '../../types';
|
||||
import { useConfig } from '../../hooks';
|
||||
|
||||
type CostOverviewChartProps = {
|
||||
metric: Maybe<Metric>;
|
||||
@@ -63,6 +64,7 @@ export const CostOverviewChart = ({
|
||||
}: CostOverviewChartProps) => {
|
||||
const theme = useTheme<CostInsightsTheme>();
|
||||
const styles = useStyles(theme);
|
||||
const { baseCurrency } = useConfig();
|
||||
|
||||
const data = {
|
||||
dailyCost: {
|
||||
@@ -104,6 +106,7 @@ export const CostOverviewChart = ({
|
||||
? DateTime.fromMillis(label)
|
||||
: DateTime.fromISO(label!);
|
||||
const title = date.toUTC().toFormat(DEFAULT_DATE_FORMAT);
|
||||
const formatGraphValueWith = formatGraphValue(baseCurrency);
|
||||
const items = payload
|
||||
.filter(p => dataKeys.includes(p.dataKey as string))
|
||||
.map((p, i) => ({
|
||||
@@ -113,8 +116,8 @@ export const CostOverviewChart = ({
|
||||
: data.metric.name,
|
||||
value:
|
||||
p.dataKey === data.dailyCost.dataKey
|
||||
? formatGraphValue(Number(p.value), i, data.dailyCost.format)
|
||||
: formatGraphValue(Number(p.value), i, data.metric.format),
|
||||
? formatGraphValueWith(Number(p.value), i, data.dailyCost.format)
|
||||
: formatGraphValueWith(Number(p.value), i, data.metric.format),
|
||||
fill:
|
||||
p.dataKey === data.dailyCost.dataKey
|
||||
? theme.palette.blue
|
||||
@@ -155,7 +158,7 @@ export const CostOverviewChart = ({
|
||||
<YAxis
|
||||
domain={[() => 0, 'dataMax']}
|
||||
tick={{ fill: styles.axis.fill }}
|
||||
tickFormatter={formatGraphValue}
|
||||
tickFormatter={formatGraphValue(baseCurrency)}
|
||||
width={styles.yAxis.width}
|
||||
yAxisId={data.dailyCost.dataKey}
|
||||
/>
|
||||
|
||||
+15
-10
@@ -19,6 +19,7 @@ import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { ProductEntityDialog } from './ProductEntityDialog';
|
||||
import { render } from '@testing-library/react';
|
||||
import { Entity } from '@backstage/plugin-cost-insights-common';
|
||||
import { MockConfigProvider } from '../../testUtils';
|
||||
|
||||
const atomicEntity: Entity = {
|
||||
id: null,
|
||||
@@ -86,11 +87,13 @@ describe('<ProductEntityDialog/>', () => {
|
||||
it('Should show a tab for a single sub-entity type', () => {
|
||||
const { getByText } = render(
|
||||
wrapInTestApp(
|
||||
<ProductEntityDialog
|
||||
open
|
||||
entity={singleBreakdownEntity}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
<MockConfigProvider>
|
||||
<ProductEntityDialog
|
||||
open
|
||||
entity={singleBreakdownEntity}
|
||||
onClose={jest.fn()}
|
||||
/>
|
||||
</MockConfigProvider>,
|
||||
),
|
||||
);
|
||||
expect(getByText('Breakdown by SKU')).toBeInTheDocument();
|
||||
@@ -99,11 +102,13 @@ describe('<ProductEntityDialog/>', () => {
|
||||
it('Should show tabs when multiple sub-entity types exist', () => {
|
||||
const { getByText } = render(
|
||||
wrapInTestApp(
|
||||
<ProductEntityDialog
|
||||
open
|
||||
entity={multiBreakdownEntity}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
<MockConfigProvider>
|
||||
<ProductEntityDialog
|
||||
open
|
||||
entity={multiBreakdownEntity}
|
||||
onClose={jest.fn()}
|
||||
/>
|
||||
</MockConfigProvider>,
|
||||
),
|
||||
);
|
||||
expect(getByText('Breakdown by SKU')).toBeInTheDocument();
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { Typography } from '@material-ui/core';
|
||||
import { costFormatter, formatChange } from '../../utils/formatters';
|
||||
import { formatChange } from '../../utils/formatters';
|
||||
import { useEntityDialogStyles as useStyles } from '../../utils/styles';
|
||||
import { CostGrowthIndicator } from '../CostGrowth';
|
||||
import { BarChartOptions } from '../../types';
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Entity,
|
||||
} from '@backstage/plugin-cost-insights-common';
|
||||
import { Table, TableColumn } from '@backstage/core-components';
|
||||
import { useConfig } from '../../hooks';
|
||||
|
||||
export type ProductEntityTableOptions = Partial<
|
||||
Pick<BarChartOptions, 'previousName' | 'currentName'>
|
||||
@@ -39,36 +40,38 @@ type RowData = {
|
||||
change: ChangeStatistic;
|
||||
};
|
||||
|
||||
function createRenderer(col: keyof RowData, classes: Record<string, string>) {
|
||||
return function render(rowData: {}): JSX.Element {
|
||||
const row = rowData as RowData;
|
||||
const rowStyles = classnames(classes.row, {
|
||||
[classes.rowTotal]: row.id === 'total',
|
||||
[classes.colFirst]: col === 'label',
|
||||
[classes.colLast]: col === 'change',
|
||||
});
|
||||
const createRenderer =
|
||||
(baseCurrency: Intl.NumberFormat) =>
|
||||
(col: keyof RowData, classes: Record<string, string>) => {
|
||||
return function render(rowData: {}): JSX.Element {
|
||||
const row = rowData as RowData;
|
||||
const rowStyles = classnames(classes.row, {
|
||||
[classes.rowTotal]: row.id === 'total',
|
||||
[classes.colFirst]: col === 'label',
|
||||
[classes.colLast]: col === 'change',
|
||||
});
|
||||
|
||||
switch (col) {
|
||||
case 'previous':
|
||||
case 'current':
|
||||
return (
|
||||
<Typography className={rowStyles}>
|
||||
{costFormatter.format(row[col])}
|
||||
</Typography>
|
||||
);
|
||||
case 'change':
|
||||
return (
|
||||
<CostGrowthIndicator
|
||||
className={rowStyles}
|
||||
change={row.change}
|
||||
formatter={formatChange}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <Typography className={rowStyles}>{row.label}</Typography>;
|
||||
}
|
||||
switch (col) {
|
||||
case 'previous':
|
||||
case 'current':
|
||||
return (
|
||||
<Typography className={rowStyles}>
|
||||
{baseCurrency.format(row[col])}
|
||||
</Typography>
|
||||
);
|
||||
case 'change':
|
||||
return (
|
||||
<CostGrowthIndicator
|
||||
className={rowStyles}
|
||||
change={row.change}
|
||||
formatter={formatChange}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <Typography className={rowStyles}>{row.label}</Typography>;
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// material-table does not support fixed rows. Override the sorting algorithm
|
||||
// to force Total row to bottom by default or when a user sort toggles a column.
|
||||
@@ -103,6 +106,7 @@ export const ProductEntityTable = ({
|
||||
options,
|
||||
}: ProductEntityTableProps) => {
|
||||
const classes = useStyles();
|
||||
const { baseCurrency } = useConfig();
|
||||
const entities = entity.entities[entityLabel];
|
||||
|
||||
const data = Object.assign(
|
||||
@@ -120,7 +124,7 @@ export const ProductEntityTable = ({
|
||||
{
|
||||
field: 'label',
|
||||
title: <Typography className={firstColClasses}>{entityLabel}</Typography>,
|
||||
render: createRenderer('label', classes),
|
||||
render: createRenderer(baseCurrency)('label', classes),
|
||||
customSort: createSorter('label'),
|
||||
width: '33.33%',
|
||||
},
|
||||
@@ -130,7 +134,7 @@ export const ProductEntityTable = ({
|
||||
<Typography className={classes.column}>{data.previousName}</Typography>
|
||||
),
|
||||
align: 'right',
|
||||
render: createRenderer('previous', classes),
|
||||
render: createRenderer(baseCurrency)('previous', classes),
|
||||
customSort: createSorter('previous'),
|
||||
},
|
||||
{
|
||||
@@ -139,14 +143,14 @@ export const ProductEntityTable = ({
|
||||
<Typography className={classes.column}>{data.currentName}</Typography>
|
||||
),
|
||||
align: 'right',
|
||||
render: createRenderer('current', classes),
|
||||
render: createRenderer(baseCurrency)('current', classes),
|
||||
customSort: createSorter('current'),
|
||||
},
|
||||
{
|
||||
field: 'change',
|
||||
title: <Typography className={lastColClasses}>Change</Typography>,
|
||||
align: 'right',
|
||||
render: createRenderer('change', classes),
|
||||
render: createRenderer(baseCurrency)('change', classes),
|
||||
customSort: createSorter('change'),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -51,7 +51,8 @@ import {
|
||||
import { Duration } from '../../types';
|
||||
import { Entity, Maybe } from '@backstage/plugin-cost-insights-common';
|
||||
import { choose } from '../../utils/change';
|
||||
import { TooltipRenderer } from '../../types/Tooltip';
|
||||
import { TooltipRenderer } from '../../types';
|
||||
import { useConfig } from '../../hooks';
|
||||
|
||||
export type ProductInsightsChartProps = {
|
||||
billingDate: string;
|
||||
@@ -66,6 +67,7 @@ export const ProductInsightsChart = ({
|
||||
}: ProductInsightsChartProps) => {
|
||||
const classes = useStyles();
|
||||
const layoutClasses = useLayoutStyles();
|
||||
const { baseCurrency } = useConfig();
|
||||
|
||||
// Only a single entities Record for the root product entity is supported
|
||||
const entities = useMemo(() => {
|
||||
@@ -132,7 +134,7 @@ export const ProductInsightsChart = ({
|
||||
const id = label === '' ? null : label;
|
||||
|
||||
const title = titleOf(label);
|
||||
const items = payload.map(tooltipItemOf).filter(notEmpty);
|
||||
const items = payload.map(tooltipItemOf(baseCurrency)).filter(notEmpty);
|
||||
|
||||
const activeEntity = findAlways(entities, e => e.id === id);
|
||||
const breakdowns = Object.keys(activeEntity.entities);
|
||||
|
||||
+11
-6
@@ -19,6 +19,7 @@ import { UnlabeledDataflowAlertCard } from './UnlabeledDataflowAlertCard';
|
||||
import {
|
||||
createMockUnlabeledDataflowData,
|
||||
createMockUnlabeledDataflowAlertProject,
|
||||
MockConfigProvider,
|
||||
} from '../../testUtils';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
|
||||
@@ -61,9 +62,11 @@ describe('<UnlabeledDataflowAlertCard />', () => {
|
||||
'projects with unlabeled Dataflow jobs in the last 30 days.',
|
||||
);
|
||||
const rendered = await renderInTestApp(
|
||||
<UnlabeledDataflowAlertCard
|
||||
alert={MockUnlabeledDataflowAlertMultipleProjects}
|
||||
/>,
|
||||
<MockConfigProvider>
|
||||
<UnlabeledDataflowAlertCard
|
||||
alert={MockUnlabeledDataflowAlertMultipleProjects}
|
||||
/>
|
||||
</MockConfigProvider>,
|
||||
);
|
||||
expect(rendered.getByText(subheader)).toBeInTheDocument();
|
||||
});
|
||||
@@ -71,9 +74,11 @@ describe('<UnlabeledDataflowAlertCard />', () => {
|
||||
it('renders the correct subheader for a single project', async () => {
|
||||
const subheader = new RegExp('1 project');
|
||||
const rendered = await renderInTestApp(
|
||||
<UnlabeledDataflowAlertCard
|
||||
alert={MockUnlabeledDataflowAlertSingleProject}
|
||||
/>,
|
||||
<MockConfigProvider>
|
||||
<UnlabeledDataflowAlertCard
|
||||
alert={MockUnlabeledDataflowAlertSingleProject}
|
||||
/>
|
||||
</MockConfigProvider>,
|
||||
);
|
||||
expect(rendered.getByText(subheader)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ import { Config as BackstageConfig } from '@backstage/config';
|
||||
import { Currency, Icon, Metric, Product } from '../types';
|
||||
import { getIcon } from '../utils/navigation';
|
||||
import { validateCurrencies, validateMetrics } from '../utils/config';
|
||||
import { defaultCurrencies } from '../utils/currency';
|
||||
import { createCurrencyFormat, defaultCurrencies } from '../utils/currency';
|
||||
import { configApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
|
||||
/*
|
||||
@@ -46,6 +46,11 @@ import { configApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
* default: true
|
||||
* metricB:
|
||||
* name: Metric B
|
||||
* baseCurrency:
|
||||
* locale: nl-NL
|
||||
* options:
|
||||
* currency: EUR
|
||||
* minimumFractionDigits: 3
|
||||
* currencies:
|
||||
* currencyA:
|
||||
* label: Currency A
|
||||
@@ -60,6 +65,7 @@ import { configApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
|
||||
/** @public */
|
||||
export type ConfigContextProps = {
|
||||
baseCurrency: Intl.NumberFormat;
|
||||
metrics: Metric[];
|
||||
products: Product[];
|
||||
icons: Icon[];
|
||||
@@ -72,6 +78,7 @@ export const ConfigContext = createContext<ConfigContextProps | undefined>(
|
||||
);
|
||||
|
||||
const defaultState: ConfigContextProps = {
|
||||
baseCurrency: createCurrencyFormat(),
|
||||
metrics: [],
|
||||
products: [],
|
||||
icons: [],
|
||||
@@ -110,6 +117,42 @@ export const ConfigProvider = ({ children }: PropsWithChildren<{}>) => {
|
||||
return [];
|
||||
}
|
||||
|
||||
function getBaseCurrency(): Intl.NumberFormat {
|
||||
const baseCurrency = c.getOptionalConfig('costInsights.baseCurrency');
|
||||
if (baseCurrency) {
|
||||
const options = baseCurrency.getOptionalConfig('options');
|
||||
return new Intl.NumberFormat(
|
||||
baseCurrency.getOptionalString('locale'),
|
||||
options
|
||||
? {
|
||||
localeMatcher: options.getOptionalString('localeMatcher'),
|
||||
style: 'currency',
|
||||
currency: options.getOptionalString('currency'),
|
||||
currencySign: options.getOptionalString('currencySign'),
|
||||
useGrouping: options.getOptionalBoolean('useGrouping'),
|
||||
minimumIntegerDigits: options.getOptionalNumber(
|
||||
'minimumIntegerDigits',
|
||||
),
|
||||
minimumFractionDigits: options.getOptionalNumber(
|
||||
'minimumFractionDigits',
|
||||
),
|
||||
maximumFractionDigits: options.getOptionalNumber(
|
||||
'maximumFractionDigits',
|
||||
),
|
||||
minimumSignificantDigits: options.getOptionalNumber(
|
||||
'minimumSignificantDigits',
|
||||
),
|
||||
maximumSignificantDigits: options.getOptionalNumber(
|
||||
'maximumSignificantDigits',
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
|
||||
return defaultState.baseCurrency;
|
||||
}
|
||||
|
||||
function getCurrencies(): Currency[] {
|
||||
const currencies = c.getOptionalConfig('costInsights.currencies');
|
||||
if (currencies) {
|
||||
@@ -141,6 +184,7 @@ export const ConfigProvider = ({ children }: PropsWithChildren<{}>) => {
|
||||
}
|
||||
|
||||
function getConfig() {
|
||||
const baseCurrency = getBaseCurrency();
|
||||
const products = getProducts();
|
||||
const metrics = getMetrics();
|
||||
const engineerCost = getEngineerCost();
|
||||
@@ -152,6 +196,7 @@ export const ConfigProvider = ({ children }: PropsWithChildren<{}>) => {
|
||||
|
||||
setConfig(prevState => ({
|
||||
...prevState,
|
||||
baseCurrency,
|
||||
metrics,
|
||||
products,
|
||||
engineerCost,
|
||||
|
||||
@@ -15,19 +15,15 @@
|
||||
*/
|
||||
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import { LoadingContext, LoadingContextProps } from '../hooks/useLoading';
|
||||
import { GroupsContext, GroupsContextProps } from '../hooks/useGroups';
|
||||
import { FilterContext, FilterContextProps } from '../hooks/useFilters';
|
||||
import { ConfigContext, ConfigContextProps } from '../hooks/useConfig';
|
||||
import { CurrencyContext, CurrencyContextProps } from '../hooks/useCurrency';
|
||||
import {
|
||||
BillingDateContext,
|
||||
BillingDateContextProps,
|
||||
} from '../hooks/useLastCompleteBillingDate';
|
||||
import { ScrollContext, ScrollContextProps } from '../hooks/useScroll';
|
||||
import { Group, Duration } from '../types';
|
||||
|
||||
export const MockGroups: Group[] = [{ id: 'tech' }, { id: 'mock-group' }];
|
||||
import { LoadingContext, LoadingContextProps } from '../hooks';
|
||||
import { GroupsContext, GroupsContextProps } from '../hooks';
|
||||
import { FilterContext, FilterContextProps } from '../hooks';
|
||||
import { ConfigContext, ConfigContextProps } from '../hooks';
|
||||
import { CurrencyContext, CurrencyContextProps } from '../hooks';
|
||||
import { BillingDateContext, BillingDateContextProps } from '../hooks';
|
||||
import { ScrollContext, ScrollContextProps } from '../hooks';
|
||||
import { Duration } from '../types';
|
||||
import { createCurrencyFormat } from '../utils/currency';
|
||||
|
||||
export type MockFilterProviderProps = PropsWithChildren<
|
||||
Partial<FilterContextProps>
|
||||
@@ -85,6 +81,7 @@ export const MockConfigProvider = (props: MockConfigProviderProps) => {
|
||||
const { children, ...context } = props;
|
||||
|
||||
const defaultContext: ConfigContextProps = {
|
||||
baseCurrency: createCurrencyFormat(),
|
||||
metrics: [],
|
||||
products: [],
|
||||
icons: [],
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { Currency, CurrencyType, Duration } from '../types';
|
||||
import { assertNever } from '../utils/assert';
|
||||
import { assertNever } from './assert';
|
||||
|
||||
export const rateOf = (cost: number, duration: Duration) => {
|
||||
switch (duration) {
|
||||
@@ -61,3 +61,12 @@ export const defaultCurrencies: Currency[] = [
|
||||
rate: 5.5,
|
||||
},
|
||||
];
|
||||
|
||||
export const createCurrencyFormat = (
|
||||
currency: string = 'USD',
|
||||
locale: string = 'en-US',
|
||||
) =>
|
||||
new Intl.NumberFormat(locale, {
|
||||
currency,
|
||||
style: 'currency',
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
quarterOf,
|
||||
} from './formatters';
|
||||
import { Duration } from '../types';
|
||||
import { createCurrencyFormat } from './currency';
|
||||
|
||||
Date.now = jest.fn(() => new Date(Date.parse('2019-12-07')).valueOf());
|
||||
|
||||
@@ -39,7 +40,7 @@ describe('date formatters', () => {
|
||||
0.00000040925, 0.21, 0.0000004, 0.4139877878, 0.00000234566,
|
||||
];
|
||||
const formattedValues = values.map(val =>
|
||||
lengthyCurrencyFormatter.format(val),
|
||||
lengthyCurrencyFormatter(createCurrencyFormat()).format(val),
|
||||
);
|
||||
expect(formattedValues).toEqual([
|
||||
'$0.00000041',
|
||||
@@ -49,6 +50,22 @@ describe('date formatters', () => {
|
||||
'$0.0000023',
|
||||
]);
|
||||
});
|
||||
|
||||
it('Correctly formats values in euros to two significant digits', () => {
|
||||
const values = [
|
||||
0.00000040925, 0.21, 0.0000004, 0.4139877878, 0.00000234566,
|
||||
];
|
||||
const formattedValues = values.map(val =>
|
||||
lengthyCurrencyFormatter(createCurrencyFormat('EUR')).format(val),
|
||||
);
|
||||
expect(formattedValues).toEqual([
|
||||
'€0.00000041',
|
||||
'€0.21',
|
||||
'€0.00000040',
|
||||
'€0.41',
|
||||
'€0.0000023',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each`
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { DateTime, Duration as LuxonDuration } from 'luxon';
|
||||
import pluralize from 'pluralize';
|
||||
import { ChangeStatistic, Duration } from '../types';
|
||||
import { inclusiveEndDateOf, inclusiveStartDateOf } from '../utils/duration';
|
||||
import { inclusiveEndDateOf, inclusiveStartDateOf } from './duration';
|
||||
import { notEmpty } from './assert';
|
||||
|
||||
export type Period = {
|
||||
@@ -25,25 +25,28 @@ export type Period = {
|
||||
periodEnd: string;
|
||||
};
|
||||
|
||||
export const costFormatter = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
});
|
||||
export const currencyFormatter = (currency: Intl.NumberFormat) => {
|
||||
const options = currency.resolvedOptions();
|
||||
|
||||
export const currencyFormatter = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
return new Intl.NumberFormat(options.locale, {
|
||||
style: 'currency',
|
||||
currency: options.currency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
};
|
||||
|
||||
export const lengthyCurrencyFormatter = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
minimumSignificantDigits: 2,
|
||||
maximumSignificantDigits: 2,
|
||||
});
|
||||
export const lengthyCurrencyFormatter = (currency: Intl.NumberFormat) => {
|
||||
const options = currency.resolvedOptions();
|
||||
|
||||
return new Intl.NumberFormat(options.locale, {
|
||||
style: 'currency',
|
||||
currency: options.currency,
|
||||
minimumFractionDigits: 0,
|
||||
minimumSignificantDigits: 2,
|
||||
maximumSignificantDigits: 2,
|
||||
});
|
||||
};
|
||||
|
||||
export const numberFormatter = new Intl.NumberFormat('en-US', {
|
||||
minimumFractionDigits: 0,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { formatGraphValue, tooltipItemOf } from './graphs';
|
||||
import { DataKey } from '../types';
|
||||
import { createCurrencyFormat } from './currency';
|
||||
|
||||
describe('graphs', () => {
|
||||
it('formatGraphValue', () => {
|
||||
expect(formatGraphValue(createCurrencyFormat('SEK'))(1000, 0)).toEqual(
|
||||
'SEK 1,000',
|
||||
);
|
||||
expect(formatGraphValue(createCurrencyFormat('EUR'))(1000, 0)).toEqual(
|
||||
'€1,000',
|
||||
);
|
||||
expect(formatGraphValue(createCurrencyFormat('USD'))(1000, 0)).toEqual(
|
||||
'$1,000',
|
||||
);
|
||||
});
|
||||
it('tooltipItemOf', () => {
|
||||
expect(
|
||||
tooltipItemOf(createCurrencyFormat('EUR'))({
|
||||
value: '1000',
|
||||
color: 'red',
|
||||
dataKey: DataKey.Current,
|
||||
name: 'Kubernetes',
|
||||
}),
|
||||
).toEqual({
|
||||
fill: 'red',
|
||||
label: 'Kubernetes',
|
||||
value: '€1,000.00',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,44 +23,43 @@ import {
|
||||
lengthyCurrencyFormatter,
|
||||
} from './formatters';
|
||||
|
||||
export function formatGraphValue(
|
||||
value: number,
|
||||
_index: number,
|
||||
format?: string,
|
||||
) {
|
||||
if (format === 'number') {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
export const formatGraphValue =
|
||||
(baseCurrency: Intl.NumberFormat) =>
|
||||
(value: number, _index: number, format?: string) => {
|
||||
if (format === 'number') {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
if (value < 1) {
|
||||
return lengthyCurrencyFormatter.format(value);
|
||||
}
|
||||
if (value < 1) {
|
||||
return lengthyCurrencyFormatter(baseCurrency).format(value);
|
||||
}
|
||||
|
||||
return currencyFormatter.format(value);
|
||||
}
|
||||
return currencyFormatter(baseCurrency).format(value);
|
||||
};
|
||||
|
||||
export const overviewGraphTickFormatter = (millis: string | number) =>
|
||||
typeof millis === 'number' ? dateFormatter.format(millis) : millis;
|
||||
|
||||
export const tooltipItemOf = (payload: Payload<string, string>) => {
|
||||
const value =
|
||||
typeof payload.value === 'number'
|
||||
? currencyFormatter.format(payload.value)
|
||||
: payload.value;
|
||||
const fill = payload.color as string;
|
||||
export const tooltipItemOf =
|
||||
(baseCurrency: Intl.NumberFormat) => (payload: Payload<string, string>) => {
|
||||
const value =
|
||||
payload.value && !isNaN(Number(payload.value))
|
||||
? baseCurrency.format(Number(payload.value))
|
||||
: payload.value;
|
||||
const fill = payload.color as string;
|
||||
|
||||
switch (payload.dataKey) {
|
||||
case DataKey.Current:
|
||||
case DataKey.Previous:
|
||||
return {
|
||||
label: payload.name,
|
||||
value: value,
|
||||
fill: fill,
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
switch (payload.dataKey) {
|
||||
case DataKey.Current:
|
||||
case DataKey.Previous:
|
||||
return {
|
||||
label: payload.name,
|
||||
value: value,
|
||||
fill: fill,
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const resourceOf = (entity: Entity | AlertCost): ResourceData => ({
|
||||
name: entity.id,
|
||||
|
||||
@@ -170,8 +170,8 @@ const http = HttpPostIngressEventPublisher.fromConfig({
|
||||
},
|
||||
},
|
||||
logger: env.logger,
|
||||
router: httpRouter,
|
||||
});
|
||||
http.bind(router);
|
||||
|
||||
await new EventsBackend(env.logger)
|
||||
.addPublishers(http)
|
||||
|
||||
@@ -33,6 +33,8 @@ export const eventsPlugin: (options?: undefined) => BackendFeature;
|
||||
|
||||
// @public
|
||||
export class HttpPostIngressEventPublisher implements EventPublisher {
|
||||
// (undocumented)
|
||||
bind(router: express.Router): void;
|
||||
// (undocumented)
|
||||
static fromConfig(env: {
|
||||
config: Config;
|
||||
@@ -40,7 +42,6 @@ export class HttpPostIngressEventPublisher implements EventPublisher {
|
||||
[topic: string]: Omit<HttpPostIngressOptions, 'topic'>;
|
||||
};
|
||||
logger: Logger;
|
||||
router: express.Router;
|
||||
}): HttpPostIngressEventPublisher;
|
||||
// (undocumented)
|
||||
setEventBroker(eventBroker: EventBroker): Promise<void>;
|
||||
|
||||
@@ -90,14 +90,11 @@ export const eventsPlugin = createBackendPlugin({
|
||||
env.registerInit({
|
||||
deps: {
|
||||
config: configServiceRef,
|
||||
httpRouter: httpRouterServiceRef,
|
||||
logger: loggerServiceRef,
|
||||
router: httpRouterServiceRef,
|
||||
},
|
||||
async init({ config, httpRouter, logger }) {
|
||||
async init({ config, logger, router }) {
|
||||
const winstonLogger = loggerToWinstonLogger(logger);
|
||||
const eventsRouter = Router();
|
||||
const router = Router();
|
||||
eventsRouter.use('/http', router);
|
||||
|
||||
const ingresses = Object.fromEntries(
|
||||
extensionPoint.httpPostIngresses.map(ingress => [
|
||||
@@ -108,23 +105,20 @@ export const eventsPlugin = createBackendPlugin({
|
||||
|
||||
const http = HttpPostIngressEventPublisher.fromConfig({
|
||||
config,
|
||||
logger: winstonLogger,
|
||||
router,
|
||||
ingresses,
|
||||
logger: winstonLogger,
|
||||
});
|
||||
const eventsRouter = Router();
|
||||
http.bind(eventsRouter);
|
||||
router.use(eventsRouter);
|
||||
|
||||
if (!extensionPoint.eventBroker) {
|
||||
extensionPoint.setEventBroker(new InMemoryEventBroker(winstonLogger));
|
||||
}
|
||||
const eventBroker =
|
||||
extensionPoint.eventBroker ?? new InMemoryEventBroker(winstonLogger);
|
||||
|
||||
extensionPoint.eventBroker!.subscribe(extensionPoint.subscribers);
|
||||
eventBroker.subscribe(extensionPoint.subscribers);
|
||||
[extensionPoint.publishers, http]
|
||||
.flat()
|
||||
.forEach(publisher =>
|
||||
publisher.setEventBroker(extensionPoint.eventBroker!),
|
||||
);
|
||||
|
||||
httpRouter.use(eventsRouter);
|
||||
.forEach(publisher => publisher.setEventBroker(eventBroker));
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { errorHandler, getVoidLogger } from '@backstage/backend-common';
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { TestEventBroker } from '@backstage/plugin-events-backend-test-utils';
|
||||
import express from 'express';
|
||||
@@ -35,37 +35,35 @@ describe('HttpPostIngressEventPublisher', () => {
|
||||
});
|
||||
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
router.use(errorHandler());
|
||||
const app = express().use(router);
|
||||
|
||||
const publisher = HttpPostIngressEventPublisher.fromConfig({
|
||||
config,
|
||||
logger,
|
||||
router,
|
||||
ingresses: {
|
||||
testB: {},
|
||||
},
|
||||
logger,
|
||||
});
|
||||
publisher.bind(router);
|
||||
|
||||
const eventBroker = new TestEventBroker();
|
||||
await publisher.setEventBroker(eventBroker);
|
||||
|
||||
const notFoundResponse = await request(app)
|
||||
.post('/unknown')
|
||||
.post('/http/unknown')
|
||||
.timeout(100)
|
||||
.send({ test: 'data' });
|
||||
expect(notFoundResponse.status).toBe(404);
|
||||
|
||||
const response1 = await request(app)
|
||||
.post('/testA')
|
||||
.post('/http/testA')
|
||||
.set('X-Custom-Header', 'test-value')
|
||||
.timeout(100)
|
||||
.send({ testA: 'data' });
|
||||
expect(response1.status).toBe(202);
|
||||
|
||||
const response2 = await request(app)
|
||||
.post('/testB')
|
||||
.post('/http/testB')
|
||||
.set('X-Custom-Header', 'test-value')
|
||||
.timeout(100)
|
||||
.send({ testB: 'data' });
|
||||
@@ -100,14 +98,10 @@ describe('HttpPostIngressEventPublisher', () => {
|
||||
});
|
||||
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
router.use(errorHandler());
|
||||
const app = express().use(router);
|
||||
|
||||
const publisher = HttpPostIngressEventPublisher.fromConfig({
|
||||
config,
|
||||
logger,
|
||||
router,
|
||||
ingresses: {
|
||||
testB: {
|
||||
validator: async (req, context) => {
|
||||
@@ -148,26 +142,28 @@ describe('HttpPostIngressEventPublisher', () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
logger,
|
||||
});
|
||||
publisher.bind(router);
|
||||
|
||||
const eventBroker = new TestEventBroker();
|
||||
await publisher.setEventBroker(eventBroker);
|
||||
|
||||
const response1 = await request(app)
|
||||
.post('/testA')
|
||||
.post('/http/testA')
|
||||
.timeout(100)
|
||||
.send({ test: 'data' });
|
||||
expect(response1.status).toBe(202);
|
||||
|
||||
const response2 = await request(app)
|
||||
.post('/testB')
|
||||
.post('/http/testB')
|
||||
.timeout(100)
|
||||
.send({ test: 'data' });
|
||||
expect(response2.status).toBe(400);
|
||||
expect(response2.body).toEqual({ message: 'wrong signature' });
|
||||
|
||||
const response3 = await request(app)
|
||||
.post('/testB')
|
||||
.post('/http/testB')
|
||||
.set('X-Test-Signature', 'wrong')
|
||||
.timeout(100)
|
||||
.send({ test: 'data' });
|
||||
@@ -175,21 +171,21 @@ describe('HttpPostIngressEventPublisher', () => {
|
||||
expect(response3.body).toEqual({ message: 'wrong signature' });
|
||||
|
||||
const response4 = await request(app)
|
||||
.post('/testB')
|
||||
.post('/http/testB')
|
||||
.set('X-Test-Signature', 'testB-signature')
|
||||
.timeout(100)
|
||||
.send({ test: 'data' });
|
||||
expect(response4.status).toBe(202);
|
||||
|
||||
const response5 = await request(app)
|
||||
.post('/testC')
|
||||
.post('/http/testC')
|
||||
.timeout(100)
|
||||
.send({ test: 'data' });
|
||||
expect(response5.status).toBe(404);
|
||||
expect(response5.body).toEqual({});
|
||||
|
||||
const response6 = await request(app)
|
||||
.post('/testD')
|
||||
.post('/http/testD')
|
||||
.timeout(100)
|
||||
.send({ test: 'data' });
|
||||
expect(response6.status).toBe(403);
|
||||
@@ -210,15 +206,10 @@ describe('HttpPostIngressEventPublisher', () => {
|
||||
it('without configuration', async () => {
|
||||
const config = new ConfigReader({});
|
||||
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
router.use(errorHandler());
|
||||
|
||||
expect(() =>
|
||||
HttpPostIngressEventPublisher.fromConfig({
|
||||
config,
|
||||
logger,
|
||||
router,
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
@@ -41,7 +41,6 @@ export class HttpPostIngressEventPublisher implements EventPublisher {
|
||||
config: Config;
|
||||
ingresses?: { [topic: string]: Omit<HttpPostIngressOptions, 'topic'> };
|
||||
logger: Logger;
|
||||
router: express.Router;
|
||||
}): HttpPostIngressEventPublisher {
|
||||
const topics =
|
||||
env.config.getOptionalStringArray('events.http.topics') ?? [];
|
||||
@@ -55,15 +54,18 @@ export class HttpPostIngressEventPublisher implements EventPublisher {
|
||||
}
|
||||
});
|
||||
|
||||
return new HttpPostIngressEventPublisher(env.logger, env.router, ingresses);
|
||||
return new HttpPostIngressEventPublisher(env.logger, ingresses);
|
||||
}
|
||||
|
||||
private constructor(
|
||||
private logger: Logger,
|
||||
router: express.Router,
|
||||
ingresses: { [topic: string]: Omit<HttpPostIngressOptions, 'topic'> },
|
||||
) {
|
||||
router.use(this.createRouter(ingresses));
|
||||
private readonly logger: Logger,
|
||||
private readonly ingresses: {
|
||||
[topic: string]: Omit<HttpPostIngressOptions, 'topic'>;
|
||||
},
|
||||
) {}
|
||||
|
||||
bind(router: express.Router): void {
|
||||
router.use('/http', this.createRouter(this.ingresses));
|
||||
}
|
||||
|
||||
async setEventBroker(eventBroker: EventBroker): Promise<void> {
|
||||
@@ -92,8 +94,12 @@ export class HttpPostIngressEventPublisher implements EventPublisher {
|
||||
const path = `/${topic}`;
|
||||
|
||||
router.post(path, async (request, response) => {
|
||||
const requestDetails = {
|
||||
body: request.body,
|
||||
headers: request.headers,
|
||||
};
|
||||
const context = new RequestValidationContextImpl();
|
||||
await validator?.(request, context);
|
||||
await validator?.(requestDetails, context);
|
||||
if (context.wasRejected()) {
|
||||
response
|
||||
.status(context.rejectionDetails!.status)
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
```ts
|
||||
import { ExtensionPoint } from '@backstage/backend-plugin-api';
|
||||
import { Request as Request_2 } from 'express';
|
||||
|
||||
// @public
|
||||
export interface EventBroker {
|
||||
@@ -74,6 +73,12 @@ export interface HttpPostIngressOptions {
|
||||
validator?: RequestValidator;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface RequestDetails {
|
||||
body: unknown;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface RequestRejectionDetails {
|
||||
// (undocumented)
|
||||
@@ -89,7 +94,7 @@ export interface RequestValidationContext {
|
||||
|
||||
// @public
|
||||
export type RequestValidator = (
|
||||
request: Request_2,
|
||||
request: RequestDetails,
|
||||
context: RequestValidationContext,
|
||||
) => Promise<void>;
|
||||
|
||||
|
||||
@@ -24,9 +24,7 @@
|
||||
"postpack": "backstage-cli package postpack"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-plugin-api": "workspace:^",
|
||||
"@types/express": "^4.17.6",
|
||||
"express": "^4.17.1"
|
||||
"@backstage/backend-plugin-api": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "workspace:^"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface RequestDetails {
|
||||
/**
|
||||
* Request body. JSON payloads have been parsed already.
|
||||
*/
|
||||
body: unknown;
|
||||
/**
|
||||
* Key-value pairs of header names and values. Header names are lower-cased.
|
||||
*/
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Request } from 'express';
|
||||
import { RequestDetails } from './RequestDetails';
|
||||
import { RequestValidationContext } from './RequestValidationContext';
|
||||
|
||||
/**
|
||||
@@ -29,6 +29,6 @@ import { RequestValidationContext } from './RequestValidationContext';
|
||||
* @public
|
||||
*/
|
||||
export type RequestValidator = (
|
||||
request: Request,
|
||||
request: RequestDetails,
|
||||
context: RequestValidationContext,
|
||||
) => Promise<void>;
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export type { RequestDetails } from './RequestDetails';
|
||||
export type { RequestRejectionDetails } from './RequestRejectionDetails';
|
||||
export type { RequestValidationContext } from './RequestValidationContext';
|
||||
export type { RequestValidator } from './RequestValidator';
|
||||
|
||||
@@ -43,8 +43,10 @@ import useAsync from 'react-use/lib/useAsync';
|
||||
|
||||
const useStyles = makeStyles((theme: BackstageTheme) => ({
|
||||
graph: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
minHeight: '100%',
|
||||
},
|
||||
graphWrapper: {
|
||||
height: '100%',
|
||||
},
|
||||
organizationNode: {
|
||||
fill: theme.palette.secondary.light,
|
||||
@@ -62,6 +64,15 @@ const useStyles = makeStyles((theme: BackstageTheme) => ({
|
||||
justifyContent: 'center',
|
||||
color: 'black',
|
||||
},
|
||||
legend: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
padding: theme.spacing(1),
|
||||
'& .icon': {
|
||||
verticalAlign: 'bottom',
|
||||
},
|
||||
},
|
||||
textOrganization: {
|
||||
color: theme.palette.secondary.contrastText,
|
||||
},
|
||||
@@ -221,7 +232,7 @@ export function GroupsDiagram() {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classes.graphWrapper}>
|
||||
<DependencyGraph
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
@@ -229,14 +240,18 @@ export function GroupsDiagram() {
|
||||
direction={DependencyGraphTypes.Direction.RIGHT_LEFT}
|
||||
renderNode={RenderNode}
|
||||
className={classes.graph}
|
||||
fit="contain"
|
||||
/>
|
||||
|
||||
<Typography
|
||||
variant="caption"
|
||||
style={{ display: 'block', textAlign: 'right' }}
|
||||
color="textSecondary"
|
||||
display="block"
|
||||
className={classes.legend}
|
||||
>
|
||||
<ZoomOutMap style={{ verticalAlign: 'bottom' }} /> Use pinch & zoom
|
||||
to move around the diagram.
|
||||
<ZoomOutMap className="icon" /> Use pinch & zoom to move around the
|
||||
diagram.
|
||||
</Typography>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,13 +18,14 @@ To use the `GroupListPicker` component you'll need to import it and add it to yo
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
+ <GroupListPicker groupTypes={['team']} placeholder='Search for a team' onChange={setGroup}/>
|
||||
+ <GroupListPicker groupTypes={['team']} placeholder='Search for a team' onChange={setGroup} defaultValue='Team A'/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
```
|
||||
|
||||
The `GroupListPicker` comes with three props:
|
||||
The `GroupListPicker` comes with four props:
|
||||
|
||||
- `groupTypes`: gives the user the option which group types the component should load. If no value is provided all group types will be loaded in;
|
||||
- `placeholder`: the placeholder that the select box in the component should display. This might be helpful in informing your users what the functionality of the component is.
|
||||
- `onChange`: a prop to help the user to give access to the selected group
|
||||
- `defaultValue`: gives the user the option to define a default value that will be shown initially before making a selection
|
||||
|
||||
@@ -12,6 +12,7 @@ export const GroupListPicker: (props: GroupListPickerProps) => JSX.Element;
|
||||
|
||||
// @public
|
||||
export type GroupListPickerProps = {
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
groupTypes?: Array<string>;
|
||||
onChange: (value: GroupEntity | undefined) => void;
|
||||
|
||||
@@ -34,6 +34,7 @@ import { GroupListPickerButton } from './GroupListPickerButton';
|
||||
* @public
|
||||
*/
|
||||
export type GroupListPickerProps = {
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
groupTypes?: Array<string>;
|
||||
onChange: (value: GroupEntity | undefined) => void;
|
||||
@@ -43,9 +44,9 @@ export type GroupListPickerProps = {
|
||||
export const GroupListPicker = (props: GroupListPickerProps) => {
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
|
||||
const { onChange, groupTypes, placeholder = '' } = props;
|
||||
const { onChange, groupTypes, placeholder = '', defaultValue = '' } = props;
|
||||
const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null);
|
||||
const [inputValue, setInputValue] = React.useState('');
|
||||
const [inputValue, setInputValue] = React.useState(defaultValue);
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
@@ -75,6 +76,7 @@ export const GroupListPicker = (props: GroupListPickerProps) => {
|
||||
const handleChange = useCallback(
|
||||
(_, v: GroupEntity | null) => {
|
||||
onChange(v ?? undefined);
|
||||
setAnchorEl(null);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
@@ -108,6 +110,8 @@ export const GroupListPicker = (props: GroupListPickerProps) => {
|
||||
renderInput={params => (
|
||||
<TextField
|
||||
{...params}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
placeholder={placeholder}
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
@@ -22,8 +22,7 @@ import PeopleIcon from '@material-ui/icons/People';
|
||||
|
||||
const useStyles = makeStyles((theme: BackstageTheme) => ({
|
||||
btn: {
|
||||
margin: 0,
|
||||
padding: 10,
|
||||
padding: '10px',
|
||||
width: '100%',
|
||||
cursor: 'pointer',
|
||||
justifyContent: 'space-between',
|
||||
@@ -32,10 +31,13 @@ const useStyles = makeStyles((theme: BackstageTheme) => ({
|
||||
fontSize: '1.5rem',
|
||||
fontStyle: 'normal',
|
||||
fontWeight: theme.typography.fontWeightBold,
|
||||
height: '32px',
|
||||
letterSpacing: '-0.25px',
|
||||
lineHeight: '32px',
|
||||
marginBottom: 0,
|
||||
marginLeft: '4px',
|
||||
textAlign: 'left',
|
||||
textTransform: 'none',
|
||||
width: '100%',
|
||||
},
|
||||
icon: {
|
||||
transform: 'scale(1.5)',
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"@material-ui/lab": "4.0.0-alpha.57",
|
||||
"@material-ui/styles": "^4.10.0",
|
||||
"cross-fetch": "^3.1.5",
|
||||
"rc-progress": "3.4.0",
|
||||
"rc-progress": "3.4.1",
|
||||
"react-use": "^17.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
import { Config } from '@backstage/config';
|
||||
import { InputError } from '@backstage/errors';
|
||||
import { Logger } from 'winston';
|
||||
import express, { Router } from 'express';
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { VaultClient } from './vaultApi';
|
||||
import { TaskRunner, PluginTaskScheduler } from '@backstage/backend-tasks';
|
||||
import { errorHandler } from '@backstage/backend-common';
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
import { NotAllowedError, NotFoundError } from '@backstage/errors';
|
||||
import fetch from 'node-fetch';
|
||||
import plimit from 'p-limit';
|
||||
import { getVaultConfig, VaultConfig } from '../config';
|
||||
@@ -103,6 +103,8 @@ export class VaultClient implements VaultApi {
|
||||
return (await response.json()) as T;
|
||||
} else if (response.status === 404) {
|
||||
throw new NotFoundError(`No secrets found in path '${path}'`);
|
||||
} else if (response.status === 403) {
|
||||
throw new NotAllowedError(response.statusText);
|
||||
}
|
||||
throw new Error(
|
||||
`Unexpected error while fetching secrets from path '${path}'`,
|
||||
|
||||
Reference in New Issue
Block a user