Fix validation file
This commit is contained in:
+82
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button, makeStyles, Typography } from '@material-ui/core';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import { CodeSnippet, EmptyState } from '@backstage/core';
|
||||
|
||||
const COMPONENT_YAML = `# Example
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: example
|
||||
spec:
|
||||
type: service
|
||||
lifecycle: production
|
||||
owner: guest
|
||||
implementsApis:
|
||||
- example-api
|
||||
`;
|
||||
|
||||
const useStyles = makeStyles<BackstageTheme>(theme => ({
|
||||
code: {
|
||||
borderRadius: 6,
|
||||
margin: `${theme.spacing(2)}px 0px`,
|
||||
background: theme.palette.type === 'dark' ? '#444' : '#fff',
|
||||
},
|
||||
}));
|
||||
|
||||
export const MissingImplementsApisEmptyState = () => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<EmptyState
|
||||
missing="field"
|
||||
title="No APIs implemented by this entity"
|
||||
description={
|
||||
<Typography>
|
||||
Components can implement APIs that are displayed on this page. You
|
||||
need to fill the <code>implementsApis</code> field to enable this
|
||||
tool.
|
||||
</Typography>
|
||||
}
|
||||
action={
|
||||
<>
|
||||
<Typography variant="body1">
|
||||
Link an API to your component as shown in the highlighted example
|
||||
below:
|
||||
</Typography>
|
||||
<div className={classes.code}>
|
||||
<CodeSnippet
|
||||
text={COMPONENT_YAML}
|
||||
language="yaml"
|
||||
showLineNumbers
|
||||
highlightedNumbers={[10, 11]}
|
||||
customStyle={{ background: 'inherit', fontSize: '115%' }}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
href="https://backstage.io/docs/features/software-catalog/descriptor-format#specimplementsapis-optional"
|
||||
>
|
||||
Read more
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -14,4 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { default } from './CostOverviewChartLegend';
|
||||
export { MissingImplementsApisEmptyState } from './MissingImplementsApisEmptyState';
|
||||
@@ -17,20 +17,17 @@
|
||||
import React from 'react';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { Route, Routes } from 'react-router';
|
||||
import { WarningPanel } from '@backstage/core';
|
||||
import { catalogRoute } from '../routes';
|
||||
import { EntityPageApi } from './EntityPageApi';
|
||||
import { MissingImplementsApisEmptyState } from './MissingImplementsApisEmptyState';
|
||||
|
||||
const isPluginApplicableToEntity = (entity: Entity) => {
|
||||
return ((entity.spec?.implementsApis as string[]) || []).length > 0;
|
||||
};
|
||||
|
||||
export const Router = ({ entity }: { entity: Entity }) =>
|
||||
// TODO(shmidt-i): move warning to a separate standardized component
|
||||
!isPluginApplicableToEntity(entity) ? (
|
||||
<WarningPanel title="API Docs plugin:">
|
||||
The entity doesn't implement any APIs.
|
||||
</WarningPanel>
|
||||
<MissingImplementsApisEmptyState />
|
||||
) : (
|
||||
<Routes>
|
||||
<Route
|
||||
|
||||
@@ -13,14 +13,12 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
|
||||
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import * as React from 'react';
|
||||
import { ApiEntityPage, getPageTheme } from './ApiEntityPage';
|
||||
import { ApiEntityPage } from './ApiEntityPage';
|
||||
|
||||
jest.mock('react-router-dom', () => {
|
||||
const actual = jest.requireActual('react-router-dom');
|
||||
@@ -71,32 +69,3 @@ describe('ApiEntityPage', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPageTheme', () => {
|
||||
const defaultPageTheme = getPageTheme();
|
||||
it.each(['service', 'app', 'library', 'tool', 'documentation', 'website'])(
|
||||
'should select right theme for predefined type: %p ̰ ',
|
||||
type => {
|
||||
const theme = getPageTheme(({
|
||||
spec: {
|
||||
type,
|
||||
},
|
||||
} as any) as Entity);
|
||||
expect(theme).toBeDefined();
|
||||
expect(theme).not.toBe(defaultPageTheme);
|
||||
},
|
||||
);
|
||||
|
||||
it('should select default theme for unknown/unspecified types', () => {
|
||||
const theme1 = getPageTheme(({
|
||||
spec: {
|
||||
type: 'unknown-type',
|
||||
},
|
||||
} as any) as Entity);
|
||||
const theme2 = getPageTheme(({
|
||||
spec: {},
|
||||
} as any) as Entity);
|
||||
expect(theme1).toBe(defaultPageTheme);
|
||||
expect(theme2).toBe(defaultPageTheme);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,8 +20,6 @@ import {
|
||||
errorApiRef,
|
||||
Header,
|
||||
Page,
|
||||
pageTheme,
|
||||
PageTheme,
|
||||
Progress,
|
||||
useApi,
|
||||
} from '@backstage/core';
|
||||
@@ -54,11 +52,6 @@ function headerProps(
|
||||
};
|
||||
}
|
||||
|
||||
export const getPageTheme = (entity?: Entity): PageTheme => {
|
||||
const themeKey = entity?.spec?.type?.toString() ?? 'home';
|
||||
return pageTheme[themeKey] ?? pageTheme.home;
|
||||
};
|
||||
|
||||
type EntityPageTitleProps = {
|
||||
title: string;
|
||||
entity: Entity | undefined;
|
||||
@@ -107,7 +100,7 @@ export const ApiEntityPage = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Page theme={getPageTheme(entity)}>
|
||||
<Page themeId={entity?.spec?.type?.toString() ?? 'home'}>
|
||||
<Header
|
||||
title={<EntityPageTitle title={headerTitle} entity={entity} />}
|
||||
pageTitleOverride={headerTitle}
|
||||
|
||||
@@ -14,22 +14,20 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Header, Page, pageTheme } from '@backstage/core';
|
||||
import { Header, Page } from '@backstage/core';
|
||||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ApiExplorerLayout = ({ children }: Props) => {
|
||||
return (
|
||||
<Page theme={pageTheme.home}>
|
||||
<Header
|
||||
title="APIs"
|
||||
subtitle="Backstage API Explorer"
|
||||
pageTitleOverride="APIs"
|
||||
/>
|
||||
{children}
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
export const ApiExplorerLayout = ({ children }: Props) => (
|
||||
<Page themeId="home">
|
||||
<Header
|
||||
title="APIs"
|
||||
subtitle="Backstage API Explorer"
|
||||
pageTitleOverride="APIs"
|
||||
/>
|
||||
{children}
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,11 @@
|
||||
|
||||
import { useAsyncRetry } from 'react-use';
|
||||
import { errorApiRef, useApi } from '@backstage/core';
|
||||
import { ApiEntity, ComponentEntity } from '@backstage/catalog-model';
|
||||
import {
|
||||
ApiEntity,
|
||||
ComponentEntity,
|
||||
parseEntityName,
|
||||
} from '@backstage/catalog-model';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog';
|
||||
import { useComponentApiNames } from './useComponentApiNames';
|
||||
|
||||
@@ -43,10 +47,20 @@ export function useComponentApiEntities({
|
||||
await Promise.all(
|
||||
apiNames.map(async name => {
|
||||
try {
|
||||
const api = (await catalogApi.getEntityByName({
|
||||
kind: 'API',
|
||||
name,
|
||||
})) as ApiEntity | undefined;
|
||||
const apiEntityName = parseEntityName(name, {
|
||||
defaultNamespace: entity.metadata.namespace,
|
||||
defaultKind: 'API',
|
||||
});
|
||||
|
||||
if (apiEntityName.kind !== 'API') {
|
||||
throw new Error(
|
||||
`Referenced entity of kind "${apiEntityName.kind}" as an API`,
|
||||
);
|
||||
}
|
||||
|
||||
const api = (await catalogApi.getEntityByName(apiEntityName)) as
|
||||
| ApiEntity
|
||||
| undefined;
|
||||
|
||||
if (api) {
|
||||
resultMap.set(api.metadata.name, api);
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import express from 'express';
|
||||
import passport from 'passport';
|
||||
import { InternalOAuthError } from 'passport-oauth2';
|
||||
import {
|
||||
executeRedirectStrategy,
|
||||
executeFrameHandlerStrategy,
|
||||
@@ -58,7 +59,11 @@ describe('PassportStrategyHelper', () => {
|
||||
}
|
||||
class MyCustomAuthErrorStrategy extends passport.Strategy {
|
||||
authenticate() {
|
||||
this.error(new Error('MyCustomAuth error'));
|
||||
this.error(
|
||||
new InternalOAuthError('MyCustomAuth error', {
|
||||
data: '{ "message": "Custom message" }',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
class MyCustomAuthRedirectStrategy extends passport.Strategy {
|
||||
@@ -97,7 +102,7 @@ describe('PassportStrategyHelper', () => {
|
||||
);
|
||||
expect(spyAuthenticate).toBeCalledTimes(1);
|
||||
await expect(frameHandlerStrategyPromise).rejects.toThrow(
|
||||
'Authentication failed, Error: MyCustomAuth error',
|
||||
'Authentication failed, MyCustomAuth error - Custom message',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import express from 'express';
|
||||
import passport from 'passport';
|
||||
import jwtDecoder from 'jwt-decode';
|
||||
import { ProfileInfo, RedirectInfo } from '../../providers/types';
|
||||
import { InternalOAuthError } from 'passport-oauth2';
|
||||
|
||||
export type PassportDoneCallback<Res, Private = never> = (
|
||||
err?: Error,
|
||||
@@ -95,8 +96,22 @@ export const executeFrameHandlerStrategy = async <T, PrivateInfo = never>(
|
||||
) => {
|
||||
reject(new Error(`Authentication rejected, ${info.message ?? ''}`));
|
||||
};
|
||||
strategy.error = (error: Error) => {
|
||||
reject(new Error(`Authentication failed, ${error}`));
|
||||
strategy.error = (error: InternalOAuthError) => {
|
||||
let message = `Authentication failed, ${error.message}`;
|
||||
|
||||
if (error.oauthError?.data) {
|
||||
try {
|
||||
const errorData = JSON.parse(error.oauthError.data);
|
||||
|
||||
if (errorData.message) {
|
||||
message += ` - ${errorData.message}`;
|
||||
}
|
||||
} catch (parseError) {
|
||||
message += ` - ${error.oauthError}`;
|
||||
}
|
||||
}
|
||||
|
||||
reject(new Error(message));
|
||||
};
|
||||
strategy.redirect = () => {
|
||||
reject(new Error('Unexpected redirect'));
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"winston": "^3.2.1",
|
||||
"yaml": "^1.9.2",
|
||||
"yn": "^4.0.0",
|
||||
"yup": "^0.29.1"
|
||||
"yup": "^0.29.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.25",
|
||||
@@ -53,7 +53,7 @@
|
||||
"@types/lodash": "^4.14.151",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"@types/uuid": "^8.0.0",
|
||||
"@types/yup": "^0.28.2",
|
||||
"@types/yup": "^0.29.8",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"msw": "^0.20.5",
|
||||
"supertest": "^4.0.2"
|
||||
|
||||
@@ -25,13 +25,13 @@ import { Logger } from 'winston';
|
||||
import { CatalogRulesEnforcer } from './CatalogRules';
|
||||
import * as result from './processors/results';
|
||||
import {
|
||||
LocationProcessor,
|
||||
LocationProcessorDataResult,
|
||||
LocationProcessorEmit,
|
||||
LocationProcessorEntityResult,
|
||||
LocationProcessorErrorResult,
|
||||
LocationProcessorLocationResult,
|
||||
LocationProcessorResult,
|
||||
CatalogProcessor,
|
||||
CatalogProcessorDataResult,
|
||||
CatalogProcessorEmit,
|
||||
CatalogProcessorEntityResult,
|
||||
CatalogProcessorErrorResult,
|
||||
CatalogProcessorLocationResult,
|
||||
CatalogProcessorResult,
|
||||
} from './processors/types';
|
||||
import { LocationReader, ReadLocationResult } from './types';
|
||||
|
||||
@@ -42,7 +42,7 @@ type Options = {
|
||||
reader: UrlReader;
|
||||
logger: Logger;
|
||||
config: Config;
|
||||
processors: LocationProcessor[];
|
||||
processors: CatalogProcessor[];
|
||||
rulesEnforcer: CatalogRulesEnforcer;
|
||||
};
|
||||
|
||||
@@ -60,11 +60,11 @@ export class LocationReaders implements LocationReader {
|
||||
const { rulesEnforcer, logger } = this.options;
|
||||
|
||||
const output: ReadLocationResult = { entities: [], errors: [] };
|
||||
let items: LocationProcessorResult[] = [result.location(location, false)];
|
||||
let items: CatalogProcessorResult[] = [result.location(location, false)];
|
||||
|
||||
for (let depth = 0; depth < MAX_DEPTH; ++depth) {
|
||||
const newItems: LocationProcessorResult[] = [];
|
||||
const emit: LocationProcessorEmit = i => newItems.push(i);
|
||||
const newItems: CatalogProcessorResult[] = [];
|
||||
const emit: CatalogProcessorEmit = i => newItems.push(i);
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type === 'location') {
|
||||
@@ -109,8 +109,8 @@ export class LocationReaders implements LocationReader {
|
||||
}
|
||||
|
||||
private async handleLocation(
|
||||
item: LocationProcessorLocationResult,
|
||||
emit: LocationProcessorEmit,
|
||||
item: CatalogProcessorLocationResult,
|
||||
emit: CatalogProcessorEmit,
|
||||
) {
|
||||
const { processors, logger } = this.options;
|
||||
|
||||
@@ -136,8 +136,8 @@ export class LocationReaders implements LocationReader {
|
||||
}
|
||||
|
||||
private async handleData(
|
||||
item: LocationProcessorDataResult,
|
||||
emit: LocationProcessorEmit,
|
||||
item: CatalogProcessorDataResult,
|
||||
emit: CatalogProcessorEmit,
|
||||
) {
|
||||
const { processors, logger } = this.options;
|
||||
|
||||
@@ -160,8 +160,8 @@ export class LocationReaders implements LocationReader {
|
||||
}
|
||||
|
||||
private async handleEntity(
|
||||
item: LocationProcessorEntityResult,
|
||||
emit: LocationProcessorEmit,
|
||||
item: CatalogProcessorEntityResult,
|
||||
emit: CatalogProcessorEmit,
|
||||
): Promise<Entity> {
|
||||
const { processors, logger } = this.options;
|
||||
|
||||
@@ -189,8 +189,8 @@ export class LocationReaders implements LocationReader {
|
||||
}
|
||||
|
||||
private async handleError(
|
||||
item: LocationProcessorErrorResult,
|
||||
emit: LocationProcessorEmit,
|
||||
item: CatalogProcessorErrorResult,
|
||||
emit: CatalogProcessorEmit,
|
||||
) {
|
||||
const { processors, logger } = this.options;
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
|
||||
import { Entity, LocationSpec } from '@backstage/catalog-model';
|
||||
import lodash from 'lodash';
|
||||
import { LocationProcessor } from './types';
|
||||
import { CatalogProcessor } from './types';
|
||||
|
||||
export class AnnotateLocationEntityProcessor implements LocationProcessor {
|
||||
export class AnnotateLocationEntityProcessor implements CatalogProcessor {
|
||||
async processEntity(entity: Entity, location: LocationSpec): Promise<Entity> {
|
||||
return lodash.merge(
|
||||
{
|
||||
|
||||
@@ -15,17 +15,17 @@
|
||||
*/
|
||||
|
||||
import { LocationSpec } from '@backstage/catalog-model';
|
||||
import fetch, { RequestInit, HeadersInit } from 'node-fetch';
|
||||
import * as result from './results';
|
||||
import { LocationProcessor, LocationProcessorEmit } from './types';
|
||||
import { Config } from '@backstage/config';
|
||||
import fetch, { HeadersInit, RequestInit } from 'node-fetch';
|
||||
import * as result from './results';
|
||||
import { CatalogProcessor, CatalogProcessorEmit } from './types';
|
||||
|
||||
// ***********************************************************************
|
||||
// * NOTE: This has been replaced by packages/backend-common/src/reading *
|
||||
// * Don't implement new functionality here as this file will be removed *
|
||||
// ***********************************************************************
|
||||
|
||||
export class AzureApiReaderProcessor implements LocationProcessor {
|
||||
export class AzureApiReaderProcessor implements CatalogProcessor {
|
||||
private privateToken: string;
|
||||
|
||||
constructor(config: Config) {
|
||||
@@ -54,7 +54,7 @@ export class AzureApiReaderProcessor implements LocationProcessor {
|
||||
async readLocation(
|
||||
location: LocationSpec,
|
||||
optional: boolean,
|
||||
emit: LocationProcessorEmit,
|
||||
emit: CatalogProcessorEmit,
|
||||
): Promise<boolean> {
|
||||
if (location.type !== 'azure/api') {
|
||||
return false;
|
||||
|
||||
@@ -15,17 +15,17 @@
|
||||
*/
|
||||
|
||||
import { LocationSpec } from '@backstage/catalog-model';
|
||||
import fetch, { RequestInit, HeadersInit } from 'node-fetch';
|
||||
import * as result from './results';
|
||||
import { LocationProcessor, LocationProcessorEmit } from './types';
|
||||
import { Config } from '@backstage/config';
|
||||
import fetch, { HeadersInit, RequestInit } from 'node-fetch';
|
||||
import * as result from './results';
|
||||
import { CatalogProcessor, CatalogProcessorEmit } from './types';
|
||||
|
||||
// ***********************************************************************
|
||||
// * NOTE: This has been replaced by packages/backend-common/src/reading *
|
||||
// * Don't implement new functionality here as this file will be removed *
|
||||
// ***********************************************************************
|
||||
|
||||
export class BitbucketApiReaderProcessor implements LocationProcessor {
|
||||
export class BitbucketApiReaderProcessor implements CatalogProcessor {
|
||||
private username: string;
|
||||
private password: string;
|
||||
|
||||
@@ -58,7 +58,7 @@ export class BitbucketApiReaderProcessor implements LocationProcessor {
|
||||
async readLocation(
|
||||
location: LocationSpec,
|
||||
optional: boolean,
|
||||
emit: LocationProcessorEmit,
|
||||
emit: CatalogProcessorEmit,
|
||||
): Promise<boolean> {
|
||||
if (location.type !== 'bitbucket/api') {
|
||||
return false;
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
resolveCodeOwner,
|
||||
} from './CodeOwnersProcessor';
|
||||
|
||||
describe(CodeOwnersProcessor, () => {
|
||||
describe('CodeOwnersProcessor', () => {
|
||||
const mockUrl = ({ basePath = '' } = {}): string =>
|
||||
`https://github.com/spotify/backstage/blob/master/${basePath}catalog-info.yaml`;
|
||||
const mockLocation = ({
|
||||
@@ -79,7 +79,7 @@ describe(CodeOwnersProcessor, () => {
|
||||
return data;
|
||||
};
|
||||
|
||||
describe(buildUrl, () => {
|
||||
describe('buildUrl', () => {
|
||||
it.each([['azure.com'], ['dev.azure.com']])(
|
||||
'should throw not implemented error',
|
||||
source => {
|
||||
@@ -99,7 +99,7 @@ describe(CodeOwnersProcessor, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildCodeOwnerUrl, () => {
|
||||
describe('buildCodeOwnerUrl', () => {
|
||||
it('should build a location spec to the codeowners', () => {
|
||||
expect(buildCodeOwnerUrl(mockUrl(), '/docs/CODEOWNERS')).toEqual(
|
||||
'https://github.com/spotify/backstage/blob/master/docs/CODEOWNERS',
|
||||
@@ -116,13 +116,13 @@ describe(CodeOwnersProcessor, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseCodeOwners, () => {
|
||||
describe('parseCodeOwners', () => {
|
||||
it('should parse the codeowners file', () => {
|
||||
expect(parseCodeOwners(mockCodeOwnersText())).toEqual(mockCodeOwners());
|
||||
});
|
||||
});
|
||||
|
||||
describe(normalizeCodeOwner, () => {
|
||||
describe('normalizeCodeOwner', () => {
|
||||
it('should remove org from org/team format', () => {
|
||||
expect(normalizeCodeOwner('@acme/foo')).toBe('foo');
|
||||
});
|
||||
@@ -139,13 +139,13 @@ describe(CodeOwnersProcessor, () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe(findPrimaryCodeOwner, () => {
|
||||
describe('findPrimaryCodeOwner', () => {
|
||||
it('should return the primary owner', () => {
|
||||
expect(findPrimaryCodeOwner(mockCodeOwners())).toBe('backstage-core');
|
||||
});
|
||||
});
|
||||
|
||||
describe(findRawCodeOwners, () => {
|
||||
describe('findRawCodeOwners', () => {
|
||||
it('should return found codeowner', async () => {
|
||||
const ownersText = mockCodeOwnersText();
|
||||
const read = jest
|
||||
@@ -184,7 +184,7 @@ describe(CodeOwnersProcessor, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveCodeOwner, () => {
|
||||
describe('resolveCodeOwner', () => {
|
||||
it('should return found codeowner', async () => {
|
||||
const read = jest
|
||||
.fn()
|
||||
@@ -207,7 +207,7 @@ describe(CodeOwnersProcessor, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(CodeOwnersProcessor, () => {
|
||||
describe('CodeOwnersProcessor', () => {
|
||||
const setupTest = ({ kind = 'Component', spec = {} } = {}) => {
|
||||
const entity = { kind, spec };
|
||||
const read = jest
|
||||
|
||||
@@ -16,14 +16,13 @@
|
||||
|
||||
import { UrlReader } from '@backstage/backend-common';
|
||||
import { Entity, LocationSpec } from '@backstage/catalog-model';
|
||||
import { LocationProcessor } from './types';
|
||||
import * as codeowners from 'codeowners-utils';
|
||||
import { CodeOwnersEntry } from 'codeowners-utils';
|
||||
import parseGitUri from 'git-url-parse';
|
||||
import { filter, head, get, pipe, reverse } from 'lodash/fp';
|
||||
|
||||
// NOTE: This can be removed when ES2021 is implemented
|
||||
import 'core-js/features/promise';
|
||||
import parseGitUri from 'git-url-parse';
|
||||
import { filter, get, head, pipe, reverse } from 'lodash/fp';
|
||||
import { CatalogProcessor } from './types';
|
||||
|
||||
const ALLOWED_LOCATION_TYPES = [
|
||||
'azure/api',
|
||||
@@ -38,7 +37,7 @@ type Options = {
|
||||
reader: UrlReader;
|
||||
};
|
||||
|
||||
export class CodeOwnersProcessor implements LocationProcessor {
|
||||
export class CodeOwnersProcessor implements CatalogProcessor {
|
||||
constructor(private readonly options: Options) {}
|
||||
|
||||
async processEntity(entity: Entity, location: LocationSpec): Promise<Entity> {
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
*/
|
||||
|
||||
import { Entity, EntityPolicy } from '@backstage/catalog-model';
|
||||
import { LocationProcessor } from './types';
|
||||
import { CatalogProcessor } from './types';
|
||||
|
||||
export class EntityPolicyProcessor implements LocationProcessor {
|
||||
export class EntityPolicyProcessor implements CatalogProcessor {
|
||||
private readonly policy: EntityPolicy;
|
||||
|
||||
constructor(policy: EntityPolicy) {
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
import { LocationSpec } from '@backstage/catalog-model';
|
||||
import fs from 'fs-extra';
|
||||
import * as result from './results';
|
||||
import { LocationProcessor, LocationProcessorEmit } from './types';
|
||||
import { CatalogProcessor, CatalogProcessorEmit } from './types';
|
||||
|
||||
export class FileReaderProcessor implements LocationProcessor {
|
||||
export class FileReaderProcessor implements CatalogProcessor {
|
||||
async readLocation(
|
||||
location: LocationSpec,
|
||||
optional: boolean,
|
||||
emit: LocationProcessorEmit,
|
||||
emit: CatalogProcessorEmit,
|
||||
): Promise<boolean> {
|
||||
if (location.type !== 'file') {
|
||||
return false;
|
||||
|
||||
@@ -19,14 +19,14 @@ import { Config } from '@backstage/config';
|
||||
import { graphql } from '@octokit/graphql';
|
||||
import { Logger } from 'winston';
|
||||
import * as results from './results';
|
||||
import { LocationProcessor, LocationProcessorEmit } from './types';
|
||||
import { CatalogProcessor, CatalogProcessorEmit } from './types';
|
||||
import { getOrganizationTeams, getOrganizationUsers } from './util/github';
|
||||
import { buildOrgHierarchy } from './util/org';
|
||||
|
||||
/**
|
||||
* Extracts teams and users out of a GitHub org.
|
||||
*/
|
||||
export class GithubOrgReaderProcessor implements LocationProcessor {
|
||||
export class GithubOrgReaderProcessor implements CatalogProcessor {
|
||||
private readonly providers: ProviderConfig[];
|
||||
private readonly logger: Logger;
|
||||
|
||||
@@ -45,7 +45,7 @@ export class GithubOrgReaderProcessor implements LocationProcessor {
|
||||
async readLocation(
|
||||
location: LocationSpec,
|
||||
_optional: boolean,
|
||||
emit: LocationProcessorEmit,
|
||||
emit: CatalogProcessorEmit,
|
||||
): Promise<boolean> {
|
||||
if (location.type !== 'github-org') {
|
||||
return false;
|
||||
|
||||
@@ -20,7 +20,7 @@ import parseGitUri from 'git-url-parse';
|
||||
import fetch, { HeadersInit, RequestInit } from 'node-fetch';
|
||||
import { Logger } from 'winston';
|
||||
import * as result from './results';
|
||||
import { LocationProcessor, LocationProcessorEmit } from './types';
|
||||
import { CatalogProcessor, CatalogProcessorEmit } from './types';
|
||||
|
||||
// ***********************************************************************
|
||||
// * NOTE: This has been replaced by packages/backend-common/src/reading *
|
||||
@@ -210,7 +210,7 @@ export function readConfig(config: Config, logger: Logger): ProviderConfig[] {
|
||||
* A processor that adds the ability to read files from GitHub v3 APIs, such as
|
||||
* the one exposed by GitHub itself.
|
||||
*/
|
||||
export class GithubReaderProcessor implements LocationProcessor {
|
||||
export class GithubReaderProcessor implements CatalogProcessor {
|
||||
private providers: ProviderConfig[];
|
||||
|
||||
static fromConfig(config: Config, logger: Logger) {
|
||||
@@ -224,7 +224,7 @@ export class GithubReaderProcessor implements LocationProcessor {
|
||||
async readLocation(
|
||||
location: LocationSpec,
|
||||
optional: boolean,
|
||||
emit: LocationProcessorEmit,
|
||||
emit: CatalogProcessorEmit,
|
||||
): Promise<boolean> {
|
||||
// The github/api type is for backward compatibility
|
||||
if (location.type !== 'github' && location.type !== 'github/api') {
|
||||
|
||||
@@ -15,17 +15,17 @@
|
||||
*/
|
||||
|
||||
import { LocationSpec } from '@backstage/catalog-model';
|
||||
import fetch, { RequestInit, HeadersInit } from 'node-fetch';
|
||||
import * as result from './results';
|
||||
import { LocationProcessor, LocationProcessorEmit } from './types';
|
||||
import { Config } from '@backstage/config';
|
||||
import fetch, { HeadersInit, RequestInit } from 'node-fetch';
|
||||
import * as result from './results';
|
||||
import { CatalogProcessor, CatalogProcessorEmit } from './types';
|
||||
|
||||
// ***********************************************************************
|
||||
// * NOTE: This has been replaced by packages/backend-common/src/reading *
|
||||
// * Don't implement new functionality here as this file will be removed *
|
||||
// ***********************************************************************
|
||||
|
||||
export class GitlabApiReaderProcessor implements LocationProcessor {
|
||||
export class GitlabApiReaderProcessor implements CatalogProcessor {
|
||||
private privateToken: string;
|
||||
|
||||
constructor(config: Config) {
|
||||
@@ -50,7 +50,7 @@ export class GitlabApiReaderProcessor implements LocationProcessor {
|
||||
async readLocation(
|
||||
location: LocationSpec,
|
||||
optional: boolean,
|
||||
emit: LocationProcessorEmit,
|
||||
emit: CatalogProcessorEmit,
|
||||
): Promise<boolean> {
|
||||
if (location.type !== 'gitlab/api') {
|
||||
return false;
|
||||
|
||||
@@ -17,18 +17,18 @@
|
||||
import { LocationSpec } from '@backstage/catalog-model';
|
||||
import fetch from 'node-fetch';
|
||||
import * as result from './results';
|
||||
import { LocationProcessor, LocationProcessorEmit } from './types';
|
||||
import { CatalogProcessor, CatalogProcessorEmit } from './types';
|
||||
|
||||
// ***********************************************************************
|
||||
// * NOTE: This has been replaced by packages/backend-common/src/reading *
|
||||
// * Don't implement new functionality here as this file will be removed *
|
||||
// ***********************************************************************
|
||||
|
||||
export class GitlabReaderProcessor implements LocationProcessor {
|
||||
export class GitlabReaderProcessor implements CatalogProcessor {
|
||||
async readLocation(
|
||||
location: LocationSpec,
|
||||
optional: boolean,
|
||||
emit: LocationProcessorEmit,
|
||||
emit: CatalogProcessorEmit,
|
||||
): Promise<boolean> {
|
||||
if (location.type !== 'gitlab') {
|
||||
return false;
|
||||
|
||||
@@ -24,12 +24,12 @@ import {
|
||||
readLdapOrg,
|
||||
} from './ldap';
|
||||
import * as results from './results';
|
||||
import { LocationProcessor, LocationProcessorEmit } from './types';
|
||||
import { CatalogProcessor, CatalogProcessorEmit } from './types';
|
||||
|
||||
/**
|
||||
* Extracts teams and users out of an LDAP server.
|
||||
*/
|
||||
export class LdapOrgReaderProcessor implements LocationProcessor {
|
||||
export class LdapOrgReaderProcessor implements CatalogProcessor {
|
||||
private readonly providers: LdapProviderConfig[];
|
||||
private readonly logger: Logger;
|
||||
|
||||
@@ -49,7 +49,7 @@ export class LdapOrgReaderProcessor implements LocationProcessor {
|
||||
async readLocation(
|
||||
location: LocationSpec,
|
||||
_optional: boolean,
|
||||
emit: LocationProcessorEmit,
|
||||
emit: CatalogProcessorEmit,
|
||||
): Promise<boolean> {
|
||||
if (location.type !== 'ldap-org') {
|
||||
return false;
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
|
||||
import { Entity, LocationEntity, LocationSpec } from '@backstage/catalog-model';
|
||||
import * as result from './results';
|
||||
import { LocationProcessor, LocationProcessorEmit } from './types';
|
||||
import { CatalogProcessor, CatalogProcessorEmit } from './types';
|
||||
|
||||
export class LocationRefProcessor implements LocationProcessor {
|
||||
export class LocationRefProcessor implements CatalogProcessor {
|
||||
async processEntity(
|
||||
entity: Entity,
|
||||
_location: LocationSpec,
|
||||
emit: LocationProcessorEmit,
|
||||
emit: CatalogProcessorEmit,
|
||||
): Promise<Entity> {
|
||||
if (entity.kind === 'Location') {
|
||||
const location = entity as LocationEntity;
|
||||
|
||||
@@ -18,7 +18,7 @@ import { UrlReader } from '@backstage/backend-common';
|
||||
import { Entity, LocationSpec } from '@backstage/catalog-model';
|
||||
import { JsonValue } from '@backstage/config';
|
||||
import yaml from 'yaml';
|
||||
import { LocationProcessor } from './types';
|
||||
import { CatalogProcessor } from './types';
|
||||
|
||||
export type ResolverRead = (url: string) => Promise<Buffer>;
|
||||
|
||||
@@ -42,7 +42,7 @@ type Options = {
|
||||
* Traverses raw entity JSON looking for occurrences of $-prefixed placeholders
|
||||
* that it then fills in with actual data.
|
||||
*/
|
||||
export class PlaceholderProcessor implements LocationProcessor {
|
||||
export class PlaceholderProcessor implements CatalogProcessor {
|
||||
constructor(private readonly options: Options) {}
|
||||
|
||||
async processEntity(entity: Entity, location: LocationSpec): Promise<Entity> {
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
*/
|
||||
|
||||
import { LocationSpec } from '@backstage/catalog-model';
|
||||
import * as result from './results';
|
||||
import { Config } from '@backstage/config';
|
||||
import { LocationProcessorEmit } from './types';
|
||||
import * as result from './results';
|
||||
import { CatalogProcessorEmit } from './types';
|
||||
|
||||
export class StaticLocationProcessor implements StaticLocationProcessor {
|
||||
static fromConfig(config: Config): StaticLocationProcessor {
|
||||
@@ -38,7 +38,7 @@ export class StaticLocationProcessor implements StaticLocationProcessor {
|
||||
async readLocation(
|
||||
location: LocationSpec,
|
||||
_optional: boolean,
|
||||
emit: LocationProcessorEmit,
|
||||
emit: CatalogProcessorEmit,
|
||||
): Promise<boolean> {
|
||||
if (location.type !== 'bootstrap') {
|
||||
return false;
|
||||
|
||||
@@ -14,16 +14,16 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { UrlReaderProcessor } from './UrlReaderProcessor';
|
||||
import {
|
||||
LocationProcessorDataResult,
|
||||
LocationProcessorResult,
|
||||
LocationProcessorErrorResult,
|
||||
} from './types';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { rest } from 'msw';
|
||||
import { getVoidLogger, UrlReaders } from '@backstage/backend-common';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import {
|
||||
CatalogProcessorDataResult,
|
||||
CatalogProcessorErrorResult,
|
||||
CatalogProcessorResult,
|
||||
} from './types';
|
||||
import { UrlReaderProcessor } from './UrlReaderProcessor';
|
||||
|
||||
describe('UrlReaderProcessor', () => {
|
||||
const mockApiOrigin = 'http://localhost:23000';
|
||||
@@ -48,9 +48,9 @@ describe('UrlReaderProcessor', () => {
|
||||
),
|
||||
);
|
||||
|
||||
const generated = (await new Promise<LocationProcessorResult>(emit =>
|
||||
const generated = (await new Promise<CatalogProcessorResult>(emit =>
|
||||
processor.readLocation(spec, false, emit),
|
||||
)) as LocationProcessorDataResult;
|
||||
)) as CatalogProcessorDataResult;
|
||||
|
||||
expect(generated.type).toBe('data');
|
||||
expect(generated.location).toBe(spec);
|
||||
@@ -72,9 +72,9 @@ describe('UrlReaderProcessor', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const generated = (await new Promise<LocationProcessorResult>(emit =>
|
||||
const generated = (await new Promise<CatalogProcessorResult>(emit =>
|
||||
processor.readLocation(spec, false, emit),
|
||||
)) as LocationProcessorErrorResult;
|
||||
)) as CatalogProcessorErrorResult;
|
||||
|
||||
expect(generated.type).toBe('error');
|
||||
expect(generated.location).toBe(spec);
|
||||
|
||||
@@ -14,11 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Logger } from 'winston';
|
||||
import { UrlReader } from '@backstage/backend-common';
|
||||
import { LocationSpec } from '@backstage/catalog-model';
|
||||
import { Logger } from 'winston';
|
||||
import * as result from './results';
|
||||
import { LocationProcessor, LocationProcessorEmit } from './types';
|
||||
import { CatalogProcessor, CatalogProcessorEmit } from './types';
|
||||
|
||||
// TODO(Rugvip): Added for backwards compatibility when moving to UrlReader, this
|
||||
// can be removed in a bit
|
||||
@@ -35,13 +35,13 @@ type Options = {
|
||||
logger: Logger;
|
||||
};
|
||||
|
||||
export class UrlReaderProcessor implements LocationProcessor {
|
||||
export class UrlReaderProcessor implements CatalogProcessor {
|
||||
constructor(private readonly options: Options) {}
|
||||
|
||||
async readLocation(
|
||||
location: LocationSpec,
|
||||
optional: boolean,
|
||||
emit: LocationProcessorEmit,
|
||||
emit: CatalogProcessorEmit,
|
||||
): Promise<boolean> {
|
||||
if (deprecatedTypes.includes(location.type)) {
|
||||
// TODO(Rugvip): Let's not enable this warning yet, as we want to move over the example YAMLs
|
||||
|
||||
@@ -14,14 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { YamlProcessor } from './YamlProcessor';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import yaml from 'yaml';
|
||||
import { TextEncoder } from 'util';
|
||||
import yaml from 'yaml';
|
||||
import {
|
||||
LocationProcessorEntityResult,
|
||||
LocationProcessorErrorResult,
|
||||
CatalogProcessorEntityResult,
|
||||
CatalogProcessorErrorResult,
|
||||
} from './types';
|
||||
import { YamlProcessor } from './YamlProcessor';
|
||||
|
||||
describe('YamlProcessor', () => {
|
||||
const processor = new YamlProcessor();
|
||||
@@ -82,7 +82,7 @@ describe('YamlProcessor', () => {
|
||||
|
||||
expect(await processor.parseData(buffer, locationSpec, emit)).toBe(true);
|
||||
|
||||
const e = emit.mock.calls[0][0] as LocationProcessorEntityResult;
|
||||
const e = emit.mock.calls[0][0] as CatalogProcessorEntityResult;
|
||||
expect(e.type).toBe('entity');
|
||||
expect(e.location).toBe(locationSpec);
|
||||
expect(e.entity).toEqual(entity);
|
||||
@@ -114,12 +114,12 @@ describe('YamlProcessor', () => {
|
||||
|
||||
expect(await processor.parseData(buffer, locationSpec, emit)).toBe(true);
|
||||
|
||||
const eComponent = emit.mock.calls[0][0] as LocationProcessorEntityResult;
|
||||
const eComponent = emit.mock.calls[0][0] as CatalogProcessorEntityResult;
|
||||
expect(eComponent.type).toBe('entity');
|
||||
expect(eComponent.location).toBe(locationSpec);
|
||||
expect(eComponent.entity).toEqual(entityComponent);
|
||||
|
||||
const eApi = emit.mock.calls[1][0] as LocationProcessorEntityResult;
|
||||
const eApi = emit.mock.calls[1][0] as CatalogProcessorEntityResult;
|
||||
expect(eApi.type).toBe('entity');
|
||||
expect(eApi.location).toBe(locationSpec);
|
||||
expect(eApi.entity).toEqual(entityApi);
|
||||
@@ -131,7 +131,7 @@ describe('YamlProcessor', () => {
|
||||
|
||||
expect(await processor.parseData(buffer, locationSpec, emit)).toBe(true);
|
||||
|
||||
const e = emit.mock.calls[0][0] as LocationProcessorErrorResult;
|
||||
const e = emit.mock.calls[0][0] as CatalogProcessorErrorResult;
|
||||
expect(e.error.message).toMatch(/^YAML error, /);
|
||||
expect(e.type).toBe('error');
|
||||
expect(e.location).toBe(locationSpec);
|
||||
@@ -143,7 +143,7 @@ describe('YamlProcessor', () => {
|
||||
|
||||
expect(await processor.parseData(buffer, locationSpec, emit)).toBe(true);
|
||||
|
||||
const e = emit.mock.calls[0][0] as LocationProcessorErrorResult;
|
||||
const e = emit.mock.calls[0][0] as CatalogProcessorErrorResult;
|
||||
expect(e.error.message).toMatch(/^Expected object at root, got /);
|
||||
expect(e.type).toBe('error');
|
||||
expect(e.location).toBe(locationSpec);
|
||||
|
||||
@@ -18,18 +18,18 @@ import { Entity, LocationSpec } from '@backstage/catalog-model';
|
||||
import lodash from 'lodash';
|
||||
import yaml from 'yaml';
|
||||
import * as result from './results';
|
||||
import { LocationProcessor, LocationProcessorEmit } from './types';
|
||||
import { CatalogProcessor, CatalogProcessorEmit } from './types';
|
||||
|
||||
/**
|
||||
* Handles incoming raw data buffers, and if they have a yaml extension,
|
||||
* attempts to parse them into structured data and emitting them as un-
|
||||
* validated entities.
|
||||
*/
|
||||
export class YamlProcessor implements LocationProcessor {
|
||||
export class YamlProcessor implements CatalogProcessor {
|
||||
async parseData(
|
||||
data: Buffer,
|
||||
location: LocationSpec,
|
||||
emit: LocationProcessorEmit,
|
||||
emit: CatalogProcessorEmit,
|
||||
): Promise<boolean> {
|
||||
if (!location.target.match(/\.ya?ml/)) {
|
||||
return false;
|
||||
|
||||
@@ -16,12 +16,12 @@
|
||||
|
||||
import { InputError, NotFoundError } from '@backstage/backend-common';
|
||||
import { Entity, LocationSpec } from '@backstage/catalog-model';
|
||||
import { LocationProcessorResult } from './types';
|
||||
import { CatalogProcessorResult } from './types';
|
||||
|
||||
export function notFoundError(
|
||||
atLocation: LocationSpec,
|
||||
message: string,
|
||||
): LocationProcessorResult {
|
||||
): CatalogProcessorResult {
|
||||
return {
|
||||
type: 'error',
|
||||
location: atLocation,
|
||||
@@ -32,7 +32,7 @@ export function notFoundError(
|
||||
export function inputError(
|
||||
atLocation: LocationSpec,
|
||||
message: string,
|
||||
): LocationProcessorResult {
|
||||
): CatalogProcessorResult {
|
||||
return {
|
||||
type: 'error',
|
||||
location: atLocation,
|
||||
@@ -43,27 +43,27 @@ export function inputError(
|
||||
export function generalError(
|
||||
atLocation: LocationSpec,
|
||||
message: string,
|
||||
): LocationProcessorResult {
|
||||
): CatalogProcessorResult {
|
||||
return { type: 'error', location: atLocation, error: new Error(message) };
|
||||
}
|
||||
|
||||
export function data(
|
||||
atLocation: LocationSpec,
|
||||
newData: Buffer,
|
||||
): LocationProcessorResult {
|
||||
): CatalogProcessorResult {
|
||||
return { type: 'data', location: atLocation, data: newData };
|
||||
}
|
||||
|
||||
export function location(
|
||||
newLocation: LocationSpec,
|
||||
optional: boolean,
|
||||
): LocationProcessorResult {
|
||||
): CatalogProcessorResult {
|
||||
return { type: 'location', location: newLocation, optional };
|
||||
}
|
||||
|
||||
export function entity(
|
||||
atLocation: LocationSpec,
|
||||
newEntity: Entity,
|
||||
): LocationProcessorResult {
|
||||
): CatalogProcessorResult {
|
||||
return { type: 'entity', location: atLocation, entity: newEntity };
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { Entity, LocationSpec } from '@backstage/catalog-model';
|
||||
|
||||
export type LocationProcessor = {
|
||||
export type CatalogProcessor = {
|
||||
/**
|
||||
* Reads the contents of a location.
|
||||
*
|
||||
@@ -28,7 +28,7 @@ export type LocationProcessor = {
|
||||
readLocation?(
|
||||
location: LocationSpec,
|
||||
optional: boolean,
|
||||
emit: LocationProcessorEmit,
|
||||
emit: CatalogProcessorEmit,
|
||||
): Promise<boolean>;
|
||||
|
||||
/**
|
||||
@@ -42,7 +42,7 @@ export type LocationProcessor = {
|
||||
parseData?(
|
||||
data: Buffer,
|
||||
location: LocationSpec,
|
||||
emit: LocationProcessorEmit,
|
||||
emit: CatalogProcessorEmit,
|
||||
): Promise<boolean>;
|
||||
|
||||
/**
|
||||
@@ -57,7 +57,7 @@ export type LocationProcessor = {
|
||||
processEntity?(
|
||||
entity: Entity,
|
||||
location: LocationSpec,
|
||||
emit: LocationProcessorEmit,
|
||||
emit: CatalogProcessorEmit,
|
||||
): Promise<Entity>;
|
||||
|
||||
/**
|
||||
@@ -71,40 +71,38 @@ export type LocationProcessor = {
|
||||
handleError?(
|
||||
error: Error,
|
||||
location: LocationSpec,
|
||||
emit: LocationProcessorEmit,
|
||||
emit: CatalogProcessorEmit,
|
||||
): Promise<void>;
|
||||
};
|
||||
|
||||
export type LocationProcessorEmit = (
|
||||
generated: LocationProcessorResult,
|
||||
) => void;
|
||||
export type CatalogProcessorEmit = (generated: CatalogProcessorResult) => void;
|
||||
|
||||
export type LocationProcessorLocationResult = {
|
||||
export type CatalogProcessorLocationResult = {
|
||||
type: 'location';
|
||||
location: LocationSpec;
|
||||
optional: boolean;
|
||||
};
|
||||
|
||||
export type LocationProcessorDataResult = {
|
||||
export type CatalogProcessorDataResult = {
|
||||
type: 'data';
|
||||
data: Buffer;
|
||||
location: LocationSpec;
|
||||
};
|
||||
|
||||
export type LocationProcessorEntityResult = {
|
||||
export type CatalogProcessorEntityResult = {
|
||||
type: 'entity';
|
||||
entity: Entity;
|
||||
location: LocationSpec;
|
||||
};
|
||||
|
||||
export type LocationProcessorErrorResult = {
|
||||
export type CatalogProcessorErrorResult = {
|
||||
type: 'error';
|
||||
error: Error;
|
||||
location: LocationSpec;
|
||||
};
|
||||
|
||||
export type LocationProcessorResult =
|
||||
| LocationProcessorLocationResult
|
||||
| LocationProcessorDataResult
|
||||
| LocationProcessorEntityResult
|
||||
| LocationProcessorErrorResult;
|
||||
export type CatalogProcessorResult =
|
||||
| CatalogProcessorLocationResult
|
||||
| CatalogProcessorDataResult
|
||||
| CatalogProcessorEntityResult
|
||||
| CatalogProcessorErrorResult;
|
||||
|
||||
@@ -18,9 +18,9 @@ import { getVoidLogger, UrlReader } from '@backstage/backend-common';
|
||||
import { Entity, LocationSpec } from '@backstage/catalog-model';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { DatabaseManager } from '../database';
|
||||
import { LocationProcessorEmit } from '../ingestion';
|
||||
import { CatalogBuilder, CatalogEnvironment } from './CatalogBuilder';
|
||||
import { CatalogProcessorEmit } from '../ingestion';
|
||||
import * as result from '../ingestion/processors/results';
|
||||
import { CatalogBuilder, CatalogEnvironment } from './CatalogBuilder';
|
||||
|
||||
describe('CatalogBuilder', () => {
|
||||
const db = DatabaseManager.createTestDatabaseConnection();
|
||||
@@ -54,7 +54,7 @@ describe('CatalogBuilder', () => {
|
||||
async readLocation(
|
||||
location: LocationSpec,
|
||||
_optional: boolean,
|
||||
emit: LocationProcessorEmit,
|
||||
emit: CatalogProcessorEmit,
|
||||
) {
|
||||
expect(location.type).toBe('test');
|
||||
emit(result.data(location, await reader.read('ignored')));
|
||||
@@ -67,7 +67,7 @@ describe('CatalogBuilder', () => {
|
||||
async parseData(
|
||||
data: Buffer,
|
||||
location: LocationSpec,
|
||||
emit: LocationProcessorEmit,
|
||||
emit: CatalogProcessorEmit,
|
||||
) {
|
||||
expect(data.toString()).toEqual('junk');
|
||||
emit(
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
AnnotateLocationEntityProcessor,
|
||||
AzureApiReaderProcessor,
|
||||
BitbucketApiReaderProcessor,
|
||||
CatalogProcessor,
|
||||
CodeOwnersProcessor,
|
||||
EntityPolicyProcessor,
|
||||
FileReaderProcessor,
|
||||
@@ -55,7 +56,6 @@ import {
|
||||
GitlabReaderProcessor,
|
||||
HigherOrderOperation,
|
||||
HigherOrderOperations,
|
||||
LocationProcessor,
|
||||
LocationReaders,
|
||||
LocationRefProcessor,
|
||||
PlaceholderProcessor,
|
||||
@@ -121,13 +121,13 @@ export class CatalogBuilder {
|
||||
private entityPoliciesReplace: boolean;
|
||||
private entityKinds: EntityPolicy[];
|
||||
private entityKindsReplace: boolean;
|
||||
private readerProcessors: LocationProcessor[];
|
||||
private readerProcessors: CatalogProcessor[];
|
||||
private readerProcessorsReplace: boolean;
|
||||
private parserProcessors: LocationProcessor[];
|
||||
private parserProcessors: CatalogProcessor[];
|
||||
private parserProcessorsReplace: boolean;
|
||||
private preProcessors: LocationProcessor[];
|
||||
private preProcessors: CatalogProcessor[];
|
||||
private preProcessorsReplace: boolean;
|
||||
private postProcessors: LocationProcessor[];
|
||||
private postProcessors: CatalogProcessor[];
|
||||
private postProcessorsReplace: boolean;
|
||||
private placeholderResolvers: Record<string, PlaceholderResolver>;
|
||||
private fieldFormatValidators: Partial<Validators>;
|
||||
@@ -219,7 +219,7 @@ export class CatalogBuilder {
|
||||
*
|
||||
* @param processors One or more processors
|
||||
*/
|
||||
addReaderProcessor(...processors: LocationProcessor[]): CatalogBuilder {
|
||||
addReaderProcessor(...processors: CatalogProcessor[]): CatalogBuilder {
|
||||
this.readerProcessors.push(...processors);
|
||||
return this;
|
||||
}
|
||||
@@ -234,7 +234,7 @@ export class CatalogBuilder {
|
||||
*
|
||||
* @param processors One or more processors
|
||||
*/
|
||||
replaceReaderProcessors(processors: LocationProcessor[]): CatalogBuilder {
|
||||
replaceReaderProcessors(processors: CatalogProcessor[]): CatalogBuilder {
|
||||
this.readerProcessors = [...processors];
|
||||
this.readerProcessorsReplace = true;
|
||||
return this;
|
||||
@@ -247,7 +247,7 @@ export class CatalogBuilder {
|
||||
*
|
||||
* @param processors One or more processors
|
||||
*/
|
||||
addParserProcessor(...processors: LocationProcessor[]): CatalogBuilder {
|
||||
addParserProcessor(...processors: CatalogProcessor[]): CatalogBuilder {
|
||||
this.parserProcessors.push(...processors);
|
||||
return this;
|
||||
}
|
||||
@@ -262,7 +262,7 @@ export class CatalogBuilder {
|
||||
*
|
||||
* @param processors One or more processors
|
||||
*/
|
||||
replaceParserProcessors(processors: LocationProcessor[]): CatalogBuilder {
|
||||
replaceParserProcessors(processors: CatalogProcessor[]): CatalogBuilder {
|
||||
this.parserProcessors = [...processors];
|
||||
this.parserProcessorsReplace = true;
|
||||
return this;
|
||||
@@ -274,7 +274,7 @@ export class CatalogBuilder {
|
||||
*
|
||||
* @param processors One or more processors
|
||||
*/
|
||||
addPreProcessor(...processors: LocationProcessor[]): CatalogBuilder {
|
||||
addPreProcessor(...processors: CatalogProcessor[]): CatalogBuilder {
|
||||
this.preProcessors.push(...processors);
|
||||
return this;
|
||||
}
|
||||
@@ -288,7 +288,7 @@ export class CatalogBuilder {
|
||||
*
|
||||
* @param processors One or more processors
|
||||
*/
|
||||
replacePreProcessors(processors: LocationProcessor[]): CatalogBuilder {
|
||||
replacePreProcessors(processors: CatalogProcessor[]): CatalogBuilder {
|
||||
this.preProcessors = [...processors];
|
||||
this.preProcessorsReplace = true;
|
||||
return this;
|
||||
@@ -300,7 +300,7 @@ export class CatalogBuilder {
|
||||
*
|
||||
* @param processors One or more processors
|
||||
*/
|
||||
addPostProcessor(...processors: LocationProcessor[]): CatalogBuilder {
|
||||
addPostProcessor(...processors: CatalogProcessor[]): CatalogBuilder {
|
||||
this.postProcessors.push(...processors);
|
||||
return this;
|
||||
}
|
||||
@@ -314,7 +314,7 @@ export class CatalogBuilder {
|
||||
*
|
||||
* @param processors One or more processors
|
||||
*/
|
||||
replacePostProcessors(processors: LocationProcessor[]): CatalogBuilder {
|
||||
replacePostProcessors(processors: CatalogProcessor[]): CatalogBuilder {
|
||||
this.postProcessors = [...processors];
|
||||
this.postProcessorsReplace = true;
|
||||
return this;
|
||||
@@ -418,7 +418,7 @@ export class CatalogBuilder {
|
||||
]);
|
||||
}
|
||||
|
||||
private buildProcessors(entityPolicy: EntityPolicy): LocationProcessor[] {
|
||||
private buildProcessors(entityPolicy: EntityPolicy): CatalogProcessor[] {
|
||||
const { config, reader } = this.env;
|
||||
|
||||
const placeholderResolvers = lodash.merge(
|
||||
@@ -441,7 +441,7 @@ export class CatalogBuilder {
|
||||
];
|
||||
}
|
||||
|
||||
private buildReaderProcessors(): LocationProcessor[] {
|
||||
private buildReaderProcessors(): CatalogProcessor[] {
|
||||
const { config, logger, reader } = this.env;
|
||||
|
||||
if (this.readerProcessorsReplace) {
|
||||
@@ -491,7 +491,7 @@ export class CatalogBuilder {
|
||||
];
|
||||
}
|
||||
|
||||
private buildParserProcessors(): LocationProcessor[] {
|
||||
private buildParserProcessors(): CatalogProcessor[] {
|
||||
if (this.parserProcessorsReplace) {
|
||||
return this.parserProcessors;
|
||||
}
|
||||
@@ -499,7 +499,7 @@ export class CatalogBuilder {
|
||||
return [new YamlProcessor(), ...this.parserProcessors];
|
||||
}
|
||||
|
||||
private buildPreProcessors(): LocationProcessor[] {
|
||||
private buildPreProcessors(): CatalogProcessor[] {
|
||||
const { reader } = this.env;
|
||||
|
||||
if (this.preProcessorsReplace) {
|
||||
@@ -509,7 +509,7 @@ export class CatalogBuilder {
|
||||
return [new CodeOwnersProcessor({ reader }), ...this.preProcessors];
|
||||
}
|
||||
|
||||
private buildPostProcessors(): LocationProcessor[] {
|
||||
private buildPostProcessors(): CatalogProcessor[] {
|
||||
if (this.postProcessorsReplace) {
|
||||
return this.postProcessors;
|
||||
}
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
configApiRef,
|
||||
Header,
|
||||
HomepageTimer,
|
||||
identityApiRef,
|
||||
Page,
|
||||
pageTheme,
|
||||
useApi,
|
||||
} from '@backstage/core';
|
||||
import React from 'react';
|
||||
@@ -33,12 +33,13 @@ const CatalogLayout = ({ children }: Props) => {
|
||||
const greeting = getTimeBasedGreeting();
|
||||
const profile = useApi(identityApiRef).getProfile();
|
||||
const userId = useApi(identityApiRef).getUserId();
|
||||
const orgName = useApi(configApiRef).getOptionalString('organization.name');
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.home}>
|
||||
<Page themeId="home">
|
||||
<Header
|
||||
title={`${greeting.greeting}, ${profile.displayName || userId}!`}
|
||||
subtitle="Backstage Service Catalog"
|
||||
subtitle={`${orgName || 'Backstage'} Service Catalog`}
|
||||
tooltip={greeting.language}
|
||||
pageTitleOverride="Home"
|
||||
>
|
||||
|
||||
@@ -17,15 +17,7 @@ import React, { useState, useContext } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
|
||||
import { EntityContext } from '../../hooks/useEntity';
|
||||
import {
|
||||
pageTheme,
|
||||
PageTheme,
|
||||
Page,
|
||||
Header,
|
||||
HeaderLabel,
|
||||
Content,
|
||||
Progress,
|
||||
} from '@backstage/core';
|
||||
import { Page, Header, HeaderLabel, Content, Progress } from '@backstage/core';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity';
|
||||
import { Box } from '@material-ui/core';
|
||||
@@ -34,11 +26,6 @@ import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEnti
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import { Tabbed } from './Tabbed';
|
||||
|
||||
const getPageTheme = (entity?: Entity): PageTheme => {
|
||||
const themeKey = entity?.spec?.type?.toString() ?? 'home';
|
||||
return pageTheme[themeKey] ?? pageTheme.home;
|
||||
};
|
||||
|
||||
const EntityPageTitle = ({
|
||||
entity,
|
||||
title,
|
||||
@@ -100,7 +87,7 @@ export const EntityPageLayout = ({
|
||||
const showRemovalDialog = () => setConfirmationDialogOpen(true);
|
||||
|
||||
return (
|
||||
<Page theme={getPageTheme(entity!)}>
|
||||
<Page themeId={entity?.spec?.type?.toString() ?? 'home'}>
|
||||
<Header
|
||||
title={<EntityPageTitle title={headerTitle} entity={entity!} />}
|
||||
pageTitleOverride={headerTitle}
|
||||
|
||||
@@ -85,7 +85,7 @@ export const UnregisterEntityDialog: FC<Props> = ({
|
||||
{error.toString()}
|
||||
</Alert>
|
||||
) : null}
|
||||
{entities ? (
|
||||
{entities?.length ? (
|
||||
<>
|
||||
<DialogContentText>
|
||||
This action will unregister the following entities:
|
||||
@@ -107,11 +107,11 @@ export const UnregisterEntityDialog: FC<Props> = ({
|
||||
</li>
|
||||
</ul>
|
||||
</Typography>
|
||||
<DialogContentText>
|
||||
To undo, just re-register the entity in Backstage.
|
||||
</DialogContentText>
|
||||
</>
|
||||
) : null}
|
||||
<DialogContentText>
|
||||
To undo, just re-register the entity in Backstage.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} color="primary">
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.esm.js",
|
||||
|
||||
@@ -79,9 +79,9 @@ costInsights:
|
||||
|
||||
### Metrics (Optional)
|
||||
|
||||
In the `Cost Overview` panel, users can choose from a dropdown of business metrics to see costs as they relate to a metric, such as daily active users. Metrics must be defined as keys on the `metrics` field. A user-friendly name is **required**. Metrics will be provided to the `getDailyCost` and `getProjectCosts` API methods via the `metric` parameter.
|
||||
In the `Cost Overview` panel, users can choose from a dropdown of business metrics to see costs as they relate to a metric, such as daily active users. Metrics must be defined as keys on the `metrics` field. A user-friendly name is **required**. Metrics will be provided to the `getDailyMetricData` API method via the `metric` parameter.
|
||||
|
||||
**Note:** Cost Insights displays daily cost without a metric by default. The dropdown text for this default can be overridden by assigning it a value on the `dailyCost` field.
|
||||
An optional `default` field can be set to `true` to set the default comparison metric to daily cost in the Cost Overview panel.
|
||||
|
||||
```yaml
|
||||
## ./app-config.yaml
|
||||
@@ -95,10 +95,9 @@ costInsights:
|
||||
name: Some Other Cloud Product
|
||||
icon: data
|
||||
metrics:
|
||||
dailyCost:
|
||||
name: Earth Rotation
|
||||
metricA:
|
||||
name: Metric A ## required
|
||||
default: true
|
||||
metricB:
|
||||
name: Metric B
|
||||
metricC:
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Project,
|
||||
ProductCost,
|
||||
Maybe,
|
||||
MetricData,
|
||||
} from '../types';
|
||||
|
||||
export type CostInsightsApi = {
|
||||
@@ -54,16 +55,10 @@ export type CostInsightsApi = {
|
||||
* reduction) and compare it to metrics important to the business.
|
||||
*
|
||||
* @param group The group id from getUserGroups or query parameters
|
||||
* @param metric A metric from the cost-insights configuration in app-config.yaml. The backend
|
||||
* should divide the actual daily cost by the corresponding metric for the same date.
|
||||
* @param intervals An ISO 8601 repeating interval string, such as R2/P1M/2020-09-01
|
||||
* https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals
|
||||
*/
|
||||
getGroupDailyCost(
|
||||
group: string,
|
||||
metric: string | null,
|
||||
intervals: string,
|
||||
): Promise<Cost>;
|
||||
getGroupDailyCost(group: string, intervals: string): Promise<Cost>;
|
||||
|
||||
/**
|
||||
* Get daily cost aggregations for a given billing entity (project in GCP, AWS has a similar
|
||||
@@ -78,16 +73,21 @@ export type CostInsightsApi = {
|
||||
* (or reduction) and compare it to metrics important to the business.
|
||||
*
|
||||
* @param project The project id from getGroupProjects or query parameters
|
||||
* @param metric A metric from the cost-insights configuration in app-config.yaml. The backend
|
||||
* should divide the actual daily cost by the corresponding metric for the same date.
|
||||
* @param intervals An ISO 8601 repeating interval string, such as R2/P1M/2020-09-01
|
||||
* https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals
|
||||
*/
|
||||
getProjectDailyCost(
|
||||
project: string,
|
||||
metric: string | null,
|
||||
intervals: string,
|
||||
): Promise<Cost>;
|
||||
getProjectDailyCost(project: string, intervals: string): Promise<Cost>;
|
||||
|
||||
/**
|
||||
* Get aggregations for a particular metric and interval timeframe. Teams
|
||||
* can see metrics important to their business in comparison to the growth
|
||||
* (or reduction) of a project or group's daily costs.
|
||||
*
|
||||
* @param metric A metric from the cost-insights configuration in app-config.yaml.
|
||||
* @param intervals An ISO 8601 repeating interval string, such as R2/P1M/2020-09-01
|
||||
* https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals
|
||||
*/
|
||||
getDailyMetricData(metric: string, intervals: string): Promise<MetricData>;
|
||||
|
||||
/**
|
||||
* Get cost aggregations for a particular cloud product and interval timeframe. This includes
|
||||
@@ -104,7 +104,7 @@ export type CostInsightsApi = {
|
||||
* @param product The product from the cost-insights configuration in app-config.yaml
|
||||
* @param group
|
||||
* @param duration A time duration, such as P1M. See the Duration type for a detailed explanation
|
||||
* of how the durations are interpreted in Cost Insights.
|
||||
* of how the durations are interpreted in Cost Insights.
|
||||
* @param project (optional) The project id from getGroupProjects or query parameters
|
||||
*/
|
||||
getProductInsights(
|
||||
|
||||
+2
-2
@@ -17,7 +17,7 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box, Button, Container, makeStyles } from '@material-ui/core';
|
||||
import ChevronLeftIcon from '@material-ui/icons/ChevronLeft';
|
||||
import { Header, Page, pageTheme } from '@backstage/core';
|
||||
import { Header, Page } from '@backstage/core';
|
||||
import { CostInsightsThemeProvider } from '../CostInsightsPage/CostInsightsThemeProvider';
|
||||
import { ConfigProvider, CurrencyProvider } from '../../hooks';
|
||||
|
||||
@@ -42,7 +42,7 @@ const AlertInstructionsLayout = ({
|
||||
<CostInsightsThemeProvider>
|
||||
<ConfigProvider>
|
||||
<CurrencyProvider>
|
||||
<Page theme={pageTheme.tool}>
|
||||
<Page themeId="tool">
|
||||
<Header
|
||||
title="Cost Insights"
|
||||
pageTitleOverride={title}
|
||||
|
||||
@@ -79,12 +79,14 @@ describe('<BarChart />', () => {
|
||||
it('Should display only 6 resources by default, sorted by cost', async () => {
|
||||
const rendered = await renderWithProps({} as BarChartProps);
|
||||
|
||||
MockResources.sort(resourceSort).forEach((resource, index) => {
|
||||
if (index < 6) {
|
||||
expect(rendered.getByText(resource.name!)).toBeInTheDocument();
|
||||
} else {
|
||||
expect(rendered.queryByText(resource.name!)).not.toBeInTheDocument();
|
||||
}
|
||||
const sorted = MockResources.sort(resourceSort);
|
||||
|
||||
expect(sorted.length).toBe(10);
|
||||
sorted.slice(0, 6).forEach(resource => {
|
||||
expect(rendered.getByText(resource.name!)).toBeInTheDocument();
|
||||
});
|
||||
sorted.slice(6).forEach(resource => {
|
||||
expect(rendered.queryByText(resource.name!)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -13,10 +13,9 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { makeStyles } from '@material-ui/core';
|
||||
import { Header, Page, pageTheme } from '@backstage/core';
|
||||
import { Header, Page } from '@backstage/core';
|
||||
import { Group } from '../../types';
|
||||
import CostInsightsTabs from '../CostInsightsTabs';
|
||||
|
||||
@@ -41,7 +40,7 @@ type CostInsightsLayoutProps = {
|
||||
const CostInsightsLayout = ({ groups, children }: CostInsightsLayoutProps) => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<Page theme={pageTheme.tool}>
|
||||
<Page themeId="tool">
|
||||
<Header
|
||||
style={{ boxShadow: 'none' }}
|
||||
title="Cost Insights"
|
||||
|
||||
+1
@@ -40,6 +40,7 @@ const mockMetrics: Metric[] = [
|
||||
{
|
||||
kind: 'some-metric',
|
||||
name: 'Some Metric',
|
||||
default: false,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -39,7 +39,14 @@ import {
|
||||
useCurrency,
|
||||
useConfig,
|
||||
} from '../../hooks';
|
||||
import { Alert, Cost, intervalsOf, Maybe, Project } from '../../types';
|
||||
import {
|
||||
Alert,
|
||||
Cost,
|
||||
intervalsOf,
|
||||
Maybe,
|
||||
MetricData,
|
||||
Project,
|
||||
} from '../../types';
|
||||
import { mapLoadingToProps } from './selector';
|
||||
import ProjectSelect from '../ProjectSelect';
|
||||
|
||||
@@ -48,15 +55,17 @@ const CostInsightsPage = () => {
|
||||
// There is not currently a UI to set feature flags
|
||||
// flags.set('cost-insights-currencies', FeatureFlagState.On);
|
||||
const client = useApi(costInsightsApiRef);
|
||||
const { currencies } = useConfig();
|
||||
const config = useConfig();
|
||||
const groups = useGroups();
|
||||
const [currency, setCurrency] = useCurrency();
|
||||
const [projects, setProjects] = useState<Maybe<Project[]>>(null);
|
||||
const [dailyCost, setDailyCost] = useState<Maybe<Cost>>(null);
|
||||
const [metricData, setMetricData] = useState<Maybe<MetricData>>(null);
|
||||
const [alerts, setAlerts] = useState<Maybe<Alert[]>>(null);
|
||||
const [error, setError] = useState<Maybe<Error>>(null);
|
||||
|
||||
const { pageFilters, setPageFilters } = useFilters(p => p);
|
||||
|
||||
const {
|
||||
loadingActions,
|
||||
loadingGroups,
|
||||
@@ -92,28 +101,26 @@ const CostInsightsPage = () => {
|
||||
try {
|
||||
if (pageFilters.group) {
|
||||
dispatchLoadingInsights(true);
|
||||
const intervals = intervalsOf(pageFilters.duration);
|
||||
const [
|
||||
fetchedProjects,
|
||||
fetchedCosts,
|
||||
fetchedAlerts,
|
||||
fetchedMetricData,
|
||||
fetchedDailyCost,
|
||||
] = await Promise.all([
|
||||
client.getGroupProjects(pageFilters.group),
|
||||
pageFilters.project
|
||||
? client.getProjectDailyCost(
|
||||
pageFilters.project,
|
||||
pageFilters.metric,
|
||||
intervalsOf(pageFilters.duration),
|
||||
)
|
||||
: client.getGroupDailyCost(
|
||||
pageFilters.group,
|
||||
pageFilters.metric,
|
||||
intervalsOf(pageFilters.duration),
|
||||
),
|
||||
client.getAlerts(pageFilters.group),
|
||||
pageFilters.metric
|
||||
? client.getDailyMetricData(pageFilters.metric, intervals)
|
||||
: null,
|
||||
pageFilters.project
|
||||
? client.getProjectDailyCost(pageFilters.project, intervals)
|
||||
: client.getGroupDailyCost(pageFilters.group, intervals),
|
||||
]);
|
||||
setProjects(fetchedProjects);
|
||||
setDailyCost(fetchedCosts);
|
||||
setAlerts(fetchedAlerts);
|
||||
setMetricData(fetchedMetricData);
|
||||
setDailyCost(fetchedDailyCost);
|
||||
} else {
|
||||
dispatchLoadingNone(loadingActions);
|
||||
}
|
||||
@@ -133,11 +140,11 @@ const CostInsightsPage = () => {
|
||||
}, [
|
||||
client,
|
||||
pageFilters,
|
||||
loadingActions,
|
||||
loadingGroups,
|
||||
dispatchLoadingInsights,
|
||||
dispatchLoadingInitial,
|
||||
dispatchLoadingNone,
|
||||
loadingActions,
|
||||
]);
|
||||
|
||||
if (loadingInitial) {
|
||||
@@ -166,7 +173,6 @@ const CostInsightsPage = () => {
|
||||
</CostInsightsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// These should be defined, alerts can be an empty array but that's truthy
|
||||
if (!dailyCost || !alerts) {
|
||||
return (
|
||||
@@ -195,7 +201,7 @@ const CostInsightsPage = () => {
|
||||
<Box mr={1}>
|
||||
<CurrencySelect
|
||||
currency={currency}
|
||||
currencies={currencies}
|
||||
currencies={config.currencies}
|
||||
onSelect={setCurrency}
|
||||
/>
|
||||
</Box>
|
||||
@@ -254,10 +260,8 @@ const CostInsightsPage = () => {
|
||||
<Box px={3} py={6}>
|
||||
{!!dailyCost.aggregation.length && (
|
||||
<CostOverviewCard
|
||||
change={dailyCost.change}
|
||||
aggregation={dailyCost.aggregation}
|
||||
trendline={dailyCost.trendline}
|
||||
projects={projects || []}
|
||||
dailyCostData={dailyCost}
|
||||
metricData={metricData}
|
||||
/>
|
||||
)}
|
||||
<WhyCostsMatter />
|
||||
|
||||
@@ -15,42 +15,47 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Card, CardContent, Divider } from '@material-ui/core';
|
||||
import CostOverviewChart from '../CostOverviewChart';
|
||||
import CostOverviewChartLegend from '../CostOverviewChartLegend';
|
||||
import { Box, Card, CardContent, Divider, useTheme } from '@material-ui/core';
|
||||
import CostGrowth from '../CostGrowth';
|
||||
import CostOverviewChart from './CostOverviewChart';
|
||||
import CostOverviewHeader from './CostOverviewHeader';
|
||||
import LegendItem from '../LegendItem';
|
||||
import MetricSelect from '../MetricSelect';
|
||||
import PeriodSelect from '../PeriodSelect';
|
||||
import { useScroll, useFilters, useConfig } from '../../hooks';
|
||||
import { mapFiltersToProps } from './selector';
|
||||
import { DefaultNavigation } from '../../utils/navigation';
|
||||
import { formatPercent } from '../../utils/formatters';
|
||||
import {
|
||||
ChangeStatistic,
|
||||
DateAggregation,
|
||||
Project,
|
||||
Trendline,
|
||||
Cost,
|
||||
CostInsightsTheme,
|
||||
MetricData,
|
||||
findAlways,
|
||||
getComparedChange,
|
||||
} from '../../types';
|
||||
|
||||
type CostOverviewCardProps = {
|
||||
change: ChangeStatistic;
|
||||
aggregation: Array<DateAggregation>;
|
||||
trendline: Trendline;
|
||||
projects: Array<Project>;
|
||||
export type CostOverviewCardProps = {
|
||||
dailyCostData: Cost;
|
||||
metricData: MetricData | null;
|
||||
};
|
||||
|
||||
const CostOverviewCard = ({
|
||||
change,
|
||||
aggregation,
|
||||
trendline,
|
||||
dailyCostData,
|
||||
metricData,
|
||||
}: CostOverviewCardProps) => {
|
||||
const { metrics } = useConfig();
|
||||
const theme = useTheme<CostInsightsTheme>();
|
||||
const config = useConfig();
|
||||
const { ScrollAnchor } = useScroll(DefaultNavigation.CostOverviewCard);
|
||||
const { setDuration, setProject, metric, setMetric, ...filters } = useFilters(
|
||||
const { setDuration, setProject, setMetric, ...filters } = useFilters(
|
||||
mapFiltersToProps,
|
||||
);
|
||||
|
||||
const { name } = findAlways(metrics, m => m.kind === metric);
|
||||
const metric = filters.metric
|
||||
? findAlways(config.metrics, m => m.kind === filters.metric)
|
||||
: null;
|
||||
const comparedChange = metricData
|
||||
? getComparedChange(dailyCostData, metricData)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card style={{ position: 'relative' }}>
|
||||
@@ -60,22 +65,50 @@ const CostOverviewCard = ({
|
||||
<PeriodSelect duration={filters.duration} onSelect={setDuration} />
|
||||
</CostOverviewHeader>
|
||||
<Divider />
|
||||
<Box marginY={1} display="flex" flexDirection="column">
|
||||
<CostOverviewChartLegend change={change} title={`${name} Trend`} />
|
||||
<Box my={1} display="flex" flexDirection="column">
|
||||
<Box display="flex" flexDirection="row">
|
||||
<Box mr={2}>
|
||||
<LegendItem title="Cost Trend" markerColor={theme.palette.blue}>
|
||||
{formatPercent(dailyCostData.change.ratio)}
|
||||
</LegendItem>
|
||||
</Box>
|
||||
{metric && metricData && comparedChange && (
|
||||
<>
|
||||
<Box mr={2}>
|
||||
<LegendItem
|
||||
title={`${metric.name} Trend`}
|
||||
markerColor={theme.palette.magenta}
|
||||
>
|
||||
{formatPercent(metricData.change.ratio)}
|
||||
</LegendItem>
|
||||
</Box>
|
||||
<LegendItem
|
||||
title={
|
||||
comparedChange.ratio <= 0 ? 'Your Savings' : 'Your Excess'
|
||||
}
|
||||
>
|
||||
<CostGrowth
|
||||
change={comparedChange}
|
||||
duration={filters.duration}
|
||||
/>
|
||||
</LegendItem>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
<CostOverviewChart
|
||||
responsive
|
||||
dailyCostData={dailyCostData}
|
||||
metric={metric}
|
||||
tooltip={name}
|
||||
aggregation={aggregation}
|
||||
trendline={trendline}
|
||||
metricData={metricData}
|
||||
/>
|
||||
</Box>
|
||||
<Box display="flex" justifyContent="flex-end" alignItems="center">
|
||||
<MetricSelect
|
||||
metric={metric}
|
||||
metrics={metrics}
|
||||
onSelect={setMetric}
|
||||
/>
|
||||
{config.metrics.length > 1 && (
|
||||
<MetricSelect
|
||||
metric={filters.metric}
|
||||
metrics={config.metrics}
|
||||
onSelect={setMetric}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { useTheme } from '@material-ui/core';
|
||||
import {
|
||||
ComposedChart,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
Area,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
TooltipPayload,
|
||||
} from 'recharts';
|
||||
import {
|
||||
ChartData,
|
||||
Cost,
|
||||
Maybe,
|
||||
Metric,
|
||||
MetricData,
|
||||
CostInsightsTheme,
|
||||
} from '../../types';
|
||||
import {
|
||||
overviewGraphTickFormatter,
|
||||
formatGraphValue,
|
||||
} from '../../utils/graphs';
|
||||
import CostOverviewTooltip from './CostOverviewTooltip';
|
||||
import { TooltipItemProps } from '../Tooltip';
|
||||
import { useCostOverviewStyles as useStyles } from '../../utils/styles';
|
||||
import { groupByDate, toDataMax, trendFrom } from '../../utils/charts';
|
||||
import { aggregationSort } from '../../utils/sort';
|
||||
|
||||
type CostOverviewChartProps = {
|
||||
metric: Maybe<Metric>;
|
||||
metricData: Maybe<MetricData>;
|
||||
dailyCostData: Cost;
|
||||
responsive?: boolean;
|
||||
};
|
||||
|
||||
const CostOverviewChart = ({
|
||||
dailyCostData,
|
||||
metric,
|
||||
metricData,
|
||||
responsive = true,
|
||||
}: CostOverviewChartProps) => {
|
||||
const theme = useTheme<CostInsightsTheme>();
|
||||
const styles = useStyles(theme);
|
||||
|
||||
const data = {
|
||||
dailyCost: {
|
||||
dataKey: 'dailyCost',
|
||||
name: `Daily Cost`,
|
||||
format: 'currency',
|
||||
data: dailyCostData,
|
||||
},
|
||||
metric: {
|
||||
dataKey: metric?.kind ?? 'Unknown',
|
||||
name: metric?.name ?? 'Unknown',
|
||||
format: metricData?.format ?? 'number',
|
||||
data: metricData,
|
||||
},
|
||||
};
|
||||
|
||||
const metricsByDate = data.metric.data
|
||||
? data.metric.data.aggregation.reduce(groupByDate, {})
|
||||
: {};
|
||||
|
||||
const chartData: ChartData[] = data.dailyCost.data.aggregation
|
||||
.slice()
|
||||
.sort(aggregationSort)
|
||||
.map(entry => ({
|
||||
date: Date.parse(entry.date),
|
||||
trend: trendFrom(data.dailyCost.data.trendline, Date.parse(entry.date)),
|
||||
dailyCost: entry.amount,
|
||||
...(metric && data.metric.data
|
||||
? { [data.metric.dataKey]: metricsByDate[`${entry.date}`] }
|
||||
: {}),
|
||||
}));
|
||||
|
||||
function tooltipFormatter(payload: TooltipPayload): TooltipItemProps {
|
||||
return {
|
||||
label:
|
||||
payload.dataKey === data.dailyCost.dataKey
|
||||
? data.dailyCost.name
|
||||
: data.metric.name,
|
||||
value:
|
||||
payload.dataKey === data.dailyCost.dataKey
|
||||
? formatGraphValue(payload.value as number, data.dailyCost.format)
|
||||
: formatGraphValue(payload.value as number, data.metric.format),
|
||||
fill:
|
||||
payload.dataKey === data.dailyCost.dataKey
|
||||
? theme.palette.blue
|
||||
: theme.palette.magenta,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer
|
||||
width={responsive ? '100%' : styles.container.width}
|
||||
height={styles.container.height}
|
||||
className="cost-overview-chart"
|
||||
>
|
||||
<ComposedChart margin={styles.chart.margin} data={chartData}>
|
||||
<CartesianGrid stroke={styles.cartesianGrid.stroke} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
domain={['dataMin', 'dataMax']}
|
||||
tickFormatter={overviewGraphTickFormatter}
|
||||
tickCount={6}
|
||||
type="number"
|
||||
stroke={styles.axis.fill}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[() => 0, 'dataMax']}
|
||||
tick={{ fill: styles.axis.fill }}
|
||||
tickFormatter={formatGraphValue}
|
||||
width={styles.yAxis.width}
|
||||
yAxisId={data.dailyCost.dataKey}
|
||||
/>
|
||||
{metric && (
|
||||
<YAxis
|
||||
hide
|
||||
domain={[() => 0, toDataMax(data.metric.dataKey, chartData)]}
|
||||
width={styles.yAxis.width}
|
||||
yAxisId={data.metric.dataKey}
|
||||
/>
|
||||
)}
|
||||
<Area
|
||||
dataKey={data.dailyCost.dataKey}
|
||||
isAnimationActive={false}
|
||||
fill={theme.palette.blue}
|
||||
fillOpacity={0.4}
|
||||
stroke="none"
|
||||
yAxisId={data.dailyCost.dataKey}
|
||||
/>
|
||||
<Line
|
||||
activeDot={false}
|
||||
dataKey="trend"
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
label={false}
|
||||
strokeWidth={2}
|
||||
stroke={theme.palette.blue}
|
||||
yAxisId={data.dailyCost.dataKey}
|
||||
/>
|
||||
{metric && (
|
||||
<Line
|
||||
dataKey={data.metric.dataKey}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
label={false}
|
||||
strokeWidth={2}
|
||||
stroke={theme.palette.magenta}
|
||||
yAxisId={data.metric.dataKey}
|
||||
/>
|
||||
)}
|
||||
<Tooltip
|
||||
content={
|
||||
<CostOverviewTooltip
|
||||
dataKeys={[data.dailyCost.dataKey, data.metric.dataKey]}
|
||||
format={tooltipFormatter}
|
||||
/>
|
||||
}
|
||||
animationDuration={100}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default CostOverviewChart;
|
||||
+8
-13
@@ -16,29 +16,24 @@
|
||||
import React from 'react';
|
||||
import moment from 'moment';
|
||||
import { TooltipPayload, TooltipProps } from 'recharts';
|
||||
import Tooltip from '../../components/Tooltip';
|
||||
import Tooltip, { TooltipItemProps } from '../../components/Tooltip';
|
||||
import { DEFAULT_DATE_FORMAT } from '../../types';
|
||||
import { formatGraphValue } from '../../utils/graphs';
|
||||
|
||||
type CostOverviewTooltipProps = TooltipProps & {
|
||||
metric: string;
|
||||
name: string;
|
||||
export type CostOverviewTooltipProps = TooltipProps & {
|
||||
dataKeys: Array<string>;
|
||||
format: (payload: TooltipPayload) => TooltipItemProps;
|
||||
};
|
||||
|
||||
const CostOverviewTooltip = ({
|
||||
label,
|
||||
payload,
|
||||
metric,
|
||||
name,
|
||||
dataKeys,
|
||||
format,
|
||||
}: CostOverviewTooltipProps) => {
|
||||
const tooltipLabel = moment(label).format(DEFAULT_DATE_FORMAT);
|
||||
const items = payload
|
||||
?.filter(data => data.name === metric)
|
||||
.map((data: TooltipPayload) => ({
|
||||
label: name,
|
||||
value: formatGraphValue(data.value as number),
|
||||
fill: data.fill as string,
|
||||
}));
|
||||
?.filter((p: TooltipPayload) => dataKeys.includes(p.dataKey as string))
|
||||
.map(p => format(p));
|
||||
return <Tooltip label={tooltipLabel} items={items} />;
|
||||
};
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import CostOverviewChart from './CostOverviewChart';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import React from 'react';
|
||||
import { DateAggregation, Trendline } from '../../types';
|
||||
import { CostInsightsThemeProvider } from '../CostInsightsPage/CostInsightsThemeProvider';
|
||||
|
||||
const mockAggregation = [
|
||||
{ date: '2020-04-01', amount: 100 },
|
||||
{ date: '2020-04-02', amount: 101 },
|
||||
{ date: '2020-04-03', amount: 102 },
|
||||
{ date: '2020-04-04', amount: 103 },
|
||||
] as Array<DateAggregation>;
|
||||
|
||||
const mockTrendline = { slope: 0.3, intercept: 101.5 } as Trendline;
|
||||
const mockMetric = 'mock-metric';
|
||||
|
||||
describe('<CostOverviewChart/>', () => {
|
||||
it('Renders without exploding', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<CostInsightsThemeProvider>
|
||||
<CostOverviewChart
|
||||
responsive={false}
|
||||
aggregation={mockAggregation}
|
||||
trendline={mockTrendline}
|
||||
metric={mockMetric}
|
||||
tooltip="Mock tooltip text"
|
||||
/>
|
||||
</CostInsightsThemeProvider>,
|
||||
);
|
||||
expect(
|
||||
rendered.container.querySelector('.cost-overview-chart'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
ComposedChart,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
Area,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import {
|
||||
Maybe,
|
||||
DateAggregation,
|
||||
Trendline,
|
||||
CostInsightsTheme,
|
||||
} from '../../types';
|
||||
import {
|
||||
overviewGraphTickFormatter,
|
||||
formatGraphValue,
|
||||
} from '../../utils/graphs';
|
||||
import CostOverviewTooltip from './CostOverviewTooltip';
|
||||
import { useTheme } from '@material-ui/core';
|
||||
import { useCostOverviewStyles as useStyles } from '../../utils/styles';
|
||||
import { NULL_METRIC } from '../../hooks/useConfig';
|
||||
|
||||
type CostOverviewChartProps = {
|
||||
responsive: boolean;
|
||||
aggregation: Array<DateAggregation>;
|
||||
trendline?: Maybe<Trendline>;
|
||||
metric: string | null;
|
||||
tooltip: string;
|
||||
};
|
||||
|
||||
const CostOverviewChart = ({
|
||||
responsive = true,
|
||||
aggregation,
|
||||
trendline,
|
||||
metric,
|
||||
tooltip,
|
||||
}: CostOverviewChartProps) => {
|
||||
const theme = useTheme<CostInsightsTheme>();
|
||||
const styles = useStyles(theme);
|
||||
|
||||
const id = metric ? metric : NULL_METRIC;
|
||||
|
||||
const dailyCostData = aggregation.map((entry: DateAggregation) => ({
|
||||
date: Date.parse(entry.date),
|
||||
[id]: entry.amount,
|
||||
trend: trendline
|
||||
? trendline.slope * (Date.parse(entry.date) / 1000) + trendline.intercept
|
||||
: null,
|
||||
}));
|
||||
|
||||
return (
|
||||
<ResponsiveContainer
|
||||
width={responsive ? '100%' : styles.container.width}
|
||||
height={styles.container.height}
|
||||
className="cost-overview-chart"
|
||||
>
|
||||
<ComposedChart margin={styles.chart.margin} data={dailyCostData}>
|
||||
<CartesianGrid stroke={styles.cartesianGrid.stroke} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
domain={['dataMin', 'dataMax']}
|
||||
tickFormatter={overviewGraphTickFormatter}
|
||||
tickCount={6}
|
||||
type="number"
|
||||
stroke={styles.axis.fill}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[() => 0, 'dataMax']}
|
||||
tick={{ fill: styles.axis.fill }}
|
||||
tickFormatter={formatGraphValue}
|
||||
width={styles.yAxis.width}
|
||||
yAxisId={id}
|
||||
/>
|
||||
<Area
|
||||
dataKey={id}
|
||||
isAnimationActive={false}
|
||||
fill={theme.palette.blue}
|
||||
fillOpacity={0.4}
|
||||
stroke="none"
|
||||
yAxisId={id}
|
||||
/>
|
||||
<Line
|
||||
activeDot={false}
|
||||
dataKey="trend"
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
label={false}
|
||||
strokeWidth={2}
|
||||
stroke={theme.palette.blue}
|
||||
yAxisId={id}
|
||||
/>
|
||||
<Tooltip
|
||||
content={<CostOverviewTooltip name={tooltip} metric={id} />}
|
||||
animationDuration={100}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default CostOverviewChart;
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import CostOverviewChartLegend from './CostOverviewChartLegend';
|
||||
import React from 'react';
|
||||
import { ChangeStatistic } from '../../types';
|
||||
|
||||
describe('<CostOverviewChartLegend />', () => {
|
||||
it('Correctly displays text if change is not supplied', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<CostOverviewChartLegend title="mock-metric-name" />,
|
||||
);
|
||||
expect(rendered.queryByText('Unclear')).toBeInTheDocument();
|
||||
});
|
||||
it('Correctly displays formatted change percentage', async () => {
|
||||
const change = {
|
||||
ratio: 0.3456,
|
||||
amount: 40000,
|
||||
} as ChangeStatistic;
|
||||
const rendered = await renderInTestApp(
|
||||
<CostOverviewChartLegend change={change} title="mock-metric-name" />,
|
||||
);
|
||||
expect(rendered.queryByText('Unclear')).not.toBeInTheDocument();
|
||||
expect(rendered.queryByText('35%')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Box, useTheme } from '@material-ui/core';
|
||||
import LegendItem from '../LegendItem';
|
||||
import { formatPercent } from '../../utils/formatters';
|
||||
import { ChangeStatistic, CostInsightsTheme } from '../../types';
|
||||
|
||||
type CostOverviewChartLegendProps = {
|
||||
change?: ChangeStatistic;
|
||||
title: string;
|
||||
tooltip?: string;
|
||||
};
|
||||
|
||||
const CostOverviewChartLegend = ({
|
||||
change,
|
||||
title,
|
||||
tooltip,
|
||||
}: CostOverviewChartLegendProps) => {
|
||||
const theme = useTheme<CostInsightsTheme>();
|
||||
|
||||
return (
|
||||
<Box marginRight={2}>
|
||||
<LegendItem
|
||||
title={title}
|
||||
markerColor={theme.palette.blue}
|
||||
tooltipText={tooltip}
|
||||
>
|
||||
{change ? formatPercent(change.ratio) : 'Unclear'}
|
||||
</LegendItem>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CostOverviewChartLegend;
|
||||
@@ -23,7 +23,7 @@ describe('<MetricSelect />', () => {
|
||||
it('should display a metric', async () => {
|
||||
const mockProps: MetricSelectProps = {
|
||||
metric: 'test',
|
||||
metrics: [{ kind: 'test', name: 'some-name' }],
|
||||
metrics: [{ kind: 'test', name: 'some-name', default: false }],
|
||||
onSelect: jest.fn(),
|
||||
};
|
||||
const { getByText } = await renderInTestApp(
|
||||
@@ -32,25 +32,12 @@ describe('<MetricSelect />', () => {
|
||||
expect(getByText(/some-name/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display a null metric', async () => {
|
||||
const mockProps: MetricSelectProps = {
|
||||
metric: null,
|
||||
metrics: [{ kind: null, name: 'billie-nullish' }],
|
||||
onSelect: jest.fn(),
|
||||
};
|
||||
const { getByText } = await renderInTestApp(
|
||||
<MetricSelect {...mockProps} />,
|
||||
);
|
||||
expect(getByText(/billie-nullish/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display all metrics', async () => {
|
||||
const mockProps: MetricSelectProps = {
|
||||
metric: null,
|
||||
metrics: [
|
||||
{ kind: null, name: 'billie-nullish' },
|
||||
{ kind: 'MAU1M', name: 'Cost Per Million MAU' },
|
||||
{ kind: 'my-cool-metric', name: 'metric-mcmetric-face' },
|
||||
{ kind: 'DAU', name: 'Daily Active Users', default: true },
|
||||
{ kind: 'MSC', name: 'Monthly Subscribers', default: false },
|
||||
],
|
||||
onSelect: jest.fn(),
|
||||
};
|
||||
@@ -61,11 +48,10 @@ describe('<MetricSelect />', () => {
|
||||
|
||||
UserEvent.click(button);
|
||||
|
||||
await waitFor(() => getAllByText(/billie-nullish/));
|
||||
await waitFor(() => getAllByText(/None/));
|
||||
|
||||
// The active metric should display in the popver list and in the input
|
||||
expect(getAllByText(/billie-nullish/).length).toBe(2);
|
||||
expect(getByText(/Cost Per Million MAU/)).toBeInTheDocument();
|
||||
expect(getByText(/metric-mcmetric-face/)).toBeInTheDocument();
|
||||
expect(getByText(/Daily Active Users/)).toBeInTheDocument();
|
||||
expect(getByText(/Monthly Subscribers/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,10 +15,9 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Select, MenuItem } from '@material-ui/core';
|
||||
import { Maybe, Metric, findAlways } from '../../types';
|
||||
import { InputLabel, FormControl, Select, MenuItem } from '@material-ui/core';
|
||||
import { Maybe, Metric } from '../../types';
|
||||
import { useSelectStyles as useStyles } from '../../utils/styles';
|
||||
import { NULL_METRIC } from '../../hooks/useConfig';
|
||||
|
||||
export type MetricSelectProps = {
|
||||
metric: Maybe<string>;
|
||||
@@ -29,38 +28,37 @@ export type MetricSelectProps = {
|
||||
const MetricSelect = ({ metric, metrics, onSelect }: MetricSelectProps) => {
|
||||
const classes = useStyles();
|
||||
|
||||
const handleOnChange = (e: React.ChangeEvent<{ value: unknown }>) => {
|
||||
if (e.target.value === NULL_METRIC) {
|
||||
function onChange(e: React.ChangeEvent<{ value: unknown }>) {
|
||||
if (e.target.value === 'none') {
|
||||
onSelect(null);
|
||||
} else {
|
||||
onSelect(e.target.value as string);
|
||||
}
|
||||
};
|
||||
|
||||
const renderValue = (value: unknown) => {
|
||||
const kind = (value === NULL_METRIC ? null : value) as Maybe<string>;
|
||||
const { name } = findAlways(metrics, m => m.kind === kind);
|
||||
return <b>{name}</b>;
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
className={classes.select}
|
||||
variant="outlined"
|
||||
value={metric || NULL_METRIC}
|
||||
renderValue={renderValue}
|
||||
onChange={handleOnChange}
|
||||
>
|
||||
{metrics.map((m: Metric) => (
|
||||
<MenuItem
|
||||
className={classes.menuItem}
|
||||
key={m.kind || NULL_METRIC}
|
||||
value={m.kind || NULL_METRIC}
|
||||
>
|
||||
{m.name}
|
||||
<FormControl variant="outlined">
|
||||
<InputLabel shrink id="metric-select-label">
|
||||
Compare to:
|
||||
</InputLabel>
|
||||
<Select
|
||||
id="metric-select"
|
||||
labelId="metric-select-label"
|
||||
labelWidth={100}
|
||||
className={classes.select}
|
||||
value={metric ?? 'none'}
|
||||
onChange={onChange}
|
||||
>
|
||||
<MenuItem className={classes.menuItem} key="none" value="none">
|
||||
<em>None</em>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{metrics.map((m: Metric) => (
|
||||
<MenuItem className={classes.menuItem} key={m.kind} value={m.kind}>
|
||||
<b>{m.name}</b>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -59,10 +59,9 @@ describe('<ProjectSelect />', () => {
|
||||
const button = getByRole(projectSelectContainer, 'button');
|
||||
UserEvent.click(button);
|
||||
await waitFor(() => rendered.getByTestId('option-all'));
|
||||
mockProjects.forEach(
|
||||
project =>
|
||||
project.id &&
|
||||
expect(rendered.getByText(project.id)).toBeInTheDocument(),
|
||||
|
||||
mockProjects.forEach(project =>
|
||||
expect(rendered.getByText(project.id)).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,12 +25,10 @@ import { useApi, configApiRef } from '@backstage/core';
|
||||
import { Config as BackstageConfig } from '@backstage/config';
|
||||
import { Currency, defaultCurrencies, Product, Icon, Metric } from '../types';
|
||||
import { getIcon } from '../utils/navigation';
|
||||
|
||||
export const NULL_METRIC = 'dailyCost';
|
||||
export const NULL_METRIC_NAME = 'Daily Cost';
|
||||
import { validateMetrics } from '../utils/config';
|
||||
|
||||
/*
|
||||
* Config schema 2020-09-28
|
||||
* Config schema 2020-10-15
|
||||
*
|
||||
* costInsights:
|
||||
* engineerCost: 200000
|
||||
@@ -44,6 +42,7 @@ export const NULL_METRIC_NAME = 'Daily Cost';
|
||||
* metrics:
|
||||
* metricA:
|
||||
* name: Metric A
|
||||
* default: true
|
||||
* metricB:
|
||||
* name: Metric B
|
||||
*/
|
||||
@@ -61,7 +60,7 @@ export const ConfigContext = createContext<ConfigContextProps | undefined>(
|
||||
);
|
||||
|
||||
const defaultState: ConfigContextProps = {
|
||||
metrics: [{ kind: null, name: NULL_METRIC_NAME }],
|
||||
metrics: [],
|
||||
products: [],
|
||||
icons: [],
|
||||
engineerCost: 0,
|
||||
@@ -87,8 +86,9 @@ export const ConfigProvider = ({ children }: { children: ReactNode }) => {
|
||||
const metrics = c.getOptionalConfig('costInsights.metrics');
|
||||
if (metrics) {
|
||||
return metrics.keys().map(key => ({
|
||||
kind: key === NULL_METRIC ? null : key,
|
||||
kind: key,
|
||||
name: metrics.getString(`${key}.name`),
|
||||
default: metrics.getOptionalBoolean(`${key}.default`) ?? false,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -115,23 +115,16 @@ export const ConfigProvider = ({ children }: { children: ReactNode }) => {
|
||||
const engineerCost = getEngineerCost();
|
||||
const icons = getIcons();
|
||||
|
||||
if (metrics.find((m: Metric) => m.kind === null)) {
|
||||
setConfig(prevState => ({
|
||||
...prevState,
|
||||
metrics,
|
||||
products,
|
||||
engineerCost,
|
||||
icons,
|
||||
}));
|
||||
} else {
|
||||
setConfig(prevState => ({
|
||||
...prevState,
|
||||
metrics: [...prevState.metrics, ...metrics],
|
||||
products,
|
||||
engineerCost,
|
||||
icons,
|
||||
}));
|
||||
}
|
||||
validateMetrics(metrics);
|
||||
|
||||
setConfig(prevState => ({
|
||||
...prevState,
|
||||
metrics,
|
||||
products,
|
||||
engineerCost,
|
||||
icons,
|
||||
}));
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -149,12 +142,7 @@ export const ConfigProvider = ({ children }: { children: ReactNode }) => {
|
||||
|
||||
export function useConfig(): ConfigContextProps {
|
||||
const config = useContext(ConfigContext);
|
||||
|
||||
if (!config) {
|
||||
assertNever();
|
||||
}
|
||||
|
||||
return config;
|
||||
return config ? config : assertNever();
|
||||
}
|
||||
|
||||
function assertNever(): never {
|
||||
|
||||
@@ -69,14 +69,14 @@ export const FilterContext = React.createContext<
|
||||
>(undefined);
|
||||
|
||||
export const FilterProvider = ({ children }: FilterProviderProps) => {
|
||||
const config = useConfig();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const queryParams = useQueryParams();
|
||||
const qsRef = useRef('');
|
||||
const groups = useGroups();
|
||||
const { products } = useConfig();
|
||||
|
||||
const defaultProductFilters = products.map(product => ({
|
||||
const defaultProductFilters = config.products.map(product => ({
|
||||
productType: product.kind,
|
||||
duration: Duration.P1M,
|
||||
}));
|
||||
@@ -101,7 +101,9 @@ export const FilterProvider = ({ children }: FilterProviderProps) => {
|
||||
|
||||
// TODO: Figure out why pageFilters doesn't get updated by the above when groups are loaded.
|
||||
useEffect(() => {
|
||||
setPageFilters(getInitialPageState(groups, queryParams.pageFilters));
|
||||
const initialState = getInitialPageState(groups, queryParams.pageFilters);
|
||||
const defaultMetric = config.metrics.find(m => m.default);
|
||||
setPageFilters({ ...initialState, metric: defaultMetric?.kind ?? null });
|
||||
}, [groups]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Cost } from './Cost';
|
||||
import { MetricData } from './MetricData';
|
||||
import { aggregationSort } from '../utils/sort';
|
||||
|
||||
export interface ChangeStatistic {
|
||||
// The ratio of change from one duration to another, expressed as: (newSum - oldSum) / oldSum
|
||||
ratio: number;
|
||||
@@ -46,3 +50,16 @@ export function growthOf(amount: number, ratio: number) {
|
||||
|
||||
return Growth.Negligible;
|
||||
}
|
||||
|
||||
// Used by <CostOverviewCard /> for displaying engineer totals
|
||||
export function getComparedChange(
|
||||
dailyCost: Cost,
|
||||
metricData: MetricData,
|
||||
): ChangeStatistic {
|
||||
const ratio = dailyCost.change.ratio - metricData.change.ratio;
|
||||
const amount = dailyCost.aggregation.slice().sort(aggregationSort)[0].amount;
|
||||
return {
|
||||
ratio: ratio,
|
||||
amount: amount * ratio,
|
||||
};
|
||||
}
|
||||
|
||||
+6
-1
@@ -14,4 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { default } from './CostOverviewChart';
|
||||
export type ChartData = {
|
||||
date: number;
|
||||
trend: number;
|
||||
dailyCost: number;
|
||||
[key: string]: number;
|
||||
};
|
||||
@@ -22,7 +22,7 @@ export interface PageFilters {
|
||||
group: Maybe<string>;
|
||||
project: Maybe<string>;
|
||||
duration: Duration;
|
||||
metric: Maybe<string>;
|
||||
metric: string | null;
|
||||
}
|
||||
|
||||
export type ProductFilters = Array<ProductPeriod>;
|
||||
|
||||
@@ -14,9 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Maybe } from './Maybe';
|
||||
|
||||
export type Metric = {
|
||||
kind: Maybe<string>;
|
||||
kind: string;
|
||||
name: string;
|
||||
default: boolean;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { DateAggregation } from './DateAggregation';
|
||||
import { ChangeStatistic } from './ChangeStatistic';
|
||||
|
||||
export interface MetricData {
|
||||
id: string;
|
||||
format: 'number' | 'currency';
|
||||
aggregation: DateAggregation[];
|
||||
change: ChangeStatistic;
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
export * from './Alert';
|
||||
export * from './ChangeStatistic';
|
||||
export * from './ChartData';
|
||||
export * from './Cost';
|
||||
export * from './DateAggregation';
|
||||
export * from './Duration';
|
||||
@@ -26,6 +27,7 @@ export * from './Filters';
|
||||
export * from './Group';
|
||||
export * from './Loading';
|
||||
export * from './Maybe';
|
||||
export * from './MetricData';
|
||||
export * from './Metric';
|
||||
export * from './Product';
|
||||
export * from './Project';
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { DateAggregation, Trendline, ChartData } from '../types';
|
||||
|
||||
export function trendFrom(trendline: Trendline, date: number): number {
|
||||
return trendline.slope * (date / 1000) + trendline.intercept;
|
||||
}
|
||||
|
||||
export function groupByDate(
|
||||
acc: Record<string, number>,
|
||||
entry: DateAggregation,
|
||||
): Record<string, number> {
|
||||
return { ...acc, [entry.date]: entry.amount };
|
||||
}
|
||||
|
||||
export function toMaxCost(acc: ChartData, entry: ChartData): ChartData {
|
||||
return acc.dailyCost > entry.dailyCost ? acc : entry;
|
||||
}
|
||||
|
||||
export function toDataMax(metric: string, data: ChartData[]): number {
|
||||
return (
|
||||
(data.reduce(toMaxCost).dailyCost / Math.abs(data[0].trend)) *
|
||||
data[0][metric]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Metric } from '../types';
|
||||
|
||||
export function validateMetrics(metrics: Metric[]) {
|
||||
const defaults = metrics.filter(metric => metric.default);
|
||||
if (defaults.length > 1) {
|
||||
throw new Error(
|
||||
`Only one default metric can be set at a time. Found ${defaults.length}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,10 +20,15 @@ import {
|
||||
lengthyCurrencyFormatter,
|
||||
} from './formatters';
|
||||
|
||||
export function formatGraphValue(value: number) {
|
||||
export function formatGraphValue(value: number, format?: string) {
|
||||
if (format === 'number') {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
if (value < 1) {
|
||||
return lengthyCurrencyFormatter.format(value);
|
||||
}
|
||||
|
||||
return currencyFormatter.format(value);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
ContentHeader,
|
||||
Header,
|
||||
Page,
|
||||
pageTheme,
|
||||
SupportButton,
|
||||
} from '@backstage/core';
|
||||
import ExploreCard, { CardData } from './ExploreCard';
|
||||
@@ -116,8 +115,9 @@ const toolsCards = [
|
||||
|
||||
export const ExplorePluginPage = () => {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.home}>
|
||||
<Page themeId="home">
|
||||
<Header
|
||||
title="Explore"
|
||||
subtitle="Tools and services available in Backstage"
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
HeaderLabel,
|
||||
InfoCard,
|
||||
Page,
|
||||
pageTheme,
|
||||
SimpleStepper,
|
||||
SimpleStepperStep,
|
||||
StructuredMetadataTable,
|
||||
@@ -111,20 +110,18 @@ const labels = (
|
||||
</>
|
||||
);
|
||||
|
||||
export const NewProjectPage = () => {
|
||||
return (
|
||||
<Page theme={pageTheme.service}>
|
||||
<Header title="New GCP Project" type="tool">
|
||||
{labels}
|
||||
</Header>
|
||||
<Content>
|
||||
<ContentHeader title="">
|
||||
<SupportButton>
|
||||
This plugin allows you to view and interact with your gcp projects.
|
||||
</SupportButton>
|
||||
</ContentHeader>
|
||||
<Project />
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
export const NewProjectPage = () => (
|
||||
<Page themeId="service">
|
||||
<Header title="New GCP Project" type="tool">
|
||||
{labels}
|
||||
</Header>
|
||||
<Content>
|
||||
<ContentHeader title="">
|
||||
<SupportButton>
|
||||
This plugin allows you to view and interact with your gcp projects.
|
||||
</SupportButton>
|
||||
</ContentHeader>
|
||||
<Project />
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -13,14 +13,12 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
Content,
|
||||
ContentHeader,
|
||||
Header,
|
||||
HeaderLabel,
|
||||
Page,
|
||||
pageTheme,
|
||||
SupportButton,
|
||||
useApi,
|
||||
WarningPanel,
|
||||
@@ -147,18 +145,16 @@ const labels = (
|
||||
</>
|
||||
);
|
||||
|
||||
export const ProjectDetailsPage = () => {
|
||||
return (
|
||||
<Page theme={pageTheme.service}>
|
||||
<Header title="GCP Project Details" type="other">
|
||||
{labels}
|
||||
</Header>
|
||||
<Content>
|
||||
<ContentHeader title="">
|
||||
<SupportButton>Support Button</SupportButton>
|
||||
</ContentHeader>
|
||||
<DetailsPage />
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
export const ProjectDetailsPage = () => (
|
||||
<Page themeId="service">
|
||||
<Header title="GCP Project Details" type="other">
|
||||
{labels}
|
||||
</Header>
|
||||
<Content>
|
||||
<ContentHeader title="">
|
||||
<SupportButton>Support Button</SupportButton>
|
||||
</ContentHeader>
|
||||
<DetailsPage />
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
*/
|
||||
|
||||
// NEEDS WORK
|
||||
|
||||
import {
|
||||
Content,
|
||||
ContentHeader,
|
||||
@@ -23,7 +22,6 @@ import {
|
||||
HeaderLabel,
|
||||
Link,
|
||||
Page,
|
||||
pageTheme,
|
||||
SupportButton,
|
||||
useApi,
|
||||
WarningPanel,
|
||||
@@ -134,21 +132,19 @@ const PageContents = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const ProjectListPage = () => {
|
||||
return (
|
||||
<Page theme={pageTheme.service}>
|
||||
<Header title="GCP Projects" type="tool">
|
||||
{labels}
|
||||
</Header>
|
||||
<Content>
|
||||
<ContentHeader title="">
|
||||
<Button variant="contained" color="primary" href="/gcp-projects/new">
|
||||
New Project
|
||||
</Button>
|
||||
<SupportButton>All your software catalog entities</SupportButton>
|
||||
</ContentHeader>
|
||||
<PageContents />
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
export const ProjectListPage = () => (
|
||||
<Page themeId="service">
|
||||
<Header title="GCP Projects" type="tool">
|
||||
{labels}
|
||||
</Header>
|
||||
<Content>
|
||||
<ContentHeader title="">
|
||||
<Button variant="contained" color="primary" href="/gcp-projects/new">
|
||||
New Project
|
||||
</Button>
|
||||
<SupportButton>All your software catalog entities</SupportButton>
|
||||
</ContentHeader>
|
||||
<PageContents />
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
Header,
|
||||
SupportButton,
|
||||
Page,
|
||||
pageTheme,
|
||||
Progress,
|
||||
HeaderLabel,
|
||||
useApi,
|
||||
@@ -91,7 +90,7 @@ const ClusterList: FC<{}> = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.home}>
|
||||
<Page themeId="home">
|
||||
<Header title="GitOps-managed Clusters">
|
||||
<HeaderLabel label="Welcome" value={githubUsername} />
|
||||
</Header>
|
||||
|
||||
@@ -13,13 +13,11 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC, useEffect, useState } from 'react';
|
||||
import {
|
||||
Content,
|
||||
Header,
|
||||
Page,
|
||||
pageTheme,
|
||||
Table,
|
||||
Progress,
|
||||
HeaderLabel,
|
||||
@@ -85,7 +83,7 @@ const ClusterPage: FC<{}> = () => {
|
||||
}, [pollingLog, api, params, githubAuth, githubAccessToken, githubUsername]);
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.home}>
|
||||
<Page themeId="home">
|
||||
<Header title={`Cluster ${params.owner}/${params.repo}`}>
|
||||
<HeaderLabel label="Welcome" value={githubUsername} />
|
||||
</Header>
|
||||
|
||||
@@ -18,7 +18,6 @@ import React, { FC, useEffect, useState } from 'react';
|
||||
import {
|
||||
Header,
|
||||
Page,
|
||||
pageTheme,
|
||||
Content,
|
||||
ContentHeader,
|
||||
HeaderLabel,
|
||||
@@ -259,7 +258,7 @@ const ProfileCatalog: FC<{}> = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.tool}>
|
||||
<Page themeId="tool">
|
||||
<Header
|
||||
title="Create GitOps-managed Cluster"
|
||||
subtitle="Kubernetes cluster with ready-to-use profiles"
|
||||
|
||||
@@ -19,6 +19,7 @@ import { GraphiQLPage } from './GraphiQLPage';
|
||||
import { ThemeProvider } from '@material-ui/core';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { renderWithEffects } from '@backstage/test-utils';
|
||||
import { GraphQLBrowseApi, graphQlBrowseApiRef } from '../../lib/api';
|
||||
|
||||
@@ -28,6 +29,7 @@ jest.mock('../GraphiQLBrowser', () => ({
|
||||
|
||||
describe('GraphiQLPage', () => {
|
||||
it('should show progress', async () => {
|
||||
jest.useFakeTimers();
|
||||
const loadingApi: GraphQLBrowseApi = {
|
||||
async getEndpoints() {
|
||||
await new Promise(() => {});
|
||||
@@ -43,9 +45,12 @@ describe('GraphiQLPage', () => {
|
||||
,
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(250);
|
||||
});
|
||||
rendered.getByText('GraphiQL');
|
||||
rendered.getByTestId('progress');
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should show error', async () => {
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Content,
|
||||
@@ -21,7 +20,6 @@ import {
|
||||
HeaderLabel,
|
||||
Page,
|
||||
Progress,
|
||||
pageTheme,
|
||||
useApi,
|
||||
} from '@backstage/core';
|
||||
import { useAsync } from 'react-use';
|
||||
@@ -60,7 +58,7 @@ export const GraphiQLPage = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.tool}>
|
||||
<Page themeId="tool">
|
||||
<Header title="GraphiQL">
|
||||
<HeaderLabel label="Owner" value="Spotify" />
|
||||
<HeaderLabel label="Lifecycle" value="Alpha" />
|
||||
|
||||
@@ -33,7 +33,7 @@ proxy:
|
||||
$env: JENKINS_BASIC_AUTH_HEADER
|
||||
```
|
||||
|
||||
4. Add an environment variable which contains the Jenkins credentials, (note: use an API token not your password)
|
||||
4. Add an environment variable which contains the Jenkins credentials, (note: use an API token not your password). Here user is the name of the user created in Jenkins.
|
||||
|
||||
```shell
|
||||
HEADER=$(echo -n user:api-token | base64)
|
||||
@@ -61,6 +61,21 @@ spec:
|
||||
|
||||
8. Click the component in the catalog you should now see Jenkins builds, and a last build result for your master build.
|
||||
|
||||
Note:
|
||||
|
||||
If you are not using environment variable then you can directly type API token in app-config.yaml
|
||||
|
||||
```yaml
|
||||
proxy:
|
||||
'/jenkins/api':
|
||||
target: 'http://localhost:8080' # your Jenkins URL
|
||||
changeOrigin: true
|
||||
headers:
|
||||
Authorization: Basic YWRtaW46MTFlYzI1NmU0Mzg1MDFjM2Y1Yzc2Yjc1MWE3ZTQ3YWY4Mw==
|
||||
```
|
||||
|
||||
YWRtaW46MTFlYzI1NmU0Mzg1MDFjM2Y1Yzc2Yjc1MWE3ZTQ3YWY4Mw== is the base64 of user and it's API token e.g. admin:11ec256e438501c3f5c76b751a7e47af83
|
||||
|
||||
## Features
|
||||
|
||||
- View all runs inside a folder
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
configApiRef,
|
||||
Content,
|
||||
Page,
|
||||
pageTheme,
|
||||
Progress,
|
||||
TabbedCard,
|
||||
useApi,
|
||||
@@ -148,7 +147,7 @@ export const KubernetesContent = ({ entity }: KubernetesContentProps) => {
|
||||
kubernetesObjects?.items.filter(r => r.errors.length > 0) ?? [];
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.tool}>
|
||||
<Page themeId="tool">
|
||||
<Content>
|
||||
<Grid container spacing={3} direction="column">
|
||||
{kubernetesObjects === undefined && error === undefined && (
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, FC, ReactNode } from 'react';
|
||||
import { useLocalStorage, useAsync } from 'react-use';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -28,7 +27,6 @@ import {
|
||||
ContentHeader,
|
||||
HeaderLabel,
|
||||
Progress,
|
||||
pageTheme,
|
||||
useApi,
|
||||
} from '@backstage/core';
|
||||
|
||||
@@ -95,7 +93,7 @@ const AuditList: FC<{}> = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.tool}>
|
||||
<Page themeId="tool">
|
||||
<Header
|
||||
title="Lighthouse"
|
||||
subtitle="Website audits powered by Lighthouse"
|
||||
@@ -120,7 +118,7 @@ const AuditList: FC<{}> = () => {
|
||||
</ContentHeader>
|
||||
<Grid container spacing={3} direction="column">
|
||||
<Grid item>
|
||||
<InfoCard>{content}</InfoCard>
|
||||
<InfoCard noPadding>{content}</InfoCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Content>
|
||||
|
||||
@@ -163,7 +163,7 @@ describe('AuditView', () => {
|
||||
});
|
||||
|
||||
describe('when the request for the website by id is pending', () => {
|
||||
it('it shows the loading', async () => {
|
||||
it('shows the loading', async () => {
|
||||
mockFetch.mockImplementationOnce(() => new Promise(() => {}));
|
||||
const rendered = render(
|
||||
wrapInTestApp(
|
||||
@@ -177,7 +177,7 @@ describe('AuditView', () => {
|
||||
});
|
||||
|
||||
describe('when the request for the website by id fails', () => {
|
||||
it('it shows an error', async () => {
|
||||
it('shows an error', async () => {
|
||||
mockFetch.mockRejectOnce(new Error('failed to fetch'));
|
||||
const rendered = render(
|
||||
wrapInTestApp(
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
import Alert from '@material-ui/lab/Alert';
|
||||
import {
|
||||
useApi,
|
||||
pageTheme,
|
||||
InfoCard,
|
||||
Header,
|
||||
Page,
|
||||
@@ -193,7 +192,7 @@ export const AuditViewContent: FC<{}> = () => {
|
||||
};
|
||||
|
||||
const ConnectedAuditView = () => (
|
||||
<Page theme={pageTheme.tool}>
|
||||
<Page themeId="tool">
|
||||
<Header title="Lighthouse" subtitle="Website audits powered by Lighthouse">
|
||||
<HeaderLabel label="Owner" value="Spotify" />
|
||||
<HeaderLabel label="Lifecycle" value="Alpha" />
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
InfoCard,
|
||||
Header,
|
||||
Page,
|
||||
pageTheme,
|
||||
Content,
|
||||
ContentHeader,
|
||||
HeaderLabel,
|
||||
@@ -170,7 +169,7 @@ export const CreateAuditContent: FC<{}> = () => {
|
||||
};
|
||||
|
||||
const CreateAudit = () => (
|
||||
<Page theme={pageTheme.tool}>
|
||||
<Page themeId="tool">
|
||||
<Header title="Lighthouse" subtitle="Website audits powered by Lighthouse">
|
||||
<HeaderLabel label="Owner" value="Spotify" />
|
||||
<HeaderLabel label="Lifecycle" value="Alpha" />
|
||||
|
||||
@@ -75,7 +75,7 @@ describe('useWebsiteForEntity', () => {
|
||||
(mockLighthouseApi.getWebsiteByUrl as jest.Mock).mockResolvedValue(website);
|
||||
});
|
||||
|
||||
it('returns the lighthouse information for the website url in annotations ', async () => {
|
||||
it('returns the lighthouse information for the website url in annotations', async () => {
|
||||
const { result, waitForNextUpdate } = subject();
|
||||
await waitForNextUpdate();
|
||||
expect(result.current?.value).toBe(website);
|
||||
|
||||
@@ -7,16 +7,38 @@ Website: [https://newrelic.com](https://newrelic.com)
|
||||
|
||||
## Getting Started
|
||||
|
||||
Add New Relic REST API Key to `app-config.yaml`
|
||||
This plugin uses the Backstage proxy to securely communicate with New Relic's
|
||||
APIs. Add the following to your `app-config.yaml` to enable this configuration:
|
||||
|
||||
```yaml
|
||||
newrelic:
|
||||
api:
|
||||
baseUrl: 'https://api.newrelic.com/v2'
|
||||
key: <NEW_RELIC_REST_API_KEY>
|
||||
proxy:
|
||||
'/newrelic/apm/api':
|
||||
target: https://api.newrelic.com/v2
|
||||
headers:
|
||||
X-Api-Key:
|
||||
$env: NEW_RELIC_REST_API_KEY
|
||||
```
|
||||
|
||||
New Relic Plugin Path: [/newrelic](http://localhost:3000/newrelic)
|
||||
In your production deployment of Backstage, you would also need to ensure that
|
||||
you've set the `NEW_RELIC_REST_API_KEY` environment variable before starting
|
||||
the backend.
|
||||
|
||||
While working locally, you may wish to hard-code your API key in your
|
||||
`app-config.local.yaml` like this:
|
||||
|
||||
```yaml
|
||||
# app-config.local.yaml
|
||||
proxy:
|
||||
'/newrelic/apm/api':
|
||||
headers:
|
||||
X-Api-Key: NRRA-YourActualApiKey
|
||||
```
|
||||
|
||||
Read more about how to find or generate this key in
|
||||
[New Relic's Documentation](https://docs.newrelic.com/docs/apis/get-started/intro-apis/types-new-relic-api-keys#rest-api-key).
|
||||
|
||||
See if it's working by visiting the New Relic Plugin Path:
|
||||
[/newrelic](http://localhost:3000/newrelic)
|
||||
|
||||
## Features
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createApiRef, DiscoveryApi } from '@backstage/core';
|
||||
|
||||
export type NewRelicApplication = {
|
||||
id: number;
|
||||
application_summary: NewRelicApplicationSummary;
|
||||
name: string;
|
||||
language: string;
|
||||
health_status: string;
|
||||
reporting: boolean;
|
||||
settings: NewRelicApplicationSettings;
|
||||
links?: NewRelicApplicationLinks;
|
||||
};
|
||||
|
||||
export type NewRelicApplicationSummary = {
|
||||
apdex_score: number;
|
||||
error_rate: number;
|
||||
host_count: number;
|
||||
instance_count: number;
|
||||
response_time: number;
|
||||
throughput: number;
|
||||
};
|
||||
|
||||
export type NewRelicApplicationSettings = {
|
||||
app_apdex_threshold: number;
|
||||
end_user_apdex_threshold: number;
|
||||
enable_real_user_monitoring: boolean;
|
||||
use_server_side_config: boolean;
|
||||
};
|
||||
|
||||
export type NewRelicApplicationLinks = {
|
||||
application_instances: Array<any>;
|
||||
servers: Array<any>;
|
||||
application_hosts: Array<any>;
|
||||
};
|
||||
|
||||
export type NewRelicApplications = {
|
||||
applications: NewRelicApplication[];
|
||||
};
|
||||
|
||||
export const newRelicApiRef = createApiRef<NewRelicApi>({
|
||||
id: 'plugin.newrelic.service',
|
||||
description: 'Used by the NewRelic plugin to make requests',
|
||||
});
|
||||
|
||||
const DEFAULT_PROXY_PATH_BASE = '/newrelic';
|
||||
|
||||
type Options = {
|
||||
discoveryApi: DiscoveryApi;
|
||||
/**
|
||||
* Path to use for requests via the proxy, defaults to /newrelic
|
||||
*/
|
||||
proxyPathBase?: string;
|
||||
};
|
||||
|
||||
export interface NewRelicApi {
|
||||
getApplications(): Promise<NewRelicApplications>;
|
||||
}
|
||||
|
||||
export class NewRelicClient implements NewRelicApi {
|
||||
private readonly discoveryApi: DiscoveryApi;
|
||||
private readonly proxyPathBase: string;
|
||||
|
||||
constructor(options: Options) {
|
||||
this.discoveryApi = options.discoveryApi;
|
||||
this.proxyPathBase = options.proxyPathBase ?? DEFAULT_PROXY_PATH_BASE;
|
||||
}
|
||||
|
||||
async getApplications(): Promise<NewRelicApplications> {
|
||||
const url = await this.getApiUrl('apm', 'applications.json');
|
||||
const response = await fetch(url);
|
||||
let responseJson;
|
||||
|
||||
try {
|
||||
responseJson = await response.json();
|
||||
} catch (e) {
|
||||
responseJson = { applications: [] };
|
||||
}
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(
|
||||
`Error communicating with New Relic: ${
|
||||
responseJson?.error?.title || response.statusText
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
return responseJson;
|
||||
}
|
||||
|
||||
private async getApiUrl(product: string, path: string) {
|
||||
const proxyUrl = await this.discoveryApi.getBaseUrl('proxy');
|
||||
return `${proxyUrl}${this.proxyPathBase}/${product}/api/${path}`;
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import { Grid } from '@material-ui/core';
|
||||
import {
|
||||
Header,
|
||||
Page,
|
||||
pageTheme,
|
||||
Content,
|
||||
ContentHeader,
|
||||
HeaderLabel,
|
||||
@@ -28,7 +27,7 @@ import {
|
||||
import NewRelicFetchComponent from '../NewRelicFetchComponent';
|
||||
|
||||
const NewRelicComponent: FC<{}> = () => (
|
||||
<Page theme={pageTheme.tool}>
|
||||
<Page themeId="tool">
|
||||
<Header title="New Relic">
|
||||
<HeaderLabel label="Owner" value="Engineering" />
|
||||
</Header>
|
||||
|
||||
@@ -15,52 +15,10 @@
|
||||
*/
|
||||
|
||||
import React, { FC } from 'react';
|
||||
import {
|
||||
configApiRef,
|
||||
Progress,
|
||||
Table,
|
||||
TableColumn,
|
||||
useApi,
|
||||
} from '@backstage/core';
|
||||
import { Progress, Table, TableColumn, useApi } from '@backstage/core';
|
||||
import Alert from '@material-ui/lab/Alert';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
type NewRelicApplication = {
|
||||
id: number;
|
||||
application_summary: NewRelicApplicationSummary;
|
||||
name: string;
|
||||
language: string;
|
||||
health_status: string;
|
||||
reporting: boolean;
|
||||
settings: NewRelicApplicationSettings;
|
||||
links?: NewRelicApplicationLinks;
|
||||
};
|
||||
|
||||
type NewRelicApplicationSummary = {
|
||||
apdex_score: number;
|
||||
error_rate: number;
|
||||
host_count: number;
|
||||
instance_count: number;
|
||||
response_time: number;
|
||||
throughput: number;
|
||||
};
|
||||
|
||||
type NewRelicApplicationSettings = {
|
||||
app_apdex_threshold: number;
|
||||
end_user_apdex_threshold: number;
|
||||
enable_real_user_monitoring: boolean;
|
||||
use_server_side_config: boolean;
|
||||
};
|
||||
|
||||
type NewRelicApplicationLinks = {
|
||||
application_instances: Array<any>;
|
||||
servers: Array<any>;
|
||||
application_hosts: Array<any>;
|
||||
};
|
||||
|
||||
type NewRelicApplications = {
|
||||
applications: NewRelicApplication[];
|
||||
};
|
||||
import { newRelicApiRef, NewRelicApplications } from '../../api';
|
||||
|
||||
export const NewRelicAPMTable: FC<NewRelicApplications> = ({
|
||||
applications,
|
||||
@@ -73,7 +31,7 @@ export const NewRelicAPMTable: FC<NewRelicApplications> = ({
|
||||
{ title: 'Instance Count', field: 'instanceCount' },
|
||||
{ title: 'Apdex', field: 'apdexScore' },
|
||||
];
|
||||
const data = applications.map((app: NewRelicApplication) => {
|
||||
const data = applications.map(app => {
|
||||
const { name, application_summary: applicationSummary } = app;
|
||||
const {
|
||||
response_time: responseTime,
|
||||
@@ -104,20 +62,11 @@ export const NewRelicAPMTable: FC<NewRelicApplications> = ({
|
||||
};
|
||||
|
||||
const NewRelicFetchComponent: FC<{}> = () => {
|
||||
const configApi = useApi(configApiRef);
|
||||
const apiBaseUrl = configApi.getString('newrelic.api.baseUrl');
|
||||
const apiKey = configApi.getString('newrelic.api.key');
|
||||
const api = useApi(newRelicApiRef);
|
||||
|
||||
const { value, loading, error } = useAsync(async (): Promise<
|
||||
NewRelicApplication[]
|
||||
> => {
|
||||
const response = await fetch(`${apiBaseUrl}/applications.json`, {
|
||||
headers: {
|
||||
'X-Api-Key': apiKey,
|
||||
},
|
||||
});
|
||||
const data: NewRelicApplications = await response.json();
|
||||
return data.applications.filter((application: NewRelicApplication) => {
|
||||
const { value, loading, error } = useAsync(async () => {
|
||||
const data = await api.getApplications();
|
||||
return data.applications.filter(application => {
|
||||
return application.hasOwnProperty('application_summary');
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -14,7 +14,13 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createPlugin, createRouteRef } from '@backstage/core';
|
||||
import {
|
||||
createApiFactory,
|
||||
createPlugin,
|
||||
createRouteRef,
|
||||
discoveryApiRef,
|
||||
} from '@backstage/core';
|
||||
import { NewRelicClient, newRelicApiRef } from './api';
|
||||
import NewRelicComponent from './components/NewRelicComponent';
|
||||
|
||||
export const rootRouteRef = createRouteRef({
|
||||
@@ -24,6 +30,13 @@ export const rootRouteRef = createRouteRef({
|
||||
|
||||
export const plugin = createPlugin({
|
||||
id: 'newrelic',
|
||||
apis: [
|
||||
createApiFactory({
|
||||
api: newRelicApiRef,
|
||||
deps: { discoveryApi: discoveryApiRef },
|
||||
factory: ({ discoveryApi }) => new NewRelicClient({ discoveryApi }),
|
||||
}),
|
||||
],
|
||||
register({ router }) {
|
||||
router.addRoute(rootRouteRef, NewRelicComponent);
|
||||
},
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"winston": "^3.2.1",
|
||||
"yaml": "^1.9.2",
|
||||
"yn": "^4.0.0",
|
||||
"yup": "^0.29.1"
|
||||
"yup": "^0.29.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.25",
|
||||
@@ -39,7 +39,7 @@
|
||||
"@types/node-fetch": "^2.5.7",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"@types/uuid": "^8.0.0",
|
||||
"@types/yup": "^0.28.2",
|
||||
"@types/yup": "^0.29.8",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"supertest": "^4.0.2"
|
||||
},
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ describe('RegisterComponentForm', () => {
|
||||
expect(screen.getByText('Submit').closest('button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should enable a submit button when the target url is set ', async () => {
|
||||
it('should enable a submit button when the target url is set', async () => {
|
||||
render(<RegisterComponentForm onSubmit={jest.fn()} />);
|
||||
|
||||
await act(async () => {
|
||||
|
||||
+1
-2
@@ -19,7 +19,6 @@ import { Grid, makeStyles } from '@material-ui/core';
|
||||
import {
|
||||
InfoCard,
|
||||
Page,
|
||||
pageTheme,
|
||||
Content,
|
||||
useApi,
|
||||
errorApiRef,
|
||||
@@ -113,7 +112,7 @@ export const RegisterComponentPage = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.home}>
|
||||
<Page themeId="home">
|
||||
<Header title="Register existing component" />
|
||||
<Content>
|
||||
<ContentHeader title="Start tracking your component in Backstage">
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Content, Header, Page, pageTheme } from '@backstage/core';
|
||||
import { Content, Header, Page } from '@backstage/core';
|
||||
import { RollbarProjectTable } from '../RollbarProjectTable/RollbarProjectTable';
|
||||
import { useRollbarEntities } from '../../hooks/useRollbarEntities';
|
||||
|
||||
@@ -23,7 +23,7 @@ export const RollbarHome = () => {
|
||||
const { entities, loading, error } = useRollbarEntities();
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.tool}>
|
||||
<Page themeId="tool">
|
||||
<Header
|
||||
title="Rollbar"
|
||||
subtitle="Real-time error tracking & debugging tools for developers"
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Content, Header, HeaderLabel, Page, pageTheme } from '@backstage/core';
|
||||
import { Content, Header, HeaderLabel, Page } from '@backstage/core';
|
||||
import { useCatalogEntity } from '../../hooks/useCatalogEntity';
|
||||
import { RollbarProject } from '../RollbarProject/RollbarProject';
|
||||
|
||||
@@ -23,7 +23,7 @@ export const RollbarProjectPage = () => {
|
||||
const { entity } = useCatalogEntity();
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.tool}>
|
||||
<Page themeId="tool">
|
||||
<Header title={entity?.metadata?.name} subtitle="Rollbar Project">
|
||||
<HeaderLabel label="Owner" value={entity?.spec?.owner} />
|
||||
<HeaderLabel label="Lifecycle" value={entity?.spec?.lifecycle} />
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
Header,
|
||||
Lifecycle,
|
||||
Page,
|
||||
pageTheme,
|
||||
Progress,
|
||||
SupportButton,
|
||||
useApi,
|
||||
@@ -66,7 +65,7 @@ export const ScaffolderPage = () => {
|
||||
}, [error, errorApi]);
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.home}>
|
||||
<Page themeId="home">
|
||||
<Header
|
||||
pageTitleOverride="Create a New Component"
|
||||
title={
|
||||
|
||||
@@ -13,8 +13,15 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { Button, pageTheme } from '@backstage/core';
|
||||
import { Card, Chip, makeStyles, Typography } from '@material-ui/core';
|
||||
import { Button } from '@backstage/core';
|
||||
import { BackstageTheme, pageTheme } from '@backstage/theme';
|
||||
import {
|
||||
Card,
|
||||
Chip,
|
||||
makeStyles,
|
||||
Typography,
|
||||
useTheme,
|
||||
} from '@material-ui/core';
|
||||
import React from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { templateRoute } from '../../routes';
|
||||
@@ -56,7 +63,10 @@ export const TemplateCard = ({
|
||||
type,
|
||||
name,
|
||||
}: TemplateCardProps) => {
|
||||
const theme = pageTheme[type] ?? pageTheme.other;
|
||||
const backstageTheme = useTheme<BackstageTheme>();
|
||||
|
||||
const themeId = pageTheme[type] ? type : 'other';
|
||||
const theme = backstageTheme.getPageTheme({ themeId });
|
||||
const classes = useStyles({ backgroundImage: theme.backgroundImage });
|
||||
const href = generatePath(templateRoute.path, { templateName: name });
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
Lifecycle,
|
||||
Page,
|
||||
useApi,
|
||||
pageTheme,
|
||||
} from '@backstage/core';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog';
|
||||
import { LinearProgress } from '@material-ui/core';
|
||||
@@ -145,7 +144,7 @@ export const TemplatePage = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.home}>
|
||||
<Page themeId="home">
|
||||
<Header
|
||||
pageTitleOverride="Create a new component"
|
||||
title={
|
||||
|
||||
@@ -19,7 +19,6 @@ import { Grid } from '@material-ui/core';
|
||||
import {
|
||||
Header,
|
||||
Page,
|
||||
pageTheme,
|
||||
Content,
|
||||
ContentHeader,
|
||||
SupportButton,
|
||||
@@ -33,7 +32,7 @@ const SentryPluginPage: FC<{}> = () => {
|
||||
const sentryProjectId = 'sample-sentry-project-id';
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.tool}>
|
||||
<Page themeId="tool">
|
||||
<Header title="Sentry" />
|
||||
<Content>
|
||||
<ContentHeader title="Issue on Sentry">
|
||||
|
||||
@@ -19,6 +19,7 @@ import { render, waitForElement } from '@testing-library/react';
|
||||
import { ThemeProvider } from '@material-ui/core';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
import { ApiRegistry, ApiProvider, errorApiRef } from '@backstage/core';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { withLogCollector } from '@backstage/test-utils';
|
||||
|
||||
import GetBBoxPolyfill from '../utils/polyfills/getBBox';
|
||||
@@ -34,8 +35,9 @@ describe('RadarComponent', () => {
|
||||
});
|
||||
|
||||
it('should render a progress bar', async () => {
|
||||
const errorApi = { post: () => {} };
|
||||
jest.useFakeTimers();
|
||||
|
||||
const errorApi = { post: () => {} };
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<ApiProvider apis={ApiRegistry.from([[errorApiRef, errorApi]])}>
|
||||
@@ -48,9 +50,13 @@ describe('RadarComponent', () => {
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(250);
|
||||
});
|
||||
expect(getByTestId('progress')).toBeInTheDocument();
|
||||
|
||||
await waitForElement(() => queryByTestId('tech-radar-svg'));
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should call the errorApi if load fails', async () => {
|
||||
|
||||
@@ -22,6 +22,7 @@ import { ApiRegistry, ApiProvider, errorApiRef } from '@backstage/core';
|
||||
|
||||
import GetBBoxPolyfill from '../utils/polyfills/getBBox';
|
||||
import { RadarPage } from './RadarPage';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { MockErrorApi, wrapInTestApp } from '@backstage/test-utils';
|
||||
|
||||
describe('RadarPage', () => {
|
||||
@@ -34,6 +35,8 @@ describe('RadarPage', () => {
|
||||
});
|
||||
|
||||
it('should render a progress bar', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const techRadarProps = {
|
||||
width: 1200,
|
||||
height: 800,
|
||||
@@ -48,9 +51,13 @@ describe('RadarPage', () => {
|
||||
),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(250);
|
||||
});
|
||||
expect(getByTestId('progress')).toBeInTheDocument();
|
||||
|
||||
await waitForElement(() => queryByTestId('tech-radar-svg'));
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should render a header with a svg', async () => {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Grid } from '@material-ui/core';
|
||||
import { Grid, makeStyles } from '@material-ui/core';
|
||||
import {
|
||||
Content,
|
||||
ContentHeader,
|
||||
@@ -23,11 +23,16 @@ import {
|
||||
Header,
|
||||
HeaderLabel,
|
||||
SupportButton,
|
||||
pageTheme,
|
||||
} from '@backstage/core';
|
||||
import RadarComponent from '../components/RadarComponent';
|
||||
import { TechRadarComponentProps } from '../api';
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
overflowXScroll: {
|
||||
overflowX: 'scroll',
|
||||
},
|
||||
}));
|
||||
|
||||
export type TechRadarPageProps = TechRadarComponentProps & {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
@@ -39,28 +44,31 @@ export const RadarPage = ({
|
||||
subtitle,
|
||||
pageTitle,
|
||||
...props
|
||||
}: TechRadarPageProps): JSX.Element => (
|
||||
<Page theme={pageTheme.tool}>
|
||||
<Header title={title} subtitle={subtitle}>
|
||||
<HeaderLabel label="Owner" value="Spotify" />
|
||||
<HeaderLabel label="Lifecycle" value="Beta" />
|
||||
</Header>
|
||||
<Content>
|
||||
<ContentHeader title={pageTitle}>
|
||||
<SupportButton>
|
||||
This is used for visualizing the official guidelines of different
|
||||
areas of software development such as languages, frameworks,
|
||||
infrastructure and processes.
|
||||
</SupportButton>
|
||||
</ContentHeader>
|
||||
<Grid container spacing={3} direction="row">
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<RadarComponent {...props} />
|
||||
}: TechRadarPageProps): JSX.Element => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<Page themeId="tool">
|
||||
<Header title={title} subtitle={subtitle}>
|
||||
<HeaderLabel label="Owner" value="Spotify" />
|
||||
<HeaderLabel label="Lifecycle" value="Beta" />
|
||||
</Header>
|
||||
<Content className={classes.overflowXScroll}>
|
||||
<ContentHeader title={pageTitle}>
|
||||
<SupportButton>
|
||||
This is used for visualizing the official guidelines of different
|
||||
areas of software development such as languages, frameworks,
|
||||
infrastructure and processes.
|
||||
</SupportButton>
|
||||
</ContentHeader>
|
||||
<Grid container spacing={3} direction="row">
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<RadarComponent {...props} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
RadarPage.defaultProps = {
|
||||
title: 'Tech Radar',
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
useApi,
|
||||
Content,
|
||||
Page,
|
||||
pageTheme,
|
||||
Header,
|
||||
} from '@backstage/core';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog';
|
||||
@@ -43,7 +42,7 @@ export const TechDocsHome = () => {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Page theme={pageTheme.documentation}>
|
||||
<Page themeId="documentation">
|
||||
<Header
|
||||
title="Documentation"
|
||||
subtitle="Documentation available in Backstage"
|
||||
@@ -57,7 +56,7 @@ export const TechDocsHome = () => {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Page theme={pageTheme.documentation}>
|
||||
<Page themeId="documentation">
|
||||
<Header
|
||||
title="Documentation"
|
||||
subtitle="Documentation available in Backstage"
|
||||
@@ -70,7 +69,7 @@ export const TechDocsHome = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.documentation}>
|
||||
<Page themeId="documentation">
|
||||
<Header
|
||||
title="Documentation"
|
||||
subtitle="Documentation available in Backstage"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user