Merge branch 'master' into continue_dependencies_deprecations
Signed-off-by: Simon Jakobsson <31953373+Znarvl@users.noreply.github.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
@@ -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);
|
||||
```
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-explore': patch
|
||||
---
|
||||
|
||||
Adds styling to graph forcing it to always fill out the available space.
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-app-api': patch
|
||||
---
|
||||
|
||||
Added `lifecycleFactory` implementation.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
'@backstage/plugin-codescene': patch
|
||||
'@backstage/plugin-sonarqube': patch
|
||||
---
|
||||
|
||||
Updated dependency `rc-progress` to `3.4.1`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': patch
|
||||
---
|
||||
|
||||
Registered shutdown hook in experimental catalog plugin.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-defaults': patch
|
||||
---
|
||||
|
||||
Added `lifecycleFactory` to default service factories.
|
||||
@@ -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 |
@@ -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:
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
+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.
|
||||
|
||||
@@ -227,6 +227,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}'`,
|
||||
|
||||
Executable
+115
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user