Merge branch 'continue_dependencies_deprecations' of github.com:Znarvl/backstage into continue_dependencies_deprecations

This commit is contained in:
Simon
2022-11-22 10:55:14 +01:00
76 changed files with 2146 additions and 1079 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend-module-github': patch
---
Fixes the assignment of group member references in `GithubMultiOrgProcessor` so membership relations are resolved correctly.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-org-react': patch
---
Bug fixes and adding the possibility to add a default value for the `GroupListPicker`. Fixes: Vertical size jump on text entry, left align for text, selecting a value closes the popup, auto focus on the popup when opening
+21
View File
@@ -0,0 +1,21 @@
---
'@backstage/plugin-events-backend': minor
---
**BREAKING:** Remove required field `router` at `HttpPostIngressEventPublisher.fromConfig`
and replace it with `bind(router: Router)`.
Additionally, the path prefix `/http` will be added inside `HttpPostIngressEventPublisher`.
```diff
// at packages/backend/src/plugins/events.ts
const eventsRouter = Router();
- const httpRouter = Router();
- eventsRouter.use('/http', httpRouter);
const http = HttpPostIngressEventPublisher.fromConfig({
config: env.config,
logger: env.logger,
- router: httpRouter,
});
+ http.bind(eventsRouter);
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-cost-insights': patch
---
Provide the ability to change the base currency from USD to any other currency in cost insights plugin
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
Create a variable for minimum height and add a prop named 'fit' for determining if the graph height should grow or be contained.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-explore': patch
---
Adds styling to graph forcing it to always fill out the available space.
+9
View File
@@ -0,0 +1,9 @@
---
'@backstage/plugin-events-backend': patch
'@backstage/plugin-events-node': minor
---
Introduce a new interface `RequestDetails` to abstract `Request`
providing access to request body and headers.
**BREAKING:** Replace `request: Request` with `request: RequestDetails` at `RequestValidator`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-app-api': patch
---
Added `lifecycleFactory` implementation.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/core-components': patch
'@backstage/plugin-codescene': patch
'@backstage/plugin-sonarqube': patch
---
Updated dependency `rc-progress` to `3.4.1`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---
Registered shutdown hook in experimental catalog plugin.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-vault-backend': patch
---
Use `express-promise-router` to catch errors properly.
Add `403` error as a known one. It will now return a `NotAllowed` error.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-defaults': patch
---
Added `lifecycleFactory` to default service factories.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-plugin-api': patch
---
Added initial support for registering shutdown hooks via `lifecycleServiceRef`.
@@ -0,0 +1,63 @@
name: Automate merge message
on:
pull_request_target:
branches: ['master']
types: ['closed']
permissions:
pull-requests: write
actions: none
checks: none
contents: none
deployments: none
issues: none
packages: none
pages: none
repository-projects: none
security-events: none
statuses: none
jobs:
message:
# prevent running towards forks, and only run on merged PRs
if: github.repository == 'backstage/backstage' && github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
ref: '${{ github.event.pull_request.merge_commit_sha }}'
- name: fetch base
run: git fetch --depth 1 origin ${{ github.event.pull_request.base.sha }}
# We avoid using the in-source script since this workflow has elevated permissions that we don't want to expose
- name: Generate Message
id: generate-message
run: |
rm -f generate.js
wget -O generate.js https://raw.githubusercontent.com/backstage/backstage/master/scripts/generate-merge-message.js 1>&2
node generate.js FETCH_HEAD > message.txt
- name: Post Message
uses: actions/github-script@v6
env:
ISSUE_NUMBER: ${{ github.event.pull_request.number }}
with:
script: |
const owner = "backstage";
const repo = "backstage";
const body = require('fs').readFileSync('message.txt', 'utf8').trim();
const issue_number = Number(process.env.ISSUE_NUMBER);
if (!body) {
console.log(`skipping comment for #${issue_number}`);
return;
}
console.log(`creating comment for #${issue_number}`);
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body,
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+92
View File
@@ -393,6 +393,98 @@ You can add more icons, if the [default icons](https://github.com/backstage/back
Note: If the icon is not available as one of the default icons or one you've added then it will fall back to Material UI's `LanguageIcon`
## Custom Sidebar
As you've seen there are many ways that you can customize your Backstage app. The following section will show you how you can customize the sidebar.
### Sidebar Sub-menu
For this example we'll show you how you can expand the sidebar with a sub-menu:
1. Open the `Root.tsx` file located in `packages/app/src/components/Root` as this is where the sidebar code lives
2. Then we want to add the following imports for the icons:
```ts
import ApiIcon from '@material-ui/icons/Extension';
import ComponentIcon from '@material-ui/icons/Memory';
import DomainIcon from '@material-ui/icons/Apartment';
import ResourceIcon from '@material-ui/icons/Work';
import SystemIcon from '@material-ui/icons/Category';
import UserIcon from '@material-ui/icons/Person';
```
3. Then update the `@backstage/core-components` import like this:
```diff
import {
Sidebar,
sidebarConfig,
SidebarDivider,
SidebarGroup,
SidebarItem,
SidebarPage,
SidebarScrollWrapper,
SidebarSpace,
useSidebarOpenState,
Link,
+ GroupIcon,
+ SidebarSubmenu,
+ SidebarSubmenuItem,
} from '@backstage/core-components';
```
4. Finally replace `<SidebarItem icon={HomeIcon} to="catalog" text="Home" />` with this:
```ts
<SidebarItem icon={HomeIcon} to="catalog" text="Home">
<SidebarSubmenu title="Catalog">
<SidebarSubmenuItem
title="Domains"
to="catalog?filters[kind]=domain"
icon={DomainIcon}
/>
<SidebarSubmenuItem
title="Systems"
to="catalog?filters[kind]=system"
icon={SystemIcon}
/>
<SidebarSubmenuItem
title="Components"
to="catalog?filters[kind]=component"
icon={ComponentIcon}
/>
<SidebarSubmenuItem
title="APIs"
to="catalog?filters[kind]=api"
icon={ApiIcon}
/>
<SidebarDivider />
<SidebarSubmenuItem
title="Resources"
to="catalog?filters[kind]=resource"
icon={ResourceIcon}
/>
<SidebarDivider />
<SidebarSubmenuItem
title="Groups"
to="catalog?filters[kind]=group"
icon={GroupIcon}
/>
<SidebarSubmenuItem
title="Users"
to="catalog?filters[kind]=user"
icon={UserIcon}
/>
</SidebarSubmenu>
</SidebarItem>
```
When you startup your Backstage app and hover over the Home option on the sidebar you'll now see a nice sub-menu appear with links to the various Kinds in your Catalog. It would look like this:
![Sidebar sub-menu example](./../assets/getting-started/sidebar-submenu-example.png)
You can see more ways to use this in the [Storybook Sidebar examples](https://backstage.io/storybook/?path=/story/layout-sidebar--sample-scalable-sidebar)
## Custom Homepage
In addition to a custom theme, a custom logo, you can also customize the
+50 -1
View File
@@ -47,12 +47,21 @@ import {
SidebarSpace,
Link,
useSidebarOpenState,
SidebarSubmenu,
SidebarSubmenuItem,
} from '@backstage/core-components';
import { MyGroupsSidebarItem } from '@backstage/plugin-org';
import GroupIcon from '@material-ui/icons/People';
import { SearchModal } from '../search/SearchModal';
import Score from '@material-ui/icons/Score';
import ApiIcon from '@material-ui/icons/Extension';
import ComponentIcon from '@material-ui/icons/Memory';
import DomainIcon from '@material-ui/icons/Apartment';
import ResourceIcon from '@material-ui/icons/Work';
import SystemIcon from '@material-ui/icons/Category';
import UserIcon from '@material-ui/icons/Person';
const useSidebarLogoStyles = makeStyles({
root: {
width: sidebarConfig.drawerWidthClosed,
@@ -93,7 +102,47 @@ export const Root = ({ children }: PropsWithChildren<{}>) => (
<SidebarDivider />
<SidebarGroup label="Menu" icon={<MenuIcon />}>
{/* Global nav, not org-specific */}
<SidebarItem icon={HomeIcon} to="catalog" text="Home" />
<SidebarItem icon={HomeIcon} to="catalog" text="Home">
<SidebarSubmenu title="Catalog">
<SidebarSubmenuItem
title="Domains"
to="catalog?filters[kind]=domain"
icon={DomainIcon}
/>
<SidebarSubmenuItem
title="Systems"
to="catalog?filters[kind]=system"
icon={SystemIcon}
/>
<SidebarSubmenuItem
title="Components"
to="catalog?filters[kind]=component"
icon={ComponentIcon}
/>
<SidebarSubmenuItem
title="APIs"
to="catalog?filters[kind]=api"
icon={ApiIcon}
/>
<SidebarDivider />
<SidebarSubmenuItem
title="Resources"
to="catalog?filters[kind]=resource"
icon={ResourceIcon}
/>
<SidebarDivider />
<SidebarSubmenuItem
title="Groups"
to="catalog?filters[kind]=group"
icon={GroupIcon}
/>
<SidebarSubmenuItem
title="Users"
to="catalog?filters[kind]=user"
icon={UserIcon}
/>
</SidebarSubmenu>
</SidebarItem>
<MyGroupsSidebarItem
singularTitle="My Squad"
pluralTitle="My Squads"
+6
View File
@@ -4,6 +4,7 @@
```ts
import { BackendFeature } from '@backstage/backend-plugin-api';
import { BackendLifecycle } from '@backstage/backend-plugin-api';
import { Config } from '@backstage/config';
import { ExtensionPoint } from '@backstage/backend-plugin-api';
import { HttpRouterService } from '@backstage/backend-plugin-api';
@@ -66,6 +67,11 @@ export type HttpRouterFactoryOptions = {
indexPlugin?: string;
};
// @public
export const lifecycleFactory: (
options?: undefined,
) => ServiceFactory<BackendLifecycle>;
// @public (undocumented)
export const loggerFactory: (options?: undefined) => ServiceFactory<Logger>;
@@ -25,4 +25,5 @@ export { schedulerFactory } from './schedulerService';
export { tokenManagerFactory } from './tokenManagerService';
export { urlReaderFactory } from './urlReaderService';
export { httpRouterFactory } from './httpRouterService';
export { lifecycleFactory } from './lifecycleService';
export type { HttpRouterFactoryOptions } from './httpRouterService';
@@ -0,0 +1,47 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { getVoidLogger } from '@backstage/backend-common';
import { BackendLifecycleImpl } from './lifecycleService';
describe('lifecycleService', () => {
it('should execute registered shutdown hook', async () => {
const service = new BackendLifecycleImpl(getVoidLogger());
const hook = jest.fn();
service.addShutdownHook({
pluginId: 'test',
fn: async () => {
hook();
},
});
// should not execute the hook more than once.
await service.shutdown();
await service.shutdown();
await service.shutdown();
expect(hook).toHaveBeenCalledTimes(1);
});
it('should not throw errors', async () => {
const service = new BackendLifecycleImpl(getVoidLogger());
service.addShutdownHook({
pluginId: 'test',
fn: async () => {
throw new Error('oh no');
},
});
await expect(service.shutdown()).resolves.toBeUndefined();
});
});
@@ -0,0 +1,96 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
BackendLifecycle,
createServiceFactory,
lifecycleServiceRef,
loggerToWinstonLogger,
pluginMetadataServiceRef,
rootLoggerServiceRef,
BackendLifecycleShutdownHook,
} from '@backstage/backend-plugin-api';
import { Logger } from 'winston';
const CALLBACKS = ['SIGTERM', 'SIGINT', 'beforeExit'];
export class BackendLifecycleImpl {
constructor(private readonly logger: Logger) {
CALLBACKS.map(signal => process.on(signal, () => this.shutdown()));
}
#isCalled = false;
#shutdownTasks: Array<BackendLifecycleShutdownHook & { pluginId: string }> =
[];
addShutdownHook(
options: BackendLifecycleShutdownHook & { pluginId: string },
): void {
this.#shutdownTasks.push(options);
}
async shutdown(): Promise<void> {
if (this.#isCalled) {
return;
}
this.#isCalled = true;
this.logger.info(`Running ${this.#shutdownTasks.length} shutdown tasks...`);
await Promise.all(
this.#shutdownTasks.map(hook =>
Promise.resolve()
.then(() => hook.fn())
.catch(e => {
this.logger.error(
`Shutdown hook registered by plugin '${hook.pluginId}' failed with: ${e}`,
);
})
.then(() =>
this.logger.info(
`Successfully ran shutdown hook registered by plugin ${hook.pluginId}`,
),
),
),
);
}
}
class PluginScopedLifecycleImpl implements BackendLifecycle {
constructor(
private readonly lifecycle: BackendLifecycleImpl,
private readonly pluginId: string,
) {}
addShutdownHook(options: BackendLifecycleShutdownHook): void {
this.lifecycle.addShutdownHook({ ...options, pluginId: this.pluginId });
}
}
/**
* Allows plugins to register shutdown hooks that are run when the process is about to exit.
* @public */
export const lifecycleFactory = createServiceFactory({
service: lifecycleServiceRef,
deps: {
logger: rootLoggerServiceRef,
plugin: pluginMetadataServiceRef,
},
async factory({ logger }) {
const rootLifecycle = new BackendLifecycleImpl(
loggerToWinstonLogger(logger),
);
return async ({ plugin }) => {
return new PluginScopedLifecycleImpl(rootLifecycle, plugin.getId());
};
},
});
@@ -22,6 +22,7 @@ import {
databaseFactory,
discoveryFactory,
httpRouterFactory,
lifecycleFactory,
loggerFactory,
permissionsFactory,
rootLoggerFactory,
@@ -43,6 +44,7 @@ export const defaultServiceFactories = [
tokenManagerFactory,
urlReaderFactory,
httpRouterFactory,
lifecycleFactory,
];
/**
+13
View File
@@ -24,6 +24,16 @@ export interface BackendFeature {
register(reg: BackendRegistrationPoints): void;
}
// @public (undocumented)
export interface BackendLifecycle {
addShutdownHook(options: BackendLifecycleShutdownHook): void;
}
// @public (undocumented)
export type BackendLifecycleShutdownHook = {
fn: () => void | Promise<void>;
};
// @public (undocumented)
export interface BackendModuleConfig<TOptions> {
// (undocumented)
@@ -158,6 +168,9 @@ export interface HttpRouterService {
// @public (undocumented)
export const httpRouterServiceRef: ServiceRef<HttpRouterService, 'plugin'>;
// @public (undocumented)
export const lifecycleServiceRef: ServiceRef<BackendLifecycle, 'plugin'>;
// @public (undocumented)
export interface Logger {
// (undocumented)
@@ -28,4 +28,9 @@ export { permissionsServiceRef } from './permissionsServiceRef';
export { schedulerServiceRef } from './schedulerServiceRef';
export { rootLoggerServiceRef } from './rootLoggerServiceRef';
export { pluginMetadataServiceRef } from './pluginMetadataServiceRef';
export { lifecycleServiceRef } from './lifecycleServiceRef';
export type {
BackendLifecycle,
BackendLifecycleShutdownHook,
} from './lifecycleServiceRef';
export type { PluginMetadata } from './pluginMetadataServiceRef';
@@ -0,0 +1,42 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createServiceRef } from '../system/types';
/**
* @public
**/
export type BackendLifecycleShutdownHook = {
fn: () => void | Promise<void>;
};
/**
* @public
**/
export interface BackendLifecycle {
/**
* Register a function to be called when the backend is shutting down.
*/
addShutdownHook(options: BackendLifecycleShutdownHook): void;
}
/**
* @public
*/
export const lifecycleServiceRef = createServiceRef<BackendLifecycle>({
id: 'core.lifecycle',
scope: 'plugin',
});
+1 -3
View File
@@ -27,14 +27,12 @@ export default async function createPlugin(
subscribers: EventSubscriber[],
): Promise<Router> {
const eventsRouter = Router();
const httpRouter = Router();
eventsRouter.use('/http', httpRouter);
const http = HttpPostIngressEventPublisher.fromConfig({
config: env.config,
logger: env.logger,
router: httpRouter,
});
http.bind(eventsRouter);
await new EventsBackend(env.logger)
.addPublishers(http)
+1
View File
@@ -250,6 +250,7 @@ export interface DependencyGraphProps<NodeData, EdgeData>
edgeRanks?: number;
edges: DependencyEdge<EdgeData>[];
edgeWeight?: number;
fit?: 'grow' | 'contain';
labelOffset?: number;
// Warning: (ae-unresolved-link) The @link reference could not be resolved: This type of declaration is not supported yet by the resolver
labelPosition?: LabelPosition;
+1 -1
View File
@@ -56,7 +56,7 @@
"pluralize": "^8.0.0",
"prop-types": "^15.7.2",
"qs": "^6.9.4",
"rc-progress": "3.4.0",
"rc-progress": "3.4.1",
"react-helmet": "6.1.0",
"react-hook-form": "^7.12.2",
"react-markdown": "^8.0.0",
@@ -170,6 +170,14 @@ export interface DependencyGraphProps<NodeData, EdgeData>
* Default: 'curveMonotoneX'
*/
curve?: 'curveStepBefore' | 'curveMonotoneX';
/**
* Controls if the graph should be contained or grow
*
* @remarks
*
* Default: 'grow'
*/
fit?: 'grow' | 'contain';
}
const WORKSPACE_ID = 'workspace';
@@ -203,6 +211,7 @@ export function DependencyGraph<NodeData, EdgeData>(
defs,
zoom = 'enabled',
curve = 'curveMonotoneX',
fit = 'grow',
...svgProps
} = props;
const theme: BackstageTheme = useTheme();
@@ -223,6 +232,9 @@ export function DependencyGraph<NodeData, EdgeData>(
const maxWidth = Math.max(graphWidth, containerWidth);
const maxHeight = Math.max(graphHeight, containerHeight);
const minHeight = Math.min(graphHeight, containerHeight);
const scalableHeight = fit === 'grow' ? maxHeight : minHeight;
const containerRef = React.useMemo(
() =>
@@ -394,7 +406,7 @@ export function DependencyGraph<NodeData, EdgeData>(
ref={containerRef}
{...svgProps}
width="100%"
height={maxHeight}
height={scalableHeight}
viewBox={`0 0 ${maxWidth} ${maxHeight}`}
>
<defs>
@@ -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 = [];
@@ -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));
@@ -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);
},
});
+1 -1
View File
@@ -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": {
+15
View File
@@ -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.
+1
View File
@@ -232,6 +232,7 @@ export type ChartData = {
// @public (undocumented)
export type ConfigContextProps = {
baseCurrency: Intl.NumberFormat;
metrics: Metric[];
products: Product[];
icons: Icon[];
+52
View File
@@ -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}
@@ -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}
/>
@@ -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);
@@ -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();
});
+46 -1
View File
@@ -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: [],
+10 -1
View File
@@ -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`
+21 -18
View File
@@ -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',
});
});
});
+30 -31
View File
@@ -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,
+1 -1
View File
@@ -170,8 +170,8 @@ const http = HttpPostIngressEventPublisher.fromConfig({
},
},
logger: env.logger,
router: httpRouter,
});
http.bind(router);
await new EventsBackend(env.logger)
.addPublishers(http)
+2 -1
View File
@@ -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)
+7 -2
View File
@@ -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>;
+1 -3
View File
@@ -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 &amp; zoom
to move around the diagram.
<ZoomOutMap className="icon" /> Use pinch &amp; zoom to move around the
diagram.
</Typography>
</>
</div>
);
}
+3 -2
View File
@@ -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
+1
View File
@@ -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)',
+1 -1
View File
@@ -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}'`,
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env node
/*
* 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.
*/
const { execFile: execFileCb } = require('child_process');
const { promisify } = require('util');
const { resolve: resolvePath } = require('path');
const execFile = promisify(execFileCb);
async function hasNewChangesets(ref) {
if (!ref) {
throw new Error('ref is required');
}
const { stdout } = await execFile('git', [
'diff',
'--compact-summary',
ref,
'.changeset/*.md',
]);
return stdout.includes('(new)');
}
function getReleaseOfMonth(year, month) {
const base = new Date(Date.UTC(year, month));
const wednesdayOffset =
base.getUTCDay() > 3 ? 10 - base.getUTCDay() : 3 - base.getUTCDay();
const thirdWednesdayOffset = wednesdayOffset + 7 * 2;
const releaseOffset = thirdWednesdayOffset - 1;
const releaseDay = new Date(
Date.UTC(base.getUTCFullYear(), base.getUTCMonth(), releaseOffset + 1),
);
return releaseDay;
}
function getReleaseSchedule() {
const firstReleaseYear = 2022;
const firstReleaseMonth = 2;
return Array(100)
.fill(0)
.map((_, i) => {
const date = getReleaseOfMonth(firstReleaseYear, firstReleaseMonth + i);
return { version: `1.${i}.0`, date };
});
}
function getCurrentRelease() {
const { version: releaseVersion } = require(resolvePath('package.json'));
const match = releaseVersion.match(/^(\d+\.\d+\.\d+)/);
if (!match) {
throw new Error(`Failed to parse release version, '${releaseVersion}'`);
}
const [versionStr] = match;
if (versionStr === releaseVersion) {
return releaseVersion;
}
const [major, minor] = versionStr.split('.').map(Number);
return `${major}.${minor - 1}.0`;
}
function findNextRelease(currentRelease, releaseSchedule) {
const currentIndex = releaseSchedule.findIndex(
r => r.version === currentRelease,
);
if (currentIndex === -1) {
throw new Error(
`Failed to find current release '${currentRelease}' in release schedule`,
);
}
return releaseSchedule[currentIndex + 1];
}
async function main() {
const [diffRef = 'origin/master'] = process.argv.slice(2);
const needsMessage = await hasNewChangesets(diffRef);
if (!needsMessage) {
return;
}
const currentRelease = getCurrentRelease();
const releaseSchedule = getReleaseSchedule();
const nextRelease = findNextRelease(currentRelease, releaseSchedule);
const scheduledDate = nextRelease.date
.toUTCString()
.replace(/\s*\d+:\d+:\d+.*/, '');
process.stdout.write(
[
'Thank you for contributing to Backstage! The changes in this pull request will be part',
`of the \`${nextRelease.version}\` release, scheduled for ${scheduledDate}.`,
].join(' '),
);
}
main().catch(error => {
console.error(error.stack);
process.exit(1);
});
+43 -43
View File
@@ -2966,90 +2966,90 @@ __metadata:
languageName: node
linkType: hard
"@swc/core-darwin-arm64@npm:1.3.16":
version: 1.3.16
resolution: "@swc/core-darwin-arm64@npm:1.3.16"
"@swc/core-darwin-arm64@npm:1.3.19":
version: 1.3.19
resolution: "@swc/core-darwin-arm64@npm:1.3.19"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@swc/core-darwin-x64@npm:1.3.16":
version: 1.3.16
resolution: "@swc/core-darwin-x64@npm:1.3.16"
"@swc/core-darwin-x64@npm:1.3.19":
version: 1.3.19
resolution: "@swc/core-darwin-x64@npm:1.3.19"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@swc/core-linux-arm-gnueabihf@npm:1.3.16":
version: 1.3.16
resolution: "@swc/core-linux-arm-gnueabihf@npm:1.3.16"
"@swc/core-linux-arm-gnueabihf@npm:1.3.19":
version: 1.3.19
resolution: "@swc/core-linux-arm-gnueabihf@npm:1.3.19"
conditions: os=linux & cpu=arm
languageName: node
linkType: hard
"@swc/core-linux-arm64-gnu@npm:1.3.16":
version: 1.3.16
resolution: "@swc/core-linux-arm64-gnu@npm:1.3.16"
"@swc/core-linux-arm64-gnu@npm:1.3.19":
version: 1.3.19
resolution: "@swc/core-linux-arm64-gnu@npm:1.3.19"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
"@swc/core-linux-arm64-musl@npm:1.3.16":
version: 1.3.16
resolution: "@swc/core-linux-arm64-musl@npm:1.3.16"
"@swc/core-linux-arm64-musl@npm:1.3.19":
version: 1.3.19
resolution: "@swc/core-linux-arm64-musl@npm:1.3.19"
conditions: os=linux & cpu=arm64 & libc=musl
languageName: node
linkType: hard
"@swc/core-linux-x64-gnu@npm:1.3.16":
version: 1.3.16
resolution: "@swc/core-linux-x64-gnu@npm:1.3.16"
"@swc/core-linux-x64-gnu@npm:1.3.19":
version: 1.3.19
resolution: "@swc/core-linux-x64-gnu@npm:1.3.19"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
"@swc/core-linux-x64-musl@npm:1.3.16":
version: 1.3.16
resolution: "@swc/core-linux-x64-musl@npm:1.3.16"
"@swc/core-linux-x64-musl@npm:1.3.19":
version: 1.3.19
resolution: "@swc/core-linux-x64-musl@npm:1.3.19"
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
"@swc/core-win32-arm64-msvc@npm:1.3.16":
version: 1.3.16
resolution: "@swc/core-win32-arm64-msvc@npm:1.3.16"
"@swc/core-win32-arm64-msvc@npm:1.3.19":
version: 1.3.19
resolution: "@swc/core-win32-arm64-msvc@npm:1.3.19"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@swc/core-win32-ia32-msvc@npm:1.3.16":
version: 1.3.16
resolution: "@swc/core-win32-ia32-msvc@npm:1.3.16"
"@swc/core-win32-ia32-msvc@npm:1.3.19":
version: 1.3.19
resolution: "@swc/core-win32-ia32-msvc@npm:1.3.19"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"@swc/core-win32-x64-msvc@npm:1.3.16":
version: 1.3.16
resolution: "@swc/core-win32-x64-msvc@npm:1.3.16"
"@swc/core-win32-x64-msvc@npm:1.3.19":
version: 1.3.19
resolution: "@swc/core-win32-x64-msvc@npm:1.3.19"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"@swc/core@npm:^1.3.9":
version: 1.3.16
resolution: "@swc/core@npm:1.3.16"
version: 1.3.19
resolution: "@swc/core@npm:1.3.19"
dependencies:
"@swc/core-darwin-arm64": 1.3.16
"@swc/core-darwin-x64": 1.3.16
"@swc/core-linux-arm-gnueabihf": 1.3.16
"@swc/core-linux-arm64-gnu": 1.3.16
"@swc/core-linux-arm64-musl": 1.3.16
"@swc/core-linux-x64-gnu": 1.3.16
"@swc/core-linux-x64-musl": 1.3.16
"@swc/core-win32-arm64-msvc": 1.3.16
"@swc/core-win32-ia32-msvc": 1.3.16
"@swc/core-win32-x64-msvc": 1.3.16
"@swc/core-darwin-arm64": 1.3.19
"@swc/core-darwin-x64": 1.3.19
"@swc/core-linux-arm-gnueabihf": 1.3.19
"@swc/core-linux-arm64-gnu": 1.3.19
"@swc/core-linux-arm64-musl": 1.3.19
"@swc/core-linux-x64-gnu": 1.3.19
"@swc/core-linux-x64-musl": 1.3.19
"@swc/core-win32-arm64-msvc": 1.3.19
"@swc/core-win32-ia32-msvc": 1.3.19
"@swc/core-win32-x64-msvc": 1.3.19
dependenciesMeta:
"@swc/core-darwin-arm64":
optional: true
@@ -3073,7 +3073,7 @@ __metadata:
optional: true
bin:
swcx: run_swcx.js
checksum: 4361252c928c487a02f526aecd8f3072b923244234c2701916944cf13c252b6d5ce2466caf3e0797d3e92e71a89d4a044f8577e51e8f7fe3af0fe30d94e94b13
checksum: 752499e18f81df789a9737936b2a83ea1db34bb2f983c80b394766796d853fa7d30701b52b9cb30ba943ad15286d8316603149683f4c553a5493aa78d89a76e0
languageName: node
linkType: hard
+887 -780
View File
File diff suppressed because it is too large Load Diff