Merge branch 'master' into ryanv/product-insights-intervals

This commit is contained in:
Ryan Vazquez
2020-12-09 15:50:17 -05:00
336 changed files with 9217 additions and 4434 deletions
+9
View File
@@ -9,6 +9,15 @@
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/api-docs"
},
"keywords": [
"backstage"
],
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
@@ -1,51 +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 { ComponentEntity, Entity } from '@backstage/catalog-model';
import { Progress } from '@backstage/core';
import { Grid } from '@material-ui/core';
import React from 'react';
import {
ApiDefinitionCard,
useComponentApiEntities,
useComponentApiNames,
} from '../../components';
type Props = {
entity: Entity;
};
export const EntityPageApi = ({ entity }: Props) => {
const apiNames = useComponentApiNames(entity as ComponentEntity);
const { apiEntities, loading } = useComponentApiEntities({
entity: entity as ComponentEntity,
});
if (loading) {
return <Progress />;
}
return (
<Grid container spacing={3}>
{apiNames.map(api => (
<Grid item xs={12} key={api}>
<ApiDefinitionCard apiEntity={apiEntities!.get(api)} />
</Grid>
))}
</Grid>
);
};
-40
View File
@@ -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 React from 'react';
import { Entity, RELATION_PROVIDES_API } from '@backstage/catalog-model';
import { Route, Routes } from 'react-router';
import { catalogRoute } from '../routes';
import { EntityPageApi } from './EntityPageApi';
import { MissingImplementsApisEmptyState } from './MissingImplementsApisEmptyState';
const isPluginApplicableToEntity = (entity: Entity) => {
// TODO: Also support RELATION_CONSUMES_API
return entity.relations?.some(r => r.type === RELATION_PROVIDES_API);
};
export const Router = ({ entity }: { entity: Entity }) =>
!isPluginApplicableToEntity(entity) ? (
<MissingImplementsApisEmptyState />
) : (
<Routes>
<Route
path={`/${catalogRoute.path}`}
element={<EntityPageApi entity={entity} />}
/>
)
</Routes>
);
@@ -0,0 +1,84 @@
/*
* 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 { ApiEntity } from '@backstage/catalog-model';
import { Table, TableColumn } from '@backstage/core';
import React from 'react';
import { ApiTypeTitle } from '../ApiDefinitionCard';
import { EntityLink } from '../EntityLink';
const columns: TableColumn<ApiEntity>[] = [
{
title: 'Name',
field: 'metadata.name',
highlight: true,
render: (entity: any) => (
<EntityLink entity={entity}>{entity.metadata.name}</EntityLink>
),
},
{
title: 'Owner',
field: 'spec.owner',
},
{
title: 'Lifecycle',
field: 'spec.lifecycle',
},
{
title: 'Type',
field: 'spec.type',
render: (entity: ApiEntity) => <ApiTypeTitle apiEntity={entity} />,
},
{
title: 'Description',
field: 'metadata.description',
width: 'auto',
},
];
type Props = {
title: string;
variant?: string;
entities: (ApiEntity | undefined)[];
};
export const ApisTable = ({ entities, title, variant = 'gridItem' }: Props) => {
const tableStyle: React.CSSProperties = {
minWidth: '0',
width: '100%',
};
if (variant === 'gridItem') {
tableStyle.height = 'calc(100% - 10px)';
}
return (
<Table<ApiEntity>
columns={columns}
title={title}
style={tableStyle}
options={{
// TODO: Toolbar padding if off compared to other cards, should be: padding: 16px 24px;
search: false,
paging: false,
actionsColumnIndex: -1,
padding: 'dense',
}}
// TODO: For now we skip all APIs that we can't find without a warning!
data={entities.filter(e => e !== undefined) as ApiEntity[]}
/>
);
};
@@ -0,0 +1,127 @@
/*
* 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 { Entity, RELATION_CONSUMES_API } from '@backstage/catalog-model';
import { ApiProvider, ApiRegistry } from '@backstage/core';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog';
import { renderInTestApp } from '@backstage/test-utils';
import { waitFor } from '@testing-library/react';
import React from 'react';
import { ApiDocsConfig, apiDocsConfigRef } from '../../config';
import { ConsumedApisCard } from './ConsumedApisCard';
describe('<ConsumedApisCard />', () => {
const apiDocsConfig: jest.Mocked<ApiDocsConfig> = {
getApiDefinitionWidget: jest.fn(),
} as any;
const catalogApi: jest.Mocked<CatalogApi> = {
getLocationById: jest.fn(),
getEntityByName: jest.fn(),
getEntities: jest.fn(),
addLocation: jest.fn(),
getLocationByEntity: jest.fn(),
removeEntityByUid: jest.fn(),
} as any;
let Wrapper: React.ComponentType;
beforeEach(() => {
const apis = ApiRegistry.with(catalogApiRef, catalogApi).with(
apiDocsConfigRef,
apiDocsConfig,
);
Wrapper = ({ children }: { children?: React.ReactNode }) => (
<ApiProvider apis={apis}>{children}</ApiProvider>
);
});
afterEach(() => jest.resetAllMocks());
it('shows empty list if no relations', async () => {
const entity: Entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'my-name',
namespace: 'my-namespace',
},
relations: [],
};
const { getByText } = await renderInTestApp(
<Wrapper>
<ConsumedApisCard entity={entity} />
</Wrapper>,
);
expect(getByText(/Consumed APIs/i)).toBeInTheDocument();
expect(getByText(/No APIs consumed by this entity/i)).toBeInTheDocument();
});
it('shows consumed APIs', async () => {
const entity: Entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'my-name',
namespace: 'my-namespace',
},
relations: [
{
target: {
kind: 'API',
namespace: 'my-namespace',
name: 'target-name',
},
type: RELATION_CONSUMES_API,
},
],
};
catalogApi.getEntityByName.mockResolvedValue({
apiVersion: 'v1',
kind: 'API',
metadata: {
name: 'target-name',
namespace: 'my-namespace',
},
spec: {
type: 'openapi',
owner: 'Test',
lifecycle: 'production',
definition: '...',
},
});
apiDocsConfig.getApiDefinitionWidget.mockReturnValue({
type: 'openapi',
title: 'OpenAPI',
component: () => <div />,
});
const { getByText } = await renderInTestApp(
<Wrapper>
<ConsumedApisCard entity={entity} />
</Wrapper>,
);
await waitFor(() => {
expect(getByText(/Consumed APIs/i)).toBeInTheDocument();
expect(getByText(/target-name/i)).toBeInTheDocument();
expect(getByText(/OpenAPI/)).toBeInTheDocument();
expect(getByText(/Test/i)).toBeInTheDocument();
expect(getByText(/production/i)).toBeInTheDocument();
});
});
});
@@ -0,0 +1,85 @@
/*
* 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 {
ApiEntity,
Entity,
RELATION_CONSUMES_API,
} from '@backstage/catalog-model';
import { EmptyState, InfoCard, Progress } from '@backstage/core';
import React, { PropsWithChildren } from 'react';
import { ApisTable } from './ApisTable';
import { MissingConsumesApisEmptyState } from '../EmptyState';
import { useRelatedEntities } from '../useRelatedEntities';
const ApisCard = ({
children,
variant = 'gridItem',
}: PropsWithChildren<{ variant?: string }>) => {
return (
<InfoCard variant={variant} title="Consumed APIs">
{children}
</InfoCard>
);
};
type Props = {
entity: Entity;
variant?: string;
};
export const ConsumedApisCard = ({ entity, variant = 'gridItem' }: Props) => {
const { entities, loading, error } = useRelatedEntities(
entity,
RELATION_CONSUMES_API,
);
if (loading) {
return (
<ApisCard variant={variant}>
<Progress />
</ApisCard>
);
}
if (error) {
return (
<ApisCard variant={variant}>
<EmptyState
missing="info"
title="No information to display"
description="There was an error while loading the consumed APIs."
/>
</ApisCard>
);
}
if (!entities || entities.length === 0) {
return (
<ApisCard variant={variant}>
<MissingConsumesApisEmptyState />
</ApisCard>
);
}
return (
<ApisTable
title="Consumed APIs"
variant={variant}
entities={entities as (ApiEntity | undefined)[]}
/>
);
};
@@ -0,0 +1,127 @@
/*
* 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 { Entity, RELATION_PROVIDES_API } from '@backstage/catalog-model';
import { ApiProvider, ApiRegistry } from '@backstage/core';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog';
import { renderInTestApp } from '@backstage/test-utils';
import { waitFor } from '@testing-library/react';
import React from 'react';
import { ApiDocsConfig, apiDocsConfigRef } from '../../config';
import { ProvidedApisCard } from './ProvidedApisCard';
describe('<ProvidedApisCard />', () => {
const apiDocsConfig: jest.Mocked<ApiDocsConfig> = {
getApiDefinitionWidget: jest.fn(),
} as any;
const catalogApi: jest.Mocked<CatalogApi> = {
getLocationById: jest.fn(),
getEntityByName: jest.fn(),
getEntities: jest.fn(),
addLocation: jest.fn(),
getLocationByEntity: jest.fn(),
removeEntityByUid: jest.fn(),
} as any;
let Wrapper: React.ComponentType;
beforeEach(() => {
const apis = ApiRegistry.with(catalogApiRef, catalogApi).with(
apiDocsConfigRef,
apiDocsConfig,
);
Wrapper = ({ children }: { children?: React.ReactNode }) => (
<ApiProvider apis={apis}>{children}</ApiProvider>
);
});
afterEach(() => jest.resetAllMocks());
it('shows empty list if no relations', async () => {
const entity: Entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'my-name',
namespace: 'my-namespace',
},
relations: [],
};
const { getByText } = await renderInTestApp(
<Wrapper>
<ProvidedApisCard entity={entity} />
</Wrapper>,
);
expect(getByText(/Provided APIs/i)).toBeInTheDocument();
expect(getByText(/No APIs provided by this entity/i)).toBeInTheDocument();
});
it('shows consumed APIs', async () => {
const entity: Entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'my-name',
namespace: 'my-namespace',
},
relations: [
{
target: {
kind: 'API',
namespace: 'my-namespace',
name: 'target-name',
},
type: RELATION_PROVIDES_API,
},
],
};
catalogApi.getEntityByName.mockResolvedValue({
apiVersion: 'v1',
kind: 'API',
metadata: {
name: 'target-name',
namespace: 'my-namespace',
},
spec: {
type: 'openapi',
owner: 'Test',
lifecycle: 'production',
definition: '...',
},
});
apiDocsConfig.getApiDefinitionWidget.mockReturnValue({
type: 'openapi',
title: 'OpenAPI',
component: () => <div />,
});
const { getByText } = await renderInTestApp(
<Wrapper>
<ProvidedApisCard entity={entity} />
</Wrapper>,
);
await waitFor(() => {
expect(getByText(/Provided APIs/i)).toBeInTheDocument();
expect(getByText(/target-name/i)).toBeInTheDocument();
expect(getByText(/OpenAPI/)).toBeInTheDocument();
expect(getByText(/Test/i)).toBeInTheDocument();
expect(getByText(/production/i)).toBeInTheDocument();
});
});
});
@@ -0,0 +1,85 @@
/*
* 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 {
ApiEntity,
Entity,
RELATION_PROVIDES_API,
} from '@backstage/catalog-model';
import { EmptyState, InfoCard, Progress } from '@backstage/core';
import React, { PropsWithChildren } from 'react';
import { ApisTable } from './ApisTable';
import { MissingProvidesApisEmptyState } from '../EmptyState';
import { useRelatedEntities } from '../useRelatedEntities';
const ApisCard = ({
children,
variant = 'gridItem',
}: PropsWithChildren<{ variant?: string }>) => {
return (
<InfoCard variant={variant} title="Provided APIs">
{children}
</InfoCard>
);
};
type Props = {
entity: Entity;
variant?: string;
};
export const ProvidedApisCard = ({ entity, variant = 'gridItem' }: Props) => {
const { entities, loading, error } = useRelatedEntities(
entity,
RELATION_PROVIDES_API,
);
if (loading) {
return (
<ApisCard variant={variant}>
<Progress />
</ApisCard>
);
}
if (error) {
return (
<ApisCard variant={variant}>
<EmptyState
missing="info"
title="No information to display"
description="There was an error while loading the provided APIs."
/>
</ApisCard>
);
}
if (!entities || entities.length === 0) {
return (
<ApisCard variant={variant}>
<MissingProvidesApisEmptyState />
</ApisCard>
);
}
return (
<ApisTable
title="Provided APIs"
variant={variant}
entities={entities as (ApiEntity | undefined)[]}
/>
);
};
@@ -0,0 +1,18 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { ConsumedApisCard } from './ConsumedApisCard';
export { ProvidedApisCard } from './ProvidedApisCard';
@@ -0,0 +1,87 @@
/*
* 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 { ComponentEntity } from '@backstage/catalog-model';
import { Table, TableColumn } from '@backstage/core';
import React from 'react';
import { EntityLink } from '../EntityLink';
const columns: TableColumn<ComponentEntity>[] = [
{
title: 'Name',
field: 'metadata.name',
highlight: true,
render: (entity: any) => (
<EntityLink entity={entity}>{entity.metadata.name}</EntityLink>
),
},
{
title: 'Owner',
field: 'spec.owner',
},
{
title: 'Lifecycle',
field: 'spec.lifecycle',
},
{
title: 'Type',
field: 'spec.type',
},
{
title: 'Description',
field: 'metadata.description',
width: 'auto',
},
];
type Props = {
title: string;
variant?: string;
entities: (ComponentEntity | undefined)[];
};
// TODO: In theory this could also be systems!
export const ComponentsTable = ({
entities,
title,
variant = 'gridItem',
}: Props) => {
const tableStyle: React.CSSProperties = {
minWidth: '0',
width: '100%',
};
if (variant === 'gridItem') {
tableStyle.height = 'calc(100% - 10px)';
}
return (
<Table<ComponentEntity>
columns={columns}
title={title}
style={tableStyle}
options={{
// TODO: Toolbar padding if off compared to other cards, should be: padding: 16px 24px;
search: false,
paging: false,
actionsColumnIndex: -1,
padding: 'dense',
}}
// TODO: For now we skip all APIs that we can't find without a warning!
data={entities.filter(e => e !== undefined) as ComponentEntity[]}
/>
);
};
@@ -0,0 +1,125 @@
/*
* 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 { Entity, RELATION_API_CONSUMED_BY } from '@backstage/catalog-model';
import { ApiProvider, ApiRegistry } from '@backstage/core';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog';
import { renderInTestApp } from '@backstage/test-utils';
import { waitFor } from '@testing-library/react';
import React from 'react';
import { ConsumingComponentsCard } from './ConsumingComponentsCard';
describe('<ConsumingComponentsCard />', () => {
const catalogApi: jest.Mocked<CatalogApi> = {
getLocationById: jest.fn(),
getEntityByName: jest.fn(),
getEntities: jest.fn(),
addLocation: jest.fn(),
getLocationByEntity: jest.fn(),
removeEntityByUid: jest.fn(),
} as any;
let Wrapper: React.ComponentType;
beforeEach(() => {
const apis = ApiRegistry.with(catalogApiRef, catalogApi);
Wrapper = ({ children }: { children?: React.ReactNode }) => (
<ApiProvider apis={apis}>{children}</ApiProvider>
);
});
afterEach(() => jest.resetAllMocks());
it('shows empty list if no relations', async () => {
const entity: Entity = {
apiVersion: 'v1',
kind: 'API',
metadata: {
name: 'my-name',
namespace: 'my-namespace',
},
spec: {
type: 'openapi',
owner: 'Test',
lifecycle: 'production',
definition: '...',
},
relations: [],
};
const { getByText } = await renderInTestApp(
<Wrapper>
<ConsumingComponentsCard entity={entity} />
</Wrapper>,
);
expect(getByText(/Consumers/i)).toBeInTheDocument();
expect(getByText(/No APIs consumed by this entity/i)).toBeInTheDocument();
});
it('shows consuming components', async () => {
const entity: Entity = {
apiVersion: 'v1',
kind: 'API',
metadata: {
name: 'my-name',
namespace: 'my-namespace',
},
spec: {
type: 'openapi',
owner: 'Test',
lifecycle: 'production',
definition: '...',
},
relations: [
{
target: {
kind: 'Component',
namespace: 'my-namespace',
name: 'target-name',
},
type: RELATION_API_CONSUMED_BY,
},
],
};
catalogApi.getEntityByName.mockResolvedValue({
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'target-name',
namespace: 'my-namespace',
},
spec: {
type: 'service',
owner: 'Test',
lifecycle: 'production',
},
});
const { getByText } = await renderInTestApp(
<Wrapper>
<ConsumingComponentsCard entity={entity} />
</Wrapper>,
);
await waitFor(() => {
expect(getByText(/Consumers/i)).toBeInTheDocument();
expect(getByText(/target-name/i)).toBeInTheDocument();
expect(getByText(/Test/i)).toBeInTheDocument();
expect(getByText(/production/i)).toBeInTheDocument();
});
});
});
@@ -0,0 +1,88 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
ComponentEntity,
Entity,
RELATION_API_CONSUMED_BY,
} from '@backstage/catalog-model';
import { EmptyState, InfoCard, Progress } from '@backstage/core';
import React, { PropsWithChildren } from 'react';
import { MissingConsumesApisEmptyState } from '../EmptyState';
import { useRelatedEntities } from '../useRelatedEntities';
import { ComponentsTable } from './ComponentsTable';
const ComponentsCard = ({
children,
variant = 'gridItem',
}: PropsWithChildren<{ variant?: string }>) => {
return (
<InfoCard variant={variant} title="Consumers">
{children}
</InfoCard>
);
};
type Props = {
entity: Entity;
variant?: string;
};
export const ConsumingComponentsCard = ({
entity,
variant = 'gridItem',
}: Props) => {
const { entities, loading, error } = useRelatedEntities(
entity,
RELATION_API_CONSUMED_BY,
);
if (loading) {
return (
<ComponentsCard variant={variant}>
<Progress />
</ComponentsCard>
);
}
if (error) {
return (
<ComponentsCard variant={variant}>
<EmptyState
missing="info"
title="No information to display"
description="There was an error while loading the consumers."
/>
</ComponentsCard>
);
}
if (!entities || entities.length === 0) {
return (
<ComponentsCard variant={variant}>
<MissingConsumesApisEmptyState />
</ComponentsCard>
);
}
return (
<ComponentsTable
title="Consumers"
variant={variant}
entities={entities as (ComponentEntity | undefined)[]}
/>
);
};
@@ -0,0 +1,125 @@
/*
* 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 { Entity, RELATION_API_PROVIDED_BY } from '@backstage/catalog-model';
import { ApiProvider, ApiRegistry } from '@backstage/core';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog';
import { renderInTestApp } from '@backstage/test-utils';
import { waitFor } from '@testing-library/react';
import React from 'react';
import { ProvidingComponentsCard } from './ProvidingComponentsCard';
describe('<ProvidingComponentsCard />', () => {
const catalogApi: jest.Mocked<CatalogApi> = {
getLocationById: jest.fn(),
getEntityByName: jest.fn(),
getEntities: jest.fn(),
addLocation: jest.fn(),
getLocationByEntity: jest.fn(),
removeEntityByUid: jest.fn(),
} as any;
let Wrapper: React.ComponentType;
beforeEach(() => {
const apis = ApiRegistry.with(catalogApiRef, catalogApi);
Wrapper = ({ children }: { children?: React.ReactNode }) => (
<ApiProvider apis={apis}>{children}</ApiProvider>
);
});
afterEach(() => jest.resetAllMocks());
it('shows empty list if no relations', async () => {
const entity: Entity = {
apiVersion: 'v1',
kind: 'API',
metadata: {
name: 'my-name',
namespace: 'my-namespace',
},
spec: {
type: 'openapi',
owner: 'Test',
lifecycle: 'production',
definition: '...',
},
relations: [],
};
const { getByText } = await renderInTestApp(
<Wrapper>
<ProvidingComponentsCard entity={entity} />
</Wrapper>,
);
expect(getByText(/Providers/i)).toBeInTheDocument();
expect(getByText(/No APIs provided by this entity/i)).toBeInTheDocument();
});
it('shows providing components', async () => {
const entity: Entity = {
apiVersion: 'v1',
kind: 'API',
metadata: {
name: 'my-name',
namespace: 'my-namespace',
},
spec: {
type: 'openapi',
owner: 'Test',
lifecycle: 'production',
definition: '...',
},
relations: [
{
target: {
kind: 'Component',
namespace: 'my-namespace',
name: 'target-name',
},
type: RELATION_API_PROVIDED_BY,
},
],
};
catalogApi.getEntityByName.mockResolvedValue({
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'target-name',
namespace: 'my-namespace',
},
spec: {
type: 'service',
owner: 'Test',
lifecycle: 'production',
},
});
const { getByText } = await renderInTestApp(
<Wrapper>
<ProvidingComponentsCard entity={entity} />
</Wrapper>,
);
await waitFor(() => {
expect(getByText(/Providers/i)).toBeInTheDocument();
expect(getByText(/target-name/i)).toBeInTheDocument();
expect(getByText(/Test/i)).toBeInTheDocument();
expect(getByText(/production/i)).toBeInTheDocument();
});
});
});
@@ -0,0 +1,88 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
ComponentEntity,
Entity,
RELATION_API_PROVIDED_BY,
} from '@backstage/catalog-model';
import { EmptyState, InfoCard, Progress } from '@backstage/core';
import React, { PropsWithChildren } from 'react';
import { MissingProvidesApisEmptyState } from '../EmptyState';
import { useRelatedEntities } from '../useRelatedEntities';
import { ComponentsTable } from './ComponentsTable';
const ComponentsCard = ({
children,
variant = 'gridItem',
}: PropsWithChildren<{ variant?: string }>) => {
return (
<InfoCard variant={variant} title="Providers">
{children}
</InfoCard>
);
};
type Props = {
entity: Entity;
variant?: string;
};
export const ProvidingComponentsCard = ({
entity,
variant = 'gridItem',
}: Props) => {
const { entities, loading, error } = useRelatedEntities(
entity,
RELATION_API_PROVIDED_BY,
);
if (loading) {
return (
<ComponentsCard variant={variant}>
<Progress />
</ComponentsCard>
);
}
if (error) {
return (
<ComponentsCard variant={variant}>
<EmptyState
missing="info"
title="No information to display"
description="There was an error while loading the providers."
/>
</ComponentsCard>
);
}
if (!entities || entities.length === 0) {
return (
<ComponentsCard variant={variant}>
<MissingProvidesApisEmptyState />
</ComponentsCard>
);
}
return (
<ComponentsTable
title="Providers"
variant={variant}
entities={entities as (ComponentEntity | undefined)[]}
/>
);
};
@@ -0,0 +1,18 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { ConsumingComponentsCard } from './ConsumingComponentsCard';
export { ProvidingComponentsCard } from './ProvidingComponentsCard';
@@ -13,14 +13,16 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { getRequestHeaders } from './sentry-api';
describe('SentryApiForwarder', () => {
it('should generate headers based on token passed in constructor', () => {
expect(getRequestHeaders('testtoken')).toEqual({
headers: {
Authorization: `Bearer testtoken`,
},
});
import { renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { MissingConsumesApisEmptyState } from './MissingConsumesApisEmptyState';
describe('<MissingConsumesApisEmptyState />', () => {
it('renders without exploding', async () => {
const { getByText } = await renderInTestApp(
<MissingConsumesApisEmptyState />,
);
expect(getByText(/consumesApis:/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,81 @@
/*
* 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
consumesApis:
- 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 MissingConsumesApisEmptyState = () => {
const classes = useStyles();
return (
<EmptyState
missing="field"
title="No APIs consumed by this entity"
description={
<>
Components can consume APIs that are displayed on this page. You need
to fill the <code>consumesApis</code> field to enable this tool.
</>
}
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#specconsumesapis-optional"
>
Read more
</Button>
</>
}
/>
);
};
@@ -14,16 +14,15 @@
* limitations under the License.
*/
import {
ComponentEntity,
RELATION_PROVIDES_API,
} from '@backstage/catalog-model';
import { renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { MissingProvidesApisEmptyState } from './MissingProvidesApisEmptyState';
export const useComponentApiNames = (entity: ComponentEntity) => {
// TODO: This code doesn't handle namespaces and kinds correctly, but will be removed soon
return (
entity.relations
?.filter(r => r.type === RELATION_PROVIDES_API)
?.map(r => r.target.name) || []
);
};
describe('<MissingProvidesApisEmptyState />', () => {
it('renders without exploding', async () => {
const { getByText } = await renderInTestApp(
<MissingProvidesApisEmptyState />,
);
expect(getByText(/providesApis:/i)).toBeInTheDocument();
});
});
@@ -40,12 +40,12 @@ const useStyles = makeStyles<BackstageTheme>(theme => ({
},
}));
export const MissingImplementsApisEmptyState = () => {
export const MissingProvidesApisEmptyState = () => {
const classes = useStyles();
return (
<EmptyState
missing="field"
title="No APIs implemented by this entity"
title="No APIs provided by this entity"
description={
<>
Components can implement APIs that are displayed on this page. You
@@ -0,0 +1,18 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { MissingConsumesApisEmptyState } from './MissingConsumesApisEmptyState';
export { MissingProvidesApisEmptyState } from './MissingProvidesApisEmptyState';
+6 -10
View File
@@ -14,13 +14,9 @@
* limitations under the License.
*/
export type { ApiDefinitionWidget } from './ApiDefinitionCard';
export {
ApiDefinitionCard,
defaultDefinitionWidgets,
} from './ApiDefinitionCard';
export { AsyncApiDefinitionWidget } from './AsyncApiDefinitionWidget';
export { OpenApiDefinitionWidget } from './OpenApiDefinitionWidget';
export { PlainApiDefinitionWidget } from './PlainApiDefinitionWidget';
export { useComponentApiNames } from './useComponentApiNames';
export { useComponentApiEntities } from './useComponentApiEntities';
export * from './ApiDefinitionCard';
export * from './ApisCards';
export * from './AsyncApiDefinitionWidget';
export * from './ComponentsCards';
export * from './OpenApiDefinitionWidget';
export * from './PlainApiDefinitionWidget';
@@ -1,83 +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 { useAsyncRetry } from 'react-use';
import { errorApiRef, useApi } from '@backstage/core';
import {
ApiEntity,
ComponentEntity,
parseEntityName,
} from '@backstage/catalog-model';
import { catalogApiRef } from '@backstage/plugin-catalog';
import { useComponentApiNames } from './useComponentApiNames';
export function useComponentApiEntities({
entity,
}: {
entity: ComponentEntity;
}): {
loading: boolean;
apiEntities?: Map<String, ApiEntity>;
error?: Error;
retry: () => void;
} {
const catalogApi = useApi(catalogApiRef);
const errorApi = useApi(errorApiRef);
const apiNames = useComponentApiNames(entity);
const { loading, value: apiEntities, retry, error } = useAsyncRetry<
Map<string, ApiEntity>
>(async () => {
const resultMap = new Map<string, ApiEntity>();
await Promise.all(
apiNames.map(async name => {
try {
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);
}
} catch (e) {
errorApi.post(e);
}
}),
);
return resultMap;
}, [catalogApi, entity]);
return {
apiEntities,
loading,
error,
retry,
};
}
@@ -0,0 +1,51 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { useApi } from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog';
import { useAsyncRetry } from 'react-use';
// TODO: Maybe this hook is interesting for others too?
export function useRelatedEntities(
entity: Entity,
type: string,
): {
entities: (Entity | undefined)[] | undefined;
loading: boolean;
error: Error | undefined;
} {
const catalogApi = useApi(catalogApiRef);
const { loading, value, error } = useAsyncRetry<
(Entity | undefined)[]
>(async () => {
const relations =
entity.relations && entity.relations.filter(r => r.type === type);
if (!relations) {
return [];
}
return await Promise.all(
relations?.map(r => catalogApi.getEntityByName(r.target)),
);
}, [entity, type]);
return {
entities: value,
loading,
error,
};
}
-1
View File
@@ -14,6 +14,5 @@
* limitations under the License.
*/
export * from './catalog';
export * from './components';
export { plugin } from './plugin';
-6
View File
@@ -23,9 +23,3 @@ export const rootRoute = createRouteRef({
path: '/api-docs',
title: 'APIs',
});
export const catalogRoute = createRouteRef({
icon: NoIcon,
path: '',
title: 'API',
});
+9
View File
@@ -10,6 +10,15 @@
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/app-backend"
},
"keywords": [
"backstage"
],
"scripts": {
"start": "backstage-cli backend:dev",
"build": "backstage-cli backend:build",
+4 -4
View File
@@ -25,7 +25,7 @@ export AUTH_GOOGLE_CLIENT_ID=x
export AUTH_GOOGLE_CLIENT_SECRET=x
```
### Github
### GitHub
#### Creating a GitHub OAuth application
@@ -42,7 +42,7 @@ export AUTH_GITHUB_CLIENT_ID=x
export AUTH_GITHUB_CLIENT_SECRET=x
```
for github enterprise:
For GitHub Enterprise:
```bash
export AUTH_GITHUB_CLIENT_ID=x
@@ -50,7 +50,7 @@ export AUTH_GITHUB_CLIENT_SECRET=x
export AUTH_GITHUB_ENTERPRISE_INSTANCE_URL=https://x
```
### Gitlab
### GitLab
#### Creating a GitLab OAuth application
@@ -70,7 +70,7 @@ Follow this link, [Add new application](https://gitlab.com/-/profile/application
```bash
export GITLAB_BASE_URL=https://gitlab.com
export AUTH_GITLAB_CLIENT_ID=x # Gitlab calls this the Application ID
export AUTH_GITLAB_CLIENT_ID=x # GitLab calls this the Application ID
export AUTH_GITLAB_CLIENT_SECRET=x
```
+9
View File
@@ -10,6 +10,15 @@
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/auth-backend"
},
"keywords": [
"backstage"
],
"scripts": {
"start": "backstage-cli backend:dev",
"build": "backstage-cli backend:build",
+9
View File
@@ -10,6 +10,15 @@
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/catalog-backend"
},
"keywords": [
"backstage"
],
"scripts": {
"start": "backstage-cli backend:dev",
"build": "backstage-cli backend:build",
@@ -271,7 +271,7 @@ export class CommonDatabase implements Database {
.select('entities.*')
.orderBy('full_name', 'asc');
return Promise.all(rows.map(row => this.toEntityResponse(tx, row)));
return this.toEntityResponses(tx, rows);
}
async entityByName(
@@ -290,7 +290,7 @@ export class CommonDatabase implements Database {
return undefined;
}
return this.toEntityResponse(tx, rows[0]);
return this.toEntityResponses(tx, rows).then(r => r[0]);
}
async entityByUid(
@@ -307,7 +307,7 @@ export class CommonDatabase implements Database {
return undefined;
}
return this.toEntityResponse(tx, rows[0]);
return this.toEntityResponses(tx, rows).then(r => r[0]);
}
async removeEntityByUid(txOpaque: Transaction, uid: string): Promise<void> {
@@ -494,31 +494,60 @@ export class CommonDatabase implements Database {
};
}
private async toEntityResponse(
private async toEntityResponses(
tx: Knex.Transaction<any, any>,
row: DbEntitiesRow,
): Promise<DbEntityResponse> {
const entity = JSON.parse(row.data) as Entity;
entity.metadata.uid = row.id;
entity.metadata.etag = row.etag;
entity.metadata.generation = Number(row.generation); // cast due to sqlite
rows: DbEntitiesRow[],
): Promise<DbEntityResponse[]> {
// TODO(Rugvip): This is here because it's simple for now, but we likely
// need to refactor this to be more efficient or introduce pagination.
const relations = await tx<DbEntitiesRelationsRow>('entities_relations')
.where({ source_full_name: row.full_name })
.orderBy(['type', 'target_full_name'])
.select();
const relations = await this.getRelationsPerFullName(
tx,
rows.map(r => r.full_name),
);
entity.relations = deduplicateRelations(relations).map(r => ({
target: parseEntityName(r.target_full_name),
type: r.type,
}));
const result = new Array<DbEntityResponse>();
for (const row of rows) {
const entity = JSON.parse(row.data) as Entity;
entity.metadata.uid = row.id;
entity.metadata.etag = row.etag;
entity.metadata.generation = Number(row.generation); // cast due to sqlite
return {
locationId: row.location_id || undefined,
entity,
};
entity.relations = (relations[row.full_name] ?? []).map(r => ({
target: parseEntityName(r.target_full_name),
type: r.type,
}));
result.push({
locationId: row.location_id || undefined,
entity,
});
}
return result;
}
// Returns a mapping from e.g. component:default/foo to the relations whose
// source_full_name matches that.
private async getRelationsPerFullName(
tx: Knex.Transaction<any, any>,
sourceFullNames: string[],
): Promise<Record<string, DbEntitiesRelationsRow[]>> {
const batches = lodash.chunk(lodash.uniq(sourceFullNames), 500);
const relations = new Array<DbEntitiesRelationsRow>();
for (const batch of batches) {
relations.push(
...(await tx<DbEntitiesRelationsRow>('entities_relations')
.whereIn('source_full_name', batch)
.orderBy(['type', 'target_full_name'])
.select()),
);
}
return lodash.groupBy(
deduplicateRelations(relations),
r => r.source_full_name,
);
}
}
@@ -85,7 +85,7 @@ export class HigherOrderOperations implements HigherOrderOperation {
// Write
if (!previousLocation && !dryRun) {
// TODO: We do not include location operations in the dryRun. We might perform
// this operation as a seperate dry run.
// this operation as a separate dry run.
await this.locationsCatalog.addLocation(location);
}
if (readerOutput.entities.length === 0) {
@@ -116,28 +116,34 @@ export class HigherOrderOperations implements HigherOrderOperation {
*/
async refreshAllLocations(): Promise<void> {
const startTimestamp = process.hrtime();
this.logger.info('Beginning locations refresh');
const logger = this.logger.child({
component: 'catalog-all-locations-refresh',
});
logger.info('Locations Refresh: Beginning locations refresh');
const locations = await this.locationsCatalog.locations();
this.logger.info(`Visiting ${locations.length} locations`);
logger.info(`Locations Refresh: Visiting ${locations.length} locations`);
for (const { data: location } of locations) {
this.logger.info(
`Refreshing location ${location.type}:${location.target}`,
logger.info(
`Locations Refresh: Refreshing location ${location.type}:${location.target}`,
);
try {
await this.refreshSingleLocation(location);
await this.locationsCatalog.logUpdateSuccess(location.id, undefined);
} catch (e) {
this.logger.warn(
`Failed to refresh location ${location.type}:${location.target}, ${e.stack}`,
logger.warn(
`Locations Refresh: Failed to refresh location ${location.type}:${location.target}, ${e.stack}`,
);
await this.locationsCatalog.logUpdateFailure(location.id, e);
}
}
this.logger.info(
`Completed locations refresh in ${durationText(startTimestamp)}`,
logger.info(
`Locations Refresh: Completed locations refresh in ${durationText(
startTimestamp,
)}`,
);
}
@@ -23,34 +23,6 @@ import {
import { BuiltinKindsEntityProcessor } from './BuiltinKindsEntityProcessor';
describe('BuiltinKindsEntityProcessor', () => {
it('fills in fields for #3049', async () => {
const p = new BuiltinKindsEntityProcessor();
const result = await p.preProcessEntity({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: {
name: 'n',
},
spec: {
type: 't',
children: [],
} as any,
});
expect(result).toEqual({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: {
name: 'n',
},
spec: {
type: 't',
children: [],
ancestors: [],
descendants: [],
},
});
});
describe('postProcessEntity', () => {
const processor = new BuiltinKindsEntityProcessor();
const location = { type: 'a', target: 'b' };
@@ -215,9 +187,7 @@ describe('BuiltinKindsEntityProcessor', () => {
spec: {
type: 't',
parent: 'p',
ancestors: [],
children: ['c'],
descendants: [],
},
};
@@ -53,25 +53,6 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor {
userEntityV1alpha1Validator,
];
async preProcessEntity(entity: Entity): Promise<Entity> {
// NOTE(freben): Part of Group field deprecation on Nov 22nd, 2020. Fields
// scheduled for removal Dec 6th, 2020. This code can be deleted after that
// point. See https://github.com/backstage/backstage/issues/3049
if (
entity.apiVersion === 'backstage.io/v1alpha1' &&
entity.kind === 'Group' &&
entity.spec
) {
if (!entity.spec.ancestors) {
entity.spec.ancestors = [];
}
if (!entity.spec.descendants) {
entity.spec.descendants = [];
}
}
return entity;
}
async validateEntityKind(entity: Entity): Promise<boolean> {
for (const validator of this.validators) {
const result = await validator.check(entity);
@@ -47,7 +47,7 @@ function group(data: RecursivePartial<GroupEntity>): GroupEntity {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: { name: 'name' },
spec: { type: 'type', ancestors: [], children: [], descendants: [] },
spec: { type: 'type', children: [] },
} as GroupEntity,
data,
);
@@ -173,9 +173,7 @@ describe('readLdapGroups', () => {
},
spec: {
type: 'type-value',
ancestors: [],
children: [],
descendants: [],
},
}),
]);
@@ -150,9 +150,7 @@ export async function readLdapGroups(
},
spec: {
type: 'unknown',
ancestors: [],
children: [],
descendants: [],
},
};
@@ -49,9 +49,7 @@ function group(data: RecursivePartial<GroupEntity>): GroupEntity {
name: 'name',
},
spec: {
ancestors: [],
children: [],
descendants: [],
type: 'team',
},
} as GroupEntity,
@@ -306,33 +304,20 @@ describe('read microsoft graph', () => {
resolveRelations(rootGroup, groups, users, groupMember, groupMemberOf);
expect(rootGroup.spec.parent).toBeUndefined();
expect(rootGroup.spec.ancestors).toEqual(expect.arrayContaining([]));
expect(rootGroup.spec.children).toEqual(
expect.arrayContaining(['a', 'b']),
);
expect(rootGroup.spec.descendants).toEqual(
expect.arrayContaining(['a', 'b', 'c']),
);
expect(groupA.spec.parent).toEqual('root');
expect(groupA.spec.ancestors).toEqual(expect.arrayContaining(['root']));
expect(groupA.spec.children).toEqual(expect.arrayContaining([]));
expect(groupA.spec.descendants).toEqual(expect.arrayContaining([]));
expect(groupB.spec.parent).toEqual('root');
expect(groupB.spec.ancestors).toEqual(expect.arrayContaining(['root']));
expect(groupB.spec.children).toEqual(expect.arrayContaining(['c']));
expect(groupB.spec.descendants).toEqual(expect.arrayContaining(['c']));
expect(groupC.spec.parent).toEqual('b');
expect(groupC.spec.ancestors).toEqual(
expect.arrayContaining(['root', 'b']),
);
expect(groupC.spec.children).toEqual(expect.arrayContaining([]));
expect(groupC.spec.descendants).toEqual(expect.arrayContaining([]));
expect(user1.spec.memberOf).toEqual(expect.arrayContaining(['a']));
expect(user2.spec.memberOf).toEqual(expect.arrayContaining(['b', 'c']));
});
});
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { GroupEntity, UserEntity } from '@backstage/catalog-model';
import limiterFactory from 'p-limit';
import { buildMemberOf, buildOrgHierarchy } from '../util/org';
import { MicrosoftGraphClient } from './client';
import {
@@ -21,7 +22,6 @@ import {
MICROSOFT_GRAPH_TENANT_ID_ANNOTATION,
MICROSOFT_GRAPH_USER_ID_ANNOTATION,
} from './constants';
import limiterFactory from 'p-limit';
export function normalizeEntityName(name: string): string {
return name
@@ -98,7 +98,7 @@ export async function readMicrosoftGraphOrganization(
): Promise<{
rootGroup: GroupEntity; // With all relations empty
}> {
// For now we expect a single root orgranization
// For now we expect a single root organization
const organization = await client.getOrganization(tenantId);
const name = normalizeEntityName(organization.displayName!);
const rootGroup: GroupEntity = {
@@ -113,9 +113,7 @@ export async function readMicrosoftGraphOrganization(
},
spec: {
type: 'root',
ancestors: [],
children: [],
descendants: [],
},
};
@@ -165,9 +163,7 @@ export async function readMicrosoftGraphGroups(
spec: {
type: 'team',
// TODO: We could include a group email and picture
ancestors: [],
children: [],
descendants: [],
},
};
@@ -278,7 +274,7 @@ export function resolveRelations(
});
});
// Make sure that all groups have proper ancestors and descendants
// Make sure that all groups have proper parents and children
buildOrgHierarchy(groups);
// Set relations for all users
@@ -100,9 +100,7 @@ describe('github', () => {
spec: {
type: 'team',
parent: 'parent',
ancestors: [],
children: [],
descendants: [],
},
}),
],
@@ -162,9 +162,7 @@ export async function getOrganizationTeams(
},
spec: {
type: 'team',
ancestors: [],
children: [],
descendants: [],
},
};
@@ -26,7 +26,7 @@ function g(
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: { name },
spec: { type: 'team', parent, children, ancestors: [], descendants: [] },
spec: { type: 'team', parent, children },
};
}
@@ -43,28 +43,16 @@ describe('buildOrgHierarchy', () => {
expect(d.spec.children).toEqual([]);
});
it('fills out descendants', () => {
const a = g('a', undefined, []);
const b = g('b', 'a', []);
const c = g('c', 'b', []);
const d = g('d', 'a', []);
it('sets parent of groups children', () => {
const a = g('a', undefined, ['b', 'd']);
const b = g('b', undefined, ['c']);
const c = g('c', undefined, []);
const d = g('d', undefined, []);
buildOrgHierarchy([a, b, c, d]);
expect(a.spec.descendants).toEqual(expect.arrayContaining(['b', 'c', 'd']));
expect(b.spec.descendants).toEqual(expect.arrayContaining(['c']));
expect(c.spec.descendants).toEqual([]);
expect(d.spec.descendants).toEqual([]);
});
it('fills out ancestors', () => {
const a = g('a', undefined, []);
const b = g('b', 'a', []);
const c = g('c', 'b', []);
const d = g('d', 'a', []);
buildOrgHierarchy([a, b, c, d]);
expect(a.spec.ancestors).toEqual([]);
expect(b.spec.ancestors).toEqual(expect.arrayContaining(['a']));
expect(c.spec.ancestors).toEqual(expect.arrayContaining(['a', 'b']));
expect(d.spec.ancestors).toEqual(expect.arrayContaining(['a']));
expect(a.spec.parent).toBeUndefined();
expect(b.spec.parent).toBe('a');
expect(c.spec.parent).toBe('b');
expect(d.spec.parent).toBe('a');
});
});
@@ -35,62 +35,17 @@ export function buildOrgHierarchy(groups: GroupEntity[]) {
}
//
// Make sure that g.descendants is complete
// Make sure that g.children.parent is g
//
function visitDescendants(current: GroupEntity): string[] {
if (current.spec.descendants.length) {
return current.spec.descendants;
}
const accumulator = new Set<string>();
for (const childName of current.spec.children) {
accumulator.add(childName);
for (const group of groups) {
const selfName = group.metadata.name;
for (const childName of group.spec.children) {
const child = groupsByName.get(childName);
if (child) {
for (const d of visitDescendants(child)) {
accumulator.add(d);
}
if (child && !child.spec.parent) {
child.spec.parent = selfName;
}
}
const descendants = Array.from(accumulator);
current.spec.descendants = descendants;
return descendants;
}
for (const group of groups) {
visitDescendants(group);
}
//
// Make sure that g.ancestors is complete
//
function visitAncestors(current: GroupEntity): string[] {
if (current.spec.ancestors.length) {
return current.spec.ancestors;
}
let ancestors: string[];
const parentName = current.spec.parent;
if (!parentName) {
ancestors = [];
} else {
const parent = groupsByName.get(parentName);
if (parent) {
ancestors = [parentName, ...visitAncestors(parent)];
} else {
ancestors = [parentName];
}
}
current.spec.ancestors = ancestors;
return ancestors;
}
for (const group of groups) {
visitAncestors(group);
}
}
@@ -100,15 +55,24 @@ export function buildMemberOf(groups: GroupEntity[], users: UserEntity[]) {
const groupsByName = new Map(groups.map(g => [g.metadata.name, g]));
users.forEach(user => {
const transitiveMemberOf = new Set([...user.spec.memberOf]);
const transitiveMemberOf = new Set<string>();
user.spec.memberOf.forEach(groupName => {
const group = groupsByName.get(groupName);
if (group) {
group.spec.ancestors.forEach(g => transitiveMemberOf.add(g));
const todo = [...user.spec.memberOf];
for (;;) {
const current = todo.pop();
if (!current) {
break;
}
});
if (!transitiveMemberOf.has(current)) {
transitiveMemberOf.add(current);
const group = groupsByName.get(current);
if (group?.spec.parent) {
todo.push(group.spec.parent);
}
}
}
user.spec.memberOf = [...transitiveMemberOf];
});
}
+10
View File
@@ -9,6 +9,16 @@
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/catalog-graphql"
},
"keywords": [
"backstage",
"graphql"
],
"scripts": {
"start": "backstage-cli backend:dev",
"build": "backstage-cli backend:build",
+9
View File
@@ -10,6 +10,15 @@
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/catalog-import"
},
"keywords": [
"backstage"
],
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
+9
View File
@@ -10,6 +10,15 @@
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/catalog"
},
"keywords": [
"backstage"
],
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
@@ -39,12 +39,12 @@ const EntityPageTitle = ({
</Box>
);
function headerProps(
const headerProps = (
kind: string,
namespace: string | undefined,
name: string,
entity: Entity | undefined,
): { headerTitle: string; headerType: string } {
): { headerTitle: string; headerType: string } => {
return {
headerTitle: `${name}${
namespace && namespace !== ENTITY_DEFAULT_NAMESPACE
@@ -60,7 +60,7 @@ function headerProps(
return t;
})(),
};
}
};
export const EntityPageLayout = ({ children }: PropsWithChildren<{}>) => {
const { kind, namespace, name } = useEntityCompoundName();
@@ -88,7 +88,8 @@ export const EntityPageLayout = ({ children }: PropsWithChildren<{}>) => {
pageTitleOverride={headerTitle}
type={headerType}
>
{entity && (
{/* TODO: fix after catalog page customization is added */}
{entity && kind !== 'user' && (
<>
<HeaderLabel
label="Owner"
+10
View File
@@ -10,6 +10,16 @@
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/circleci"
},
"keywords": [
"backstage",
"circleci"
],
"scripts": {
"build": "backstage-cli plugin:build",
"lint": "backstage-cli lint",
+10
View File
@@ -9,6 +9,16 @@
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/cloudbuild"
},
"keywords": [
"backstage",
"google cloud"
],
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
+11
View File
@@ -10,6 +10,15 @@
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/cost-insights"
},
"keywords": [
"backstage"
],
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
@@ -36,6 +45,7 @@
"dayjs": "^1.9.4",
"history": "^5.0.0",
"moment": "^2.27.0",
"pluralize": "^8.0.0",
"qs": "^6.9.4",
"react": "^16.13.1",
"react-dom": "^16.13.1",
@@ -54,6 +64,7 @@
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"@types/pluralize": "^0.0.29",
"@types/recharts": "^1.8.14",
"@types/regression": "^2.0.0",
"@types/yup": "^0.29.8",
@@ -83,7 +83,7 @@ export type CostInsightsApi = {
* reduction) and compare it to metrics important to the business.
*
* @param group The group id from getUserGroups or query parameters
* @param intervals An ISO 8601 repeating interval string, such as R2/P1M/2020-09-01
* @param intervals An ISO 8601 repeating interval string, such as R2/P30D/2020-09-01
* https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals
*/
getGroupDailyCost(group: string, intervals: string): Promise<Cost>;
@@ -101,7 +101,7 @@ 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 intervals An ISO 8601 repeating interval string, such as R2/P1M/2020-09-01
* @param intervals An ISO 8601 repeating interval string, such as R2/P30D/2020-09-01
* https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals
*/
getProjectDailyCost(project: string, intervals: string): Promise<Cost>;
@@ -112,7 +112,7 @@ export type CostInsightsApi = {
* (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
* @param intervals An ISO 8601 repeating interval string, such as R2/P30D/2020-09-01
* https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals
*/
getDailyMetricData(metric: string, intervals: string): Promise<MetricData>;
@@ -132,6 +132,7 @@ export type CostInsightsApi = {
* @param options Options to use when fetching insights for a particular cloud product and interval timeframe.
*/
getProductInsights(options: ProductInsightsOptions): Promise<Entity>;
/**
* Get current cost alerts for a given group. These show up as Action Items for the group on the
* Cost Insights page. Alerts may include cost-saving recommendations, such as infrastructure
@@ -54,7 +54,7 @@ describe.each`
it(`formats ${engineers.unit}s correctly for ${expected}`, async () => {
const { getByText } = await renderInTestApp(
<MockContext engineerCost={engineerCost} currency={engineers}>
<CostGrowth change={{ ratio, amount }} duration={Duration.P1M} />
<CostGrowth change={{ ratio, amount }} duration={Duration.P30D} />
</MockContext>,
);
expect(getByText(expected)).toBeInTheDocument();
@@ -73,7 +73,7 @@ describe.each`
it(`formats ${usd.unit}s correctly for ${expected}`, async () => {
const { getByText } = await renderInTestApp(
<MockContext engineerCost={engineerCost} currency={usd}>
<CostGrowth change={{ ratio, amount }} duration={Duration.P1M} />
<CostGrowth change={{ ratio, amount }} duration={Duration.P30D} />
</MockContext>,
);
expect(getByText(expected)).toBeInTheDocument();
@@ -92,7 +92,7 @@ describe.each`
it(`formats ${carbon.unit}s correctly for ${expected}`, async () => {
const { getByText } = await renderInTestApp(
<MockContext engineerCost={engineerCost} currency={carbon}>
<CostGrowth change={{ ratio, amount }} duration={Duration.P1M} />
<CostGrowth change={{ ratio, amount }} duration={Duration.P30D} />
</MockContext>,
);
expect(getByText(expected)).toBeInTheDocument();
@@ -66,7 +66,6 @@ describe('<PeriodSelect />', () => {
describe.each`
duration
${Duration.P1M}
${Duration.P3M}
${Duration.P90D}
${Duration.P30D}
@@ -74,8 +73,9 @@ describe('<PeriodSelect />', () => {
it(`Should select ${duration}`, async () => {
const mockOnSelect = jest.fn();
const mockAggregation =
// Can't select an option that's already the default
DefaultPageFilters.duration === duration
? Duration.P1M
? Duration.P30D
: DefaultPageFilters.duration;
const rendered = await renderInTestApp(
@@ -89,7 +89,6 @@ describe('<PeriodSelect />', () => {
const button = getByRole(periodSelect, 'button');
UserEvent.click(button);
await waitFor(() => rendered.getByText('Past 60 Days'));
UserEvent.click(rendered.getByTestId(`period-select-option-${duration}`));
expect(mockOnSelect).toHaveBeenLastCalledWith(duration);
});
@@ -17,10 +17,7 @@
import React from 'react';
import { MenuItem, Select, SelectProps } from '@material-ui/core';
import { Duration } from '../../types';
import {
formatLastTwoLookaheadQuarters,
formatLastTwoMonths,
} from '../../utils/formatters';
import { formatLastTwoLookaheadQuarters } from '../../utils/formatters';
import { findAlways } from '../../utils/assert';
import { useSelectStyles as useStyles } from '../../utils/styles';
import { useLastCompleteBillingDate } from '../../hooks';
@@ -42,10 +39,6 @@ export function getDefaultOptions(
value: Duration.P30D,
label: 'Past 60 Days',
},
{
value: Duration.P1M,
label: formatLastTwoMonths(lastCompleteBillingDate),
},
{
value: Duration.P3M,
label: formatLastTwoLookaheadQuarters(lastCompleteBillingDate),
@@ -40,7 +40,7 @@ const MockComputeEngine: Product = {
const MockComputeEngineInsights: Entity = {
id: 'compute-engine',
entities: [],
entities: {},
aggregation: [0, 0],
change: {
ratio: 0,
@@ -55,7 +55,7 @@ const MockCloudDataflow: Product = {
const MockCloudDataflowInsights: Entity = {
id: MockCloudDataflow.kind,
entities: [],
entities: {},
aggregation: [1_000, 2_000],
change: {
ratio: 1,
@@ -70,7 +70,7 @@ const MockCloudStorage: Product = {
const MockCloudStorageInsights: Entity = {
id: MockCloudStorage.kind,
entities: [],
entities: {},
aggregation: [2_000, 4_000],
change: {
ratio: 1,
@@ -85,7 +85,7 @@ const MockBigQuery: Product = {
const MockBigQueryInsights: Entity = {
id: MockBigQuery.kind,
entities: [],
entities: {},
aggregation: [8_000, 16_000],
change: {
ratio: 1,
@@ -100,7 +100,7 @@ const MockBigTable: Product = {
const MockBigTableInsights: Entity = {
id: MockBigTable.kind,
entities: [],
entities: {},
aggregation: [16_000, 32_000],
change: {
ratio: 1,
@@ -115,7 +115,7 @@ const MockCloudPubSub: Product = {
const MockCloudPubSubInsights: Entity = {
id: MockCloudPubSub.kind,
entities: [],
entities: {},
aggregation: [32_000, 64_000],
change: {
ratio: 1,
@@ -0,0 +1,113 @@
/*
* 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 { wrapInTestApp } from '@backstage/test-utils';
import { ProductEntityDialog } from './ProductEntityDialog';
import { render } from '@testing-library/react';
import { Entity } from '../../types';
const atomicEntity: Entity = {
id: null,
aggregation: [0, 0],
change: { ratio: 0, amount: 0 },
entities: {},
};
const singleBreakdownEntity = {
...atomicEntity,
entities: {
SKU: [
{
id: 'sku-1',
aggregation: [0, 0],
change: { ratio: 0, amount: 0 },
entities: {},
},
{
id: 'sku-2',
aggregation: [0, 0],
change: { ratio: 0, amount: 0 },
entities: {},
},
] as Entity[],
},
};
const multiBreakdownEntity = {
...singleBreakdownEntity,
entities: {
...singleBreakdownEntity.entities,
deployment: [
{
id: 'd-1',
aggregation: [0, 0],
change: { ratio: 0, amount: 0 },
entities: {},
},
{
id: 'd-2',
aggregation: [0, 0],
change: { ratio: 0, amount: 0 },
entities: {},
},
] as Entity[],
},
};
describe('<ProductEntityDialog/>', () => {
it('Should error if no sub-entities exist', () => {
expect(() =>
render(
wrapInTestApp(
<ProductEntityDialog
open
entity={atomicEntity}
onClose={jest.fn()}
/>,
),
),
).toThrow();
});
it('Should show a tab for a single sub-entity type', () => {
const { getByText } = render(
wrapInTestApp(
<ProductEntityDialog
open
entity={singleBreakdownEntity}
onClose={jest.fn()}
/>,
),
);
expect(getByText('Breakdown by SKU')).toBeInTheDocument();
});
it('Should show tabs when multiple sub-entity types exist', () => {
const { getByText } = render(
wrapInTestApp(
<ProductEntityDialog
open
entity={multiBreakdownEntity}
onClose={jest.fn()}
/>,
),
);
expect(getByText('Breakdown by SKU')).toBeInTheDocument();
expect(getByText('Breakdown by deployment')).toBeInTheDocument();
expect(getByText('sku-1')).toBeInTheDocument();
});
});
@@ -14,179 +14,55 @@
* limitations under the License.
*/
import React from 'react';
import classnames from 'classnames';
import { Table, TableColumn } from '@backstage/core';
import { Dialog, IconButton, Typography } from '@material-ui/core';
import React, { useState } from 'react';
import { HeaderTabs } from '@backstage/core';
import { Dialog, IconButton } from '@material-ui/core';
import { default as CloseButton } from '@material-ui/icons/Close';
import { CostGrowthIndicator } from '../CostGrowth';
import { costFormatter, formatPercent } from '../../utils/formatters';
import { useEntityDialogStyles as useStyles } from '../../utils/styles';
import { BarChartOptions, Entity } from '../../types';
function createRenderer(col: keyof RowData, classes: Record<string, string>) {
return function render(rowData: {}): JSX.Element {
const row = rowData as RowData;
const rowStyles = classnames(classes.row, {
[classes.rowTotal]: row.id === 'total',
[classes.colFirst]: col === 'label',
[classes.colLast]: col === 'ratio',
});
switch (col) {
case 'previous':
case 'current':
return (
<Typography className={rowStyles}>
{costFormatter.format(row[col])}
</Typography>
);
case 'ratio':
return (
<CostGrowthIndicator
className={rowStyles}
ratio={row.ratio}
formatter={amount => formatPercent(Math.abs(amount))}
/>
);
default:
return <Typography className={rowStyles}>{row.label}</Typography>;
}
};
}
// material-table does not support fixed rows. Override the sorting algorithm
// to force Total row to bottom by default or when a user sort toggles a column.
function createSorter(field?: keyof Omit<RowData, 'id'>) {
return function rowSort(data1: {}, data2: {}): number {
const a = data1 as RowData;
const b = data2 as RowData;
if (a.id === 'total') return 1;
if (b.id === 'total') return 1;
if (field === 'label') return a.label.localeCompare(b.label);
return field
? a[field] - b[field]
: b.previous + b.current - (a.previous - a.current);
};
}
const defaultEntity: Entity = {
id: null,
aggregation: [0, 0],
change: { ratio: 0, amount: 0 },
entities: [],
};
type RowData = {
id: string;
label: string;
previous: number;
current: number;
ratio: number;
};
type ProductEntityDialogOptions = Partial<
Pick<BarChartOptions, 'previousName' | 'currentName'>
>;
import { Entity } from '../../types';
import {
ProductEntityTable,
ProductEntityTableOptions,
} from './ProductEntityTable';
import { findAlways } from '../../utils/assert';
type ProductEntityDialogProps = {
open: boolean;
entity?: Entity;
entitiesLabel: string;
options?: ProductEntityDialogOptions;
entity: Entity;
options?: ProductEntityTableOptions;
onClose: () => void;
};
export const ProductEntityDialog = ({
open,
entity = defaultEntity,
entitiesLabel,
entity,
options = {},
onClose,
}: ProductEntityDialogProps) => {
const classes = useStyles();
const data = Object.assign(
{
previousName: 'Previous',
currentName: 'Current',
},
options,
const labels = Object.keys(entity.entities);
const [selectedLabel, setSelectedLabel] = useState(
findAlways(labels, _ => true),
);
const firstColClasses = classnames(classes.column, classes.colFirst);
const lastColClasses = classnames(classes.column, classes.colLast);
const columns: TableColumn[] = [
{
field: 'label',
title: (
<Typography className={firstColClasses}>{entitiesLabel}</Typography>
),
render: createRenderer('label', classes),
customSort: createSorter('label'),
width: '33.33%',
},
{
field: 'previous',
title: (
<Typography className={classes.column}>{data.previousName}</Typography>
),
align: 'right',
render: createRenderer('previous', classes),
customSort: createSorter('previous'),
},
{
field: 'current',
title: (
<Typography className={classes.column}>{data.currentName}</Typography>
),
align: 'right',
render: createRenderer('current', classes),
customSort: createSorter('current'),
},
{
field: 'ratio',
title: <Typography className={lastColClasses}>M/M</Typography>,
align: 'right',
render: createRenderer('ratio', classes),
customSort: createSorter('ratio'),
},
];
const rowData: RowData[] = entity.entities
.map(e => ({
id: e.id || 'Unknown',
label: e.id || 'Unknown',
previous: e.aggregation[0],
current: e.aggregation[1],
ratio: e.change.ratio,
}))
.concat({
id: 'total',
label: 'Total',
previous: entity.aggregation[0],
current: entity.aggregation[1],
ratio: entity.change.ratio,
})
.sort(createSorter());
const tabs = labels.map((label, index) => ({
id: index.toString(),
label: `Breakdown by ${label}`,
}));
return (
<Dialog open={open} onClose={onClose} scroll="body" fullWidth maxWidth="lg">
<IconButton className={classes.closeButton} onClick={onClose}>
<CloseButton />
</IconButton>
<Table
columns={columns}
data={rowData}
title={entity.id || 'Unlabeled'}
subtitle="Resource breakdown"
options={{
paging: false,
search: false,
hideFilterIcons: true,
}}
<HeaderTabs
tabs={tabs}
onChange={index => setSelectedLabel(labels[index])}
/>
<ProductEntityTable
entityLabel={selectedLabel}
entity={entity}
options={options}
/>
</Dialog>
);
@@ -0,0 +1,174 @@
/*
* 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 classnames from 'classnames';
import { Table, TableColumn } from '@backstage/core';
import { Typography } from '@material-ui/core';
import { costFormatter, formatPercent } from '../../utils/formatters';
import { useEntityDialogStyles as useStyles } from '../../utils/styles';
import { CostGrowthIndicator } from '../CostGrowth';
import { BarChartOptions, Entity } from '../../types';
export type ProductEntityTableOptions = Partial<
Pick<BarChartOptions, 'previousName' | 'currentName'>
>;
type RowData = {
id: string;
label: string;
previous: number;
current: number;
ratio: number;
};
function createRenderer(col: keyof RowData, classes: Record<string, string>) {
return function render(rowData: {}): JSX.Element {
const row = rowData as RowData;
const rowStyles = classnames(classes.row, {
[classes.rowTotal]: row.id === 'total',
[classes.colFirst]: col === 'label',
[classes.colLast]: col === 'ratio',
});
switch (col) {
case 'previous':
case 'current':
return (
<Typography className={rowStyles}>
{costFormatter.format(row[col])}
</Typography>
);
case 'ratio':
return (
<CostGrowthIndicator
className={rowStyles}
ratio={row.ratio}
formatter={amount => formatPercent(Math.abs(amount))}
/>
);
default:
return <Typography className={rowStyles}>{row.label}</Typography>;
}
};
}
// material-table does not support fixed rows. Override the sorting algorithm
// to force Total row to bottom by default or when a user sort toggles a column.
function createSorter(field?: keyof Omit<RowData, 'id'>) {
return function rowSort(data1: {}, data2: {}): number {
const a = data1 as RowData;
const b = data2 as RowData;
if (a.id === 'total') return 1;
if (b.id === 'total') return 1;
if (field === 'label') return a.label.localeCompare(b.label);
return field
? a[field] - b[field]
: b.previous + b.current - (a.previous - a.current);
};
}
type ProductEntityTableProps = {
entityLabel: string;
entity: Entity;
options: ProductEntityTableOptions;
};
export const ProductEntityTable = ({
entityLabel,
entity,
options,
}: ProductEntityTableProps) => {
const classes = useStyles();
const entities = entity.entities[entityLabel];
const data = Object.assign(
{
previousName: 'Previous',
currentName: 'Current',
},
options,
);
const firstColClasses = classnames(classes.column, classes.colFirst);
const lastColClasses = classnames(classes.column, classes.colLast);
const columns: TableColumn[] = [
{
field: 'label',
title: <Typography className={firstColClasses}>{entityLabel}</Typography>,
render: createRenderer('label', classes),
customSort: createSorter('label'),
width: '33.33%',
},
{
field: 'previous',
title: (
<Typography className={classes.column}>{data.previousName}</Typography>
),
align: 'right',
render: createRenderer('previous', classes),
customSort: createSorter('previous'),
},
{
field: 'current',
title: (
<Typography className={classes.column}>{data.currentName}</Typography>
),
align: 'right',
render: createRenderer('current', classes),
customSort: createSorter('current'),
},
{
field: 'ratio',
title: <Typography className={lastColClasses}>Change</Typography>,
align: 'right',
render: createRenderer('ratio', classes),
customSort: createSorter('ratio'),
},
];
const rowData: RowData[] = entities
.map(e => ({
id: e.id || 'Unknown',
label: e.id || 'Unknown',
previous: e.aggregation[0],
current: e.aggregation[1],
ratio: e.change.ratio,
}))
.concat({
id: 'total',
label: 'Total',
previous: entity.aggregation[0],
current: entity.aggregation[1],
ratio: entity.change.ratio,
})
.sort(createSorter());
return (
<Table
columns={columns}
data={rowData}
title={entity.id || 'Unlabeled'}
options={{
paging: false,
search: false,
hideFilterIcons: true,
}}
/>
);
};
@@ -42,7 +42,7 @@ const costInsightsApi = (entity: Entity): Partial<CostInsightsApi> => ({
const mockProductCost = createMockEntity(() => ({
id: 'test-id',
entities: [],
entities: {},
aggregation: [3000, 4000],
change: {
ratio: 0.23,
@@ -81,7 +81,7 @@ describe('<ProductInsightsCard/>', () => {
const rendered = await renderProductInsightsCardInTestApp(
mockProductCost,
MockComputeEngine,
Duration.P1M,
Duration.P30D,
);
expect(
rendered.queryByTestId(`scroll-test-compute-engine`),
@@ -91,21 +91,21 @@ describe('<ProductInsightsCard/>', () => {
it('Should render the right subheader for products with cost data', async () => {
const entity = {
...mockProductCost,
entities: [...Array(1000)].map(createMockEntity),
entities: { entity: [...Array(1000)].map(createMockEntity) },
};
const rendered = await renderProductInsightsCardInTestApp(
entity,
MockComputeEngine,
);
const subheader = 'entities, sorted by cost';
const subheaderRgx = new RegExp(`${entity.entities.length} ${subheader}`);
expect(rendered.getByText(subheaderRgx)).toBeInTheDocument();
expect(
rendered.getByText(/1000 entities, sorted by cost/),
).toBeInTheDocument();
});
it('Should render the right subheader if there is no cost data or change data', async () => {
const entity: Entity = {
id: 'test-id',
entities: [],
entities: {},
aggregation: [0, 0],
change: { ratio: 0, amount: 0 },
};
@@ -113,7 +113,7 @@ describe('<ProductInsightsCard/>', () => {
const rendered = await renderProductInsightsCardInTestApp(
entity,
MockComputeEngine,
Duration.P1M,
Duration.P30D,
);
const subheaderRgx = new RegExp(subheader);
expect(rendered.getByText(subheaderRgx)).toBeInTheDocument();
@@ -135,7 +135,7 @@ describe('<ProductInsightsCard/>', () => {
it(`Should display the correct relative time for ${duration}`, async () => {
const entity = {
...mockProductCost,
entities: [...Array(3)].map(createMockEntity),
entities: { entity: [...Array(3)].map(createMockEntity) },
};
const rendered = await renderProductInsightsCardInTestApp(
entity,
@@ -21,6 +21,7 @@ import React, {
useRef,
useState,
} from 'react';
import pluralize from 'pluralize';
import { InfoCard } from '@backstage/core';
import { Typography } from '@material-ui/core';
import { default as Alert } from '@material-ui/lab/Alert';
@@ -30,12 +31,12 @@ import { useProductInsightsCardStyles as useStyles } from '../../utils/styles';
import { DefaultLoadingAction } from '../../utils/loading';
import { Duration, Entity, Maybe, Product } from '../../types';
import {
useLastCompleteBillingDate,
useScroll,
useLoading,
MapLoadingToProps,
useLastCompleteBillingDate,
useLoading,
useScroll,
} from '../../hooks';
import { pluralOf } from '../../utils/grammar';
import { findAnyKey } from '../../utils/assert';
type LoadingProps = (isLoading: boolean) => void;
@@ -91,13 +92,12 @@ export const ProductInsightsCard = ({
}
}, [product, duration, onSelectAsync, dispatchLoadingProduct]);
const entities = entity?.entities ?? [];
const subheader = entities.length
? `${entities.length} ${pluralOf(
entities.length,
'entity',
'entities',
)}, sorted by cost`
// Only a single entities Record for the root product entity is supported
const entityKey = findAnyKey(entity?.entities);
const entities = entityKey ? entity!.entities[entityKey] : [];
const subheader = entityKey
? `${pluralize(entityKey, entities.length, true)}, sorted by cost`
: null;
const headerProps = {
classes: classes,
@@ -20,6 +20,7 @@ import {
TooltipProps as RechartsTooltipProps,
RechartsFunction,
} from 'recharts';
import pluralize from 'pluralize';
import { Box, Typography } from '@material-ui/core';
import { default as FullScreenIcon } from '@material-ui/icons/Fullscreen';
import { LegendItem } from '../LegendItem';
@@ -32,8 +33,13 @@ import {
BarChartTooltipItem,
BarChartLegendOptions,
} from '../BarChart';
import { pluralOf } from '../../utils/grammar';
import { findAlways, notEmpty, isUndefined } from '../../utils/assert';
import {
findAlways,
notEmpty,
isUndefined,
findAnyKey,
assertAlways,
} from '../../utils/assert';
import { formatPeriod, formatPercent } from '../../utils/formatters';
import {
titleOf,
@@ -62,19 +68,28 @@ export const ProductInsightsChart = ({
}: ProductInsightsChartProps) => {
const classes = useStyles();
const layoutClasses = useLayoutStyles();
// Only a single entities Record for the root product entity is supported
const entities = useMemo(() => {
const entityLabel = assertAlways(findAnyKey(entity.entities));
return entity.entities[entityLabel] ?? [];
}, [entity]);
const [activeLabel, setActive] = useState<Maybe<string>>();
const [selectLabel, setSelected] = useState<Maybe<string>>();
const isSelected = useMemo(() => !isUndefined(selectLabel), [selectLabel]);
const isClickable = useMemo(() => {
const breakdownEntities =
entity.entities.find(e => e.id === activeLabel)?.entities ?? [];
return breakdownEntities.length > 0;
}, [entity, activeLabel]);
const breakdowns = Object.keys(
entities.find(e => e.id === activeLabel)?.entities ?? {},
);
return breakdowns.length > 0;
}, [entities, activeLabel]);
const legendTitle = `Cost ${entity.change.ratio <= 0 ? 'Savings' : 'Growth'}`;
const costStart = entity.aggregation[0];
const costEnd = entity.aggregation[1];
const resources = entity.entities.map(resourceOf);
const resources = entities.map(resourceOf);
const options: Partial<BarChartLegendOptions> = {
previousName: formatPeriod(duration, billingDate, false),
@@ -120,15 +135,14 @@ export const ProductInsightsChart = ({
const title = titleOf(label);
const items = payload.map(tooltipItemOf).filter(notEmpty);
const activeEntity = findAlways(entity.entities, e => e.id === id);
const activeEntity = findAlways(entities, e => e.id === id);
const ratio = activeEntity.change.ratio;
const breakdownEntities = activeEntity.entities;
const subtitle = `${breakdownEntities.length} ${pluralOf(
breakdownEntities.length,
entity.entitiesLabel || 'SKU',
)}`;
const breakdowns = Object.keys(activeEntity.entities);
if (breakdownEntities.length) {
if (breakdowns.length) {
const subtitle = breakdowns
.map(b => pluralize(b, activeEntity.entities[b].length, true))
.join(', ');
return (
<BarChartTooltip
title={title}
@@ -194,13 +208,12 @@ export const ProductInsightsChart = ({
options={options}
{...barChartProps}
/>
{isSelected && entity.entities.length && (
{isSelected && entities.length && (
<ProductEntityDialog
open={isSelected}
onClose={() => setSelected(undefined)}
entity={entity.entities.find(e => e.id === selectLabel)}
entity={findAlways(entities, e => e.id === selectLabel)}
options={options}
entitiesLabel={entity.entitiesLabel || 'SKU'}
/>
)}
</Box>
@@ -15,10 +15,10 @@
*/
import React from 'react';
import pluralize from 'pluralize';
import { InfoCard } from '@backstage/core';
import { ProjectGrowthAlertChart } from './ProjectGrowthAlertChart';
import { ProjectGrowthData } from '../../types';
import { pluralOf } from '../../utils/grammar';
type ProjectGrowthAlertProps = {
alert: ProjectGrowthData;
@@ -26,7 +26,7 @@ type ProjectGrowthAlertProps = {
export const ProjectGrowthAlertCard = ({ alert }: ProjectGrowthAlertProps) => {
const subheader = `
${alert.products.length} ${pluralOf(alert.products.length, 'product')}${
${pluralize('product', alert.products.length, true)}${
alert.products.length > 1 ? ', sorted by cost' : ''
}`;
@@ -72,26 +72,28 @@ export const ProjectGrowthInstructionsPage = () => {
ratio: 3,
amount: 40_000,
},
entities: [
{
id: 'service-one',
aggregation: [18_200, 58_500],
entities: [],
change: { ratio: 2.21, amount: 40_300 },
},
{
id: 'service-two',
aggregation: [1200, 1300],
entities: [],
change: { ratio: 0.083, amount: 100 },
},
{
id: 'service-three',
aggregation: [600, 200],
entities: [],
change: { ratio: -0.666, amount: -400 },
},
],
entities: {
service: [
{
id: 'service-one',
aggregation: [18_200, 58_500],
entities: {},
change: { ratio: 2.21, amount: 40_300 },
},
{
id: 'service-two',
aggregation: [1200, 1300],
entities: {},
change: { ratio: 0.083, amount: 100 },
},
{
id: 'service-three',
aggregation: [600, 200],
entities: {},
change: { ratio: -0.666, amount: -400 },
},
],
},
};
return (
@@ -15,11 +15,11 @@
*/
import React from 'react';
import pluralize from 'pluralize';
import { InfoCard } from '@backstage/core';
import { Box } from '@material-ui/core';
import { BarChart, BarChartLegend } from '../BarChart';
import { UnlabeledDataflowData, ResourceData } from '../../types';
import { pluralOf } from '../../utils/grammar';
import { useBarChartLayoutStyles as useStyles } from '../../utils/styles';
type UnlabeledDataflowAlertProps = {
@@ -30,9 +30,9 @@ export const UnlabeledDataflowAlertCard = ({
alert,
}: UnlabeledDataflowAlertProps) => {
const classes = useStyles();
const projects = pluralOf(alert.projects.length, 'project');
const projects = pluralize('project', alert.projects.length, true);
const subheader = `
Showing costs from ${alert.projects.length} ${projects} with unlabeled Dataflow jobs in the last 30 days.
Showing costs from ${projects} with unlabeled Dataflow jobs in the last 30 days.
`;
const options = {
previousName: 'Unlabeled Cost',
+3 -4
View File
@@ -15,15 +15,14 @@
*/
/**
* Time periods for cost comparison; slight abuse of ISO 8601 periods. We take P1M and P3M to mean
* 'last completed [month|quarter]', and P30D/P90D to be '[month|quarter] relative to today'. So if
* it's September 15, P1M represents costs for the month of August and P30D represents August 16 -
* Time periods for cost comparison; slight abuse of ISO 8601 periods. We take P3M to mean
* 'last completed quarter', and P30D/P90D to be '[month|quarter] relative to today'. So if
* it's September 15, P3M represents costs for Q2 and P30D represents August 16 -
* September 15.
*/
export enum Duration {
P30D = 'P30D',
P90D = 'P90D',
P1M = 'P1M',
P3M = 'P3M',
}
+71 -41
View File
@@ -20,9 +20,8 @@ import { Maybe } from './Maybe';
export interface Entity {
id: Maybe<string>;
aggregation: [number, number];
entities: Entity[];
entities: Record<string, Entity[]>;
change: ChangeStatistic;
entitiesLabel?: string;
}
/*
@@ -31,8 +30,15 @@ export interface Entity {
An entity could be atomic or composite. An atomic entity is indivisible
and cannot be broken into sub-entities.
A composite entity can be broken down recursively into sub-entities
that generate cost **over the same time period**. All costs must sum to the root cost.
A composite entity is divided into sub-entities that account for portions
of the total cost **over the same time period**. The root entity is
expected to only have _one_ Record consisting of the sub-entities to display
in the product panel (keyed by the entity type, such as "service" for
compute entities).
The root sub-entities may have multiple breakdowns - for example, a
breakdown of an entity cost by SKU vs deployment environment. The sum
aggregated cost of each keyed breakdown should equal the sub-entity's cost.
Entities with null ids are considered "unlabeled" - costs without attribution.
If an entity is a composite, it may only have one (1) null child but may have any number of
@@ -45,44 +51,68 @@ export interface Entity {
ratio: 2000,
amount: 200
},
entities: [
{
id: 'service-a',
aggregation: [0, 100],
change: {
ratio: 100,
amount: 100
},
entities: []
},
{
id: 'service-b',
aggregation: [0, 100],
change: {
ratio: 100,
amount: 100
},
entities: [
{
id: 'service-b-sku-a',
aggregation: [0, 25],
change: {
ratio: 25,
amount: 25
},
entities: []
entities: {
service: [
{
id: 'service-a',
aggregation: [0, 100],
change: {
ratio: 100,
amount: 100
},
{
id: null, // Unlabeled cost for service-b
aggregation: [0, 75],
change: {
ratio: 75,
amount: 75
},
entities: []
entities: {}
},
{
id: 'service-b',
aggregation: [0, 100],
change: {
ratio: 100,
amount: 100
},
]
},
]
entities: {
SKU: [
{
id: 'service-b-sku-a',
aggregation: [0, 25],
change: {
ratio: 25,
amount: 25
},
entities: {}
},
{
id: null, // Unlabeled cost for service-b
aggregation: [0, 75],
change: {
ratio: 75,
amount: 75
},
entities: {}
},
],
deployment: [
{
id: 'service-b-env-a',
aggregation: [0, 50],
change: {
ratio: 50,
amount: 50
},
entities: {}
},
{
id: 'service-b-env-b',
aggregation: [0, 50],
change: {
ratio: 50,
amount: 50
},
entities: {}
},
]
}
},
]
}
}
*/
@@ -50,3 +50,9 @@ export function findAlways<T>(
): T {
return assertAlways(collection.find(callback));
}
export function findAnyKey<T>(
record: Record<string, T> | undefined,
): string | undefined {
return Object.keys(record ?? {}).find(_ => true);
}
@@ -80,7 +80,7 @@ describe('getPreviousPeriodTotalCost', () => {
expect(
getPreviousPeriodTotalCost(
mockGroupDailyCost.aggregation,
Duration.P1M,
Duration.P30D,
exclusiveEndDate,
),
).toEqual(100_000);
@@ -18,7 +18,6 @@ import { assertNever } from '../utils/assert';
export const rateOf = (cost: number, duration: Duration) => {
switch (duration) {
case Duration.P1M:
case Duration.P30D:
return cost / 12;
case Duration.P90D:
@@ -15,7 +15,11 @@
*/
import { Duration } from '../types';
import { inclusiveEndDateOf, inclusiveStartDateOf } from './duration';
import {
inclusiveEndDateOf,
inclusiveStartDateOf,
quarterEndDate,
} from './duration';
const lastCompleteBillingDate = '2020-06-05';
@@ -23,7 +27,6 @@ describe.each`
duration | startDate | endDate
${Duration.P30D} | ${'2020-04-06'} | ${'2020-06-05'}
${Duration.P90D} | ${'2019-12-08'} | ${'2020-06-05'}
${Duration.P1M} | ${'2020-04-01'} | ${'2020-05-31'}
${Duration.P3M} | ${'2019-10-01'} | ${'2020-03-31'}
`('Calculates interval dates correctly', ({ duration, startDate, endDate }) => {
it(`Calculates dates correctly for ${duration}`, () => {
@@ -33,3 +36,14 @@ describe.each`
expect(inclusiveEndDateOf(duration, lastCompleteBillingDate)).toBe(endDate);
});
});
describe.each`
inclusiveEndDate | expectedQuarterEndDate
${'2020-12-31'} | ${'2020-12-31'}
${'2020-12-30'} | ${'2020-09-30'}
${'2021-02-19'} | ${'2020-12-31'}
`('quarterEndDate', ({ inclusiveEndDate, expectedQuarterEndDate }) => {
it(`calculates quarter end date correctly from inclusive end date ${inclusiveEndDate}`, () => {
expect(quarterEndDate(inclusiveEndDate)).toBe(expectedQuarterEndDate);
});
});
+14 -13
View File
@@ -37,12 +37,6 @@ export function inclusiveStartDateOf(
.utc()
.subtract(moment.duration(duration).add(moment.duration(duration)))
.format(DEFAULT_DATE_FORMAT);
case Duration.P1M:
return moment(exclusiveEndDate)
.utc()
.startOf('month')
.subtract(moment.duration(duration).add(moment.duration(duration)))
.format(DEFAULT_DATE_FORMAT);
case Duration.P3M:
return moment(exclusiveEndDate)
.utc()
@@ -65,15 +59,10 @@ export function exclusiveEndDateOf(
.utc()
.add(1, 'day')
.format(DEFAULT_DATE_FORMAT);
case Duration.P1M:
return moment(inclusiveEndDate)
.utc()
.startOf('month')
.format(DEFAULT_DATE_FORMAT);
case Duration.P3M:
return moment(inclusiveEndDate)
return moment(quarterEndDate(inclusiveEndDate))
.utc()
.startOf('quarter')
.add(1, 'day')
.format(DEFAULT_DATE_FORMAT);
default:
return assertNever(duration);
@@ -94,3 +83,15 @@ export function inclusiveEndDateOf(
export function intervalsOf(duration: Duration, inclusiveEndDate: string) {
return `R2/${duration}/${exclusiveEndDateOf(duration, inclusiveEndDate)}`;
}
export function quarterEndDate(inclusiveEndDate: string): string {
const endDate = moment(inclusiveEndDate).utc();
const endOfQuarter = endDate.endOf('quarter').format(DEFAULT_DATE_FORMAT);
if (endOfQuarter === inclusiveEndDate) {
return endDate.format(DEFAULT_DATE_FORMAT);
}
return endDate
.startOf('quarter')
.subtract(1, 'day')
.format(DEFAULT_DATE_FORMAT);
}
@@ -57,8 +57,6 @@ describe('date formatters', () => {
describe.each`
duration | date | isEndDate | output
${Duration.P1M} | ${'2020-10-11'} | ${true} | ${'September 2020'}
${Duration.P1M} | ${'2020-10-11'} | ${false} | ${'August 2020'}
${Duration.P3M} | ${'2020-10-11'} | ${true} | ${'Q3 2020'}
${Duration.P3M} | ${'2020-10-11'} | ${false} | ${'Q2 2020'}
${Duration.P30D} | ${'2020-10-11'} | ${true} | ${'Last 30 Days'}
+2 -21
View File
@@ -15,9 +15,9 @@
*/
import moment from 'moment';
import pluralize from 'pluralize';
import { Duration, DEFAULT_DATE_FORMAT } from '../types';
import { inclusiveEndDateOf, inclusiveStartDateOf } from '../utils/duration';
import { pluralOf } from '../utils/grammar';
export type Period = {
periodStart: string;
@@ -75,7 +75,7 @@ export function formatCurrency(amount: number, currency?: string): string {
const n = Math.round(amount);
const numString = numberFormatter.format(n);
return currency ? `${numString} ${pluralOf(n, currency)}` : numString;
return currency ? `${numString} ${pluralize(currency, n)}` : numString;
}
export function formatPercent(n: number): string {
@@ -104,19 +104,6 @@ export function formatLastTwoLookaheadQuarters(inclusiveEndDate: string) {
return `${start} vs ${end}`;
}
export function formatLastTwoMonths(inclusiveEndDate: string) {
const exclusiveEndDate = moment(inclusiveEndDate)
.add(1, 'day')
.format(DEFAULT_DATE_FORMAT);
const start = moment(inclusiveStartDateOf(Duration.P1M, exclusiveEndDate))
.utc()
.format('MMMM');
const end = moment(inclusiveEndDateOf(Duration.P1M, inclusiveEndDate))
.utc()
.format('MMMM');
return `${start} vs ${end}`;
}
const formatRelativePeriod = (
duration: Duration,
date: string,
@@ -137,12 +124,6 @@ export function formatPeriod(
isEndDate: boolean,
) {
switch (duration) {
case Duration.P1M:
return monthOf(
isEndDate
? inclusiveEndDateOf(duration, date)
: inclusiveStartDateOf(duration, date),
);
case Duration.P3M:
return quarterOf(
isEndDate
@@ -22,20 +22,6 @@ const vowels = {
u: 'U',
};
export const pluralOf = (
n: number,
string: string,
plural?: string,
): string => {
if (n !== 1) {
if (plural) {
return plural;
}
return string.concat('s');
}
return string;
};
export const indefiniteArticleOf = (
articles: [string, string],
word: string,
+421 -354
View File
@@ -52,7 +52,7 @@ export const createMockEntity = (
const defaultEntity: Entity = {
id: 'test-entity',
aggregation: [100, 200],
entities: [],
entities: {},
change: {
ratio: 0,
amount: 0,
@@ -143,7 +143,7 @@ export const MockProductTypes: Record<string, string> = {
export const MockProductFilters: ProductFilters = Object.keys(
MockProductTypes,
).map(productType => ({ duration: Duration.P1M, productType }));
).map(productType => ({ duration: Duration.P30D, productType }));
export const MockProducts: Product[] = Object.keys(MockProductTypes).map(
productType =>
@@ -517,35 +517,37 @@ export const SampleBigQueryInsights: Entity = {
ratio: 3,
amount: 20_000,
},
entities: [
{
id: 'entity-a',
aggregation: [5_000, 10_000],
change: {
ratio: 1,
amount: 5_000,
entities: {
dataset: [
{
id: 'entity-a',
aggregation: [5_000, 10_000],
change: {
ratio: 1,
amount: 5_000,
},
entities: {},
},
entities: [],
},
{
id: 'entity-b',
aggregation: [5_000, 10_000],
change: {
ratio: 1,
amount: 5_000,
{
id: 'entity-b',
aggregation: [5_000, 10_000],
change: {
ratio: 1,
amount: 5_000,
},
entities: {},
},
entities: [],
},
{
id: 'entity-c',
aggregation: [0, 10_000],
change: {
ratio: 10_000,
amount: 10_000,
{
id: 'entity-c',
aggregation: [0, 10_000],
change: {
ratio: 10_000,
amount: 10_000,
},
entities: {},
},
entities: [],
},
],
],
},
};
export const SampleCloudDataflowInsights: Entity = {
@@ -555,110 +557,118 @@ export const SampleCloudDataflowInsights: Entity = {
ratio: 0.58,
amount: 58_000,
},
entities: [
{
id: null,
aggregation: [10_000, 12_000],
change: {
ratio: 0.2,
amount: 2_000,
entities: {
pipeline: [
{
id: null,
aggregation: [10_000, 12_000],
change: {
ratio: 0.2,
amount: 2_000,
},
entities: {
SKU: [
{
id: 'Sample SKU A',
aggregation: [3_000, 4_000],
change: {
ratio: 0.333333,
amount: 1_000,
},
entities: {},
},
{
id: 'Sample SKU B',
aggregation: [7_000, 8_000],
change: {
ratio: 0.14285714,
amount: 1_000,
},
entities: {},
},
],
},
},
entities: [
{
id: 'Sample SKU A',
aggregation: [3_000, 4_000],
change: {
ratio: 0.333333,
amount: 1_000,
},
entities: [],
{
id: 'entity-a',
aggregation: [60_000, 70_000],
change: {
ratio: 0.16666666666666666,
amount: 10_000,
},
{
id: 'Sample SKU B',
aggregation: [7_000, 8_000],
change: {
ratio: 0.14285714,
amount: 1_000,
},
entities: [],
entities: {
SKU: [
{
id: 'Sample SKU A',
aggregation: [20_000, 15_000],
change: {
ratio: -0.25,
amount: -5_000,
},
entities: {},
},
{
id: 'Sample SKU B',
aggregation: [30_000, 35_000],
change: {
ratio: -0.16666666666666666,
amount: -5_000,
},
entities: {},
},
{
id: 'Sample SKU C',
aggregation: [10_000, 20_000],
change: {
ratio: 1,
amount: 10_000,
},
entities: {},
},
],
},
],
},
{
id: 'entity-a',
aggregation: [60_000, 70_000],
change: {
ratio: 0.16666666666666666,
amount: 10_000,
},
entities: [
{
id: 'Sample SKU A',
aggregation: [20_000, 15_000],
change: {
ratio: -0.25,
amount: -5_000,
},
entities: [],
{
id: 'entity-b',
aggregation: [12_000, 8_000],
change: {
ratio: -0.33333,
amount: -4_000,
},
{
id: 'Sample SKU B',
aggregation: [30_000, 35_000],
change: {
ratio: -0.16666666666666666,
amount: -5_000,
},
entities: [],
entities: {
SKU: [
{
id: 'Sample SKU A',
aggregation: [4_000, 4_000],
change: {
ratio: 0,
amount: 0,
},
entities: {},
},
{
id: 'Sample SKU B',
aggregation: [8_000, 4_000],
change: {
ratio: -0.5,
amount: -4_000,
},
entities: {},
},
],
},
{
id: 'Sample SKU C',
aggregation: [10_000, 20_000],
change: {
ratio: 1,
amount: 10_000,
},
entities: [],
},
],
},
{
id: 'entity-b',
aggregation: [12_000, 8_000],
change: {
ratio: -0.33333,
amount: -4_000,
},
entities: [
{
id: 'Sample SKU A',
aggregation: [4_000, 4_000],
change: {
ratio: 0,
amount: 0,
},
entities: [],
{
id: 'entity-c',
aggregation: [0, 10_000],
change: {
ratio: 10_000,
amount: 10_000,
},
{
id: 'Sample SKU B',
aggregation: [8_000, 4_000],
change: {
ratio: -0.5,
amount: -4_000,
},
entities: [],
},
],
},
{
id: 'entity-c',
aggregation: [0, 10_000],
change: {
ratio: 10_000,
amount: 10_000,
entities: {},
},
entities: [],
},
],
],
},
};
export const SampleCloudStorageInsights: Entity = {
@@ -668,91 +678,97 @@ export const SampleCloudStorageInsights: Entity = {
ratio: 0,
amount: 0,
},
entities: [
{
id: 'entity-a',
aggregation: [15_000, 20_000],
change: {
ratio: 0.333,
amount: 5_000,
entities: {
bucket: [
{
id: 'entity-a',
aggregation: [15_000, 20_000],
change: {
ratio: 0.333,
amount: 5_000,
},
entities: {
SKU: [
{
id: 'Sample SKU A',
aggregation: [10_000, 11_000],
change: {
ratio: 0.1,
amount: 1_000,
},
entities: {},
},
{
id: 'Sample SKU B',
aggregation: [2_000, 5_000],
change: {
ratio: 1.5,
amount: 3_000,
},
entities: {},
},
{
id: 'Sample SKU C',
aggregation: [3_000, 4_000],
change: {
ratio: 0.3333,
amount: 1_000,
},
entities: {},
},
],
},
},
entities: [
{
id: 'Sample SKU A',
aggregation: [10_000, 11_000],
change: {
ratio: 0.1,
amount: 1_000,
},
entities: [],
{
id: 'entity-b',
aggregation: [30_000, 25_000],
change: {
ratio: -0.16666,
amount: -5_000,
},
{
id: 'Sample SKU B',
aggregation: [2_000, 5_000],
change: {
ratio: 1.5,
amount: 3_000,
},
entities: [],
entities: {
SKU: [
{
id: 'Sample SKU A',
aggregation: [12_000, 13_000],
change: {
ratio: 0.08333333333333333,
amount: 1_000,
},
entities: {},
},
{
id: 'Sample SKU B',
aggregation: [16_000, 12_000],
change: {
ratio: -0.25,
amount: -4_000,
},
entities: {},
},
{
id: 'Sample SKU C',
aggregation: [2_000, 0],
change: {
ratio: -1,
amount: -2000,
},
entities: {},
},
],
},
{
id: 'Sample SKU C',
aggregation: [3_000, 4_000],
change: {
ratio: 0.3333,
amount: 1_000,
},
entities: [],
},
],
},
{
id: 'entity-b',
aggregation: [30_000, 25_000],
change: {
ratio: -0.16666,
amount: -5_000,
},
entities: [
{
id: 'Sample SKU A',
aggregation: [12_000, 13_000],
change: {
ratio: 0.08333333333333333,
amount: 1_000,
},
entities: [],
{
id: 'entity-c',
aggregation: [0, 0],
change: {
ratio: 0,
amount: 0,
},
{
id: 'Sample SKU B',
aggregation: [16_000, 12_000],
change: {
ratio: -0.25,
amount: -4_000,
},
entities: [],
},
{
id: 'Sample SKU C',
aggregation: [2_000, 0],
change: {
ratio: -1,
amount: -2000,
},
entities: [],
},
],
},
{
id: 'entity-c',
aggregation: [0, 0],
change: {
ratio: 0,
amount: 0,
entities: {},
},
entities: [],
},
],
],
},
};
export const SampleComputeEngineInsights: Entity = {
@@ -762,91 +778,137 @@ export const SampleComputeEngineInsights: Entity = {
ratio: 0.125,
amount: 10_000,
},
entities: [
{
id: 'entity-a',
aggregation: [20_000, 10_000],
change: {
ratio: -0.5,
amount: -10_000,
entities: {
service: [
{
id: 'entity-a',
aggregation: [20_000, 10_000],
change: {
ratio: -0.5,
amount: -10_000,
},
entities: {
SKU: [
{
id: 'Sample SKU A',
aggregation: [4_000, 2_000],
change: {
ratio: -0.5,
amount: -2_000,
},
entities: {},
},
{
id: 'Sample SKU B',
aggregation: [7_000, 6_000],
change: {
ratio: -0.14285714285714285,
amount: -1_000,
},
entities: {},
},
{
id: 'Sample SKU C',
aggregation: [9_000, 2_000],
change: {
ratio: -0.7777777777777778,
amount: -7000,
},
entities: {},
},
],
deployment: [
{
id: 'Compute Engine',
aggregation: [7_000, 6_000],
change: {
ratio: -0.5,
amount: -2_000,
},
entities: {},
},
{
id: 'Kubernetes',
aggregation: [4_000, 2_000],
change: {
ratio: -0.14285714285714285,
amount: -1_000,
},
entities: {},
},
],
},
},
entities: [
{
id: 'Sample SKU A',
aggregation: [4_000, 2_000],
change: {
ratio: -0.5,
amount: -2_000,
},
entities: [],
{
id: 'entity-b',
aggregation: [10_000, 20_000],
change: {
ratio: 1,
amount: 10_000,
},
{
id: 'Sample SKU B',
aggregation: [7_000, 6_000],
change: {
ratio: -0.14285714285714285,
amount: -1_000,
},
entities: [],
entities: {
SKU: [
{
id: 'Sample SKU A',
aggregation: [1_000, 2_000],
change: {
ratio: 1,
amount: 1_000,
},
entities: {},
},
{
id: 'Sample SKU B',
aggregation: [4_000, 8_000],
change: {
ratio: 1,
amount: 4_000,
},
entities: {},
},
{
id: 'Sample SKU C',
aggregation: [5_000, 10_000],
change: {
ratio: 1,
amount: 5_000,
},
entities: {},
},
],
deployment: [
{
id: 'Compute Engine',
aggregation: [7_000, 6_000],
change: {
ratio: -0.5,
amount: -2_000,
},
entities: {},
},
{
id: 'Kubernetes',
aggregation: [4_000, 2_000],
change: {
ratio: -0.14285714285714285,
amount: -1_000,
},
entities: {},
},
],
},
{
id: 'Sample SKU C',
aggregation: [9_000, 2_000],
change: {
ratio: -0.7777777777777778,
amount: -7000,
},
entities: [],
},
],
},
{
id: 'entity-b',
aggregation: [10_000, 20_000],
change: {
ratio: 1,
amount: 10_000,
},
entities: [
{
id: 'Sample SKU A',
aggregation: [1_000, 2_000],
change: {
ratio: 1,
amount: 1_000,
},
entities: [],
{
id: 'entity-c',
aggregation: [0, 10_000],
change: {
ratio: 10_000,
amount: 10_000,
},
{
id: 'Sample SKU B',
aggregation: [4_000, 8_000],
change: {
ratio: 1,
amount: 4_000,
},
entities: [],
},
{
id: 'Sample SKU C',
aggregation: [5_000, 10_000],
change: {
ratio: 1,
amount: 5_000,
},
entities: [],
},
],
},
{
id: 'entity-c',
aggregation: [0, 10_000],
change: {
ratio: 10_000,
amount: 10_000,
entities: {},
},
entities: [],
},
],
],
},
};
export const SampleEventsInsights: Entity = {
@@ -856,83 +918,88 @@ export const SampleEventsInsights: Entity = {
ratio: -0.5,
amount: -10_000,
},
entitiesLabel: 'Product',
entities: [
{
id: 'entity-a',
aggregation: [15_000, 7_000],
change: {
ratio: -0.53333333333,
amount: -8_000,
entities: {
event: [
{
id: 'entity-a',
aggregation: [15_000, 7_000],
change: {
ratio: -0.53333333333,
amount: -8_000,
},
entities: {
product: [
{
id: 'Sample Product A',
aggregation: [5_000, 2_000],
change: {
ratio: -0.6,
amount: -3_000,
},
entities: {},
},
{
id: 'Sample Product B',
aggregation: [7_000, 2_500],
change: {
ratio: -0.64285714285,
amount: -4_500,
},
entities: {},
},
{
id: 'Sample Product C',
aggregation: [3_000, 2_500],
change: {
ratio: -0.16666666666,
amount: -500,
},
entities: {},
},
],
},
},
entities: [
{
id: 'Sample Product A',
aggregation: [5_000, 2_000],
change: {
ratio: -0.6,
amount: -3_000,
},
entities: [],
{
id: 'entity-b',
aggregation: [5_000, 3_000],
change: {
ratio: -0.4,
amount: -2_000,
},
{
id: 'Sample Product B',
aggregation: [7_000, 2_500],
change: {
ratio: -0.64285714285,
amount: -4_500,
},
entities: [],
entities: {
product: [
{
id: 'Sample Product A',
aggregation: [2_000, 1_000],
change: {
ratio: -0.5,
amount: -1_000,
},
entities: {},
},
{
id: 'Sample Product B',
aggregation: [1_000, 1_500],
change: {
ratio: 0.5,
amount: 500,
},
entities: {},
},
{
id: 'Sample Product C',
aggregation: [2_000, 500],
change: {
ratio: -0.75,
amount: -1_500,
},
entities: {},
},
],
},
{
id: 'Sample Product C',
aggregation: [3_000, 2_500],
change: {
ratio: -0.16666666666,
amount: -500,
},
entities: [],
},
],
},
{
id: 'entity-b',
aggregation: [5_000, 3_000],
change: {
ratio: -0.4,
amount: -2_000,
},
entities: [
{
id: 'Sample Product A',
aggregation: [2_000, 1_000],
change: {
ratio: -0.5,
amount: -1_000,
},
entities: [],
},
{
id: 'Sample Product B',
aggregation: [1_000, 1_500],
change: {
ratio: 0.5,
amount: 500,
},
entities: [],
},
{
id: 'Sample Product C',
aggregation: [2_000, 500],
change: {
ratio: -0.75,
amount: -1_500,
},
entities: [],
},
],
},
],
],
},
};
export function entityOf(product: string): Entity {
+9
View File
@@ -10,6 +10,15 @@
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/explore"
},
"keywords": [
"backstage"
],
"scripts": {
"build": "backstage-cli plugin:build",
"lint": "backstage-cli lint",
+10
View File
@@ -9,6 +9,16 @@
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/gcp-projects"
},
"keywords": [
"backstage",
"google cloud"
],
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
+11
View File
@@ -10,6 +10,17 @@
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/github-actions"
},
"keywords": [
"backstage",
"github",
"github actions"
],
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
@@ -60,7 +60,7 @@ export const RecentWorkflowRunsCard = ({
<EmptyState
missing="data"
title="No Workflow Data"
description="This component has Github Actions enabled, but no data was found. Have you created any Workflows? Click the button below to create a new Workflow."
description="This component has GitHub Actions enabled, but no data was found. Have you created any Workflows? Click the button below to create a new Workflow."
action={
<Button
variant="contained"
@@ -177,7 +177,7 @@ export const WorkflowRunsTable = ({
<EmptyState
missing="data"
title="No Workflow Data"
description="This component has Github Actions enabled, but no data was found. Have you created any Workflows? Click the button below to create a new Workflow."
description="This component has GitHub Actions enabled, but no data was found. Have you created any Workflows? Click the button below to create a new Workflow."
action={
<Button
variant="contained"
+10
View File
@@ -10,6 +10,16 @@
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/gitops-profiles"
},
"keywords": [
"backstage",
"gitops"
],
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
+10
View File
@@ -9,6 +9,16 @@
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/graphql-backend"
},
"keywords": [
"backstage",
"graphql"
],
"scripts": {
"start": "backstage-cli backend:dev",
"build": "backstage-cli backend:build",
+10
View File
@@ -10,6 +10,16 @@
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/jenkins"
},
"keywords": [
"backstage",
"jenkins"
],
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
+35 -13
View File
@@ -107,26 +107,48 @@ export class JenkinsApi {
async getFolder(folderName: string) {
const client = await this.getClient();
const folder = await client.job.get(folderName);
const folder = await client.job.get({
name: folderName,
// Filter only be the information we need, instead of loading all fields.
// Limit to only show the latest build for each job and only load 50 jobs
// at all.
// Whitespaces are only included for readablity here and stripped out
// before sending to Jenkins
tree: `jobs[
actions[*],
builds[
number,
url,
fullDisplayName,
building,
result,
actions[
*[
*[
*[
*
]
]
]
]
]{0,1},
jobs{0,1},
name
]{0,50}
`.replace(/\s/g, ''),
});
const results = [];
for (const jobSummary of folder.jobs) {
const jobDetails = await client.job.get({
name: `${folderName}/${jobSummary.name}`,
depth: 1,
});
for (const jobDetails of folder.jobs) {
const jobScmInfo = this.extractScmDetailsFromJob(jobDetails);
if (jobDetails.jobs) {
// skipping folders inside folders for now
} else {
for (const buildDetails of jobDetails.builds) {
const build = await client.build.get({
name: `${folderName}/${jobSummary.name}`,
number: buildDetails.number,
depth: 1,
});
const ciTable = this.mapJenkinsBuildToCITable(build, jobScmInfo);
const ciTable = this.mapJenkinsBuildToCITable(
buildDetails,
jobScmInfo,
);
results.push(ciTable);
}
}
+18 -27
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { errorApiRef, useApi } from '@backstage/core';
import { useCallback, useEffect, useState } from 'react';
import { useState } from 'react';
import { useAsyncRetry } from 'react-use';
import { jenkinsApiRef } from '../api';
@@ -26,21 +26,6 @@ export function useBuilds(owner: string, repo: string, branch?: string) {
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(5);
const getBuilds = useCallback(async () => {
try {
let build;
if (branch) {
build = await api.getLastBuild(`${owner}/${repo}/${branch}`);
} else {
build = await api.getFolder(`${owner}/${repo}`);
}
return build;
} catch (e) {
errorApi.post(e);
return Promise.reject(e);
}
}, [api, branch, errorApi, owner, repo]);
const restartBuild = async (buildName: string) => {
try {
await api.retry(buildName);
@@ -49,18 +34,24 @@ export function useBuilds(owner: string, repo: string, branch?: string) {
}
};
useEffect(() => {
getBuilds().then(b => {
const size = Array.isArray(b) ? b?.[0].build_num! : 1;
setTotal(size);
});
}, [repo, getBuilds]);
const { loading, value: builds, retry } = useAsyncRetry(async () => {
try {
let builds;
if (branch) {
builds = await api.getLastBuild(`${owner}/${repo}/${branch}`);
} else {
builds = await api.getFolder(`${owner}/${repo}`);
}
const { loading, value: builds, retry } = useAsyncRetry(
() =>
getBuilds().then(retrievedBuilds => retrievedBuilds ?? [], restartBuild),
[page, pageSize, getBuilds],
);
const size = Array.isArray(builds) ? builds?.[0].build_num! : 1;
setTotal(size);
return builds || [];
} catch (e) {
errorApi.post(e);
throw e;
}
}, [api, errorApi, owner, repo, branch]);
const projectName = `${owner}/${repo}`;
return [
+10
View File
@@ -10,6 +10,16 @@
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/kubernetes-backend"
},
"keywords": [
"backstage",
"kubernetes"
],
"configSchema": "schema.d.ts",
"scripts": {
"start": "backstage-cli backend:dev",
+10
View File
@@ -9,6 +9,16 @@
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/kubernetes"
},
"keywords": [
"backstage",
"kubernetes"
],
"configSchema": "schema.d.ts",
"scripts": {
"build": "backstage-cli plugin:build",
+2 -3
View File
@@ -27,8 +27,7 @@ your app's [`plugins.ts`](https://github.com/backstage/backstage/blob/master/pac
to enable the plugin:
```js
import { default as LighthousePlugin } from '@backstage/plugin-lighthouse';
export LighthousePlugin;
export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse';
```
Then, you need to use the `lighthouseApiRef` exported from the plugin to initialize the Rest API in
@@ -74,7 +73,7 @@ metadata:
lighthouse.com/website-url: # A single website url e.g. https://backstage.io/
```
> NOTE: The lighthouse plugin only supports one website url per component at this time.
> NOTE: The lighthouse plugin only supports one website URL per component at this time.
Add a lighthouse tab to the EntityPage:
+10
View File
@@ -10,6 +10,16 @@
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/lighthouse"
},
"keywords": [
"backstage",
"lighthouse"
],
"scripts": {
"build": "backstage-cli plugin:build",
"lint": "backstage-cli lint",
@@ -26,7 +26,7 @@ export const LIGHTHOUSE_INTRO_LOCAL_STORAGE =
const USE_CASES = `
Google's [Lighthouse](https://developers.google.com/web/tools/lighthouse) auditing tool for websites
is a great open-source resource forbenchmarking and improving the accessibility, performance, SEO, and best practices of your site.
is a great open-source resource for benchmarking and improving the accessibility, performance, SEO, and best practices of your site.
At Spotify, we keep track of Lighthouse audit scores over time to look at trends and overall areas for investment.
This plugin allows you to generate on-demand Lighthouse audits for websites, and to track the trends for the
@@ -46,8 +46,7 @@ your app's [\`plugins.ts\`](https://github.com/backstage/backstage/blob/master/p
to enable the plugin:
\`\`\`js
import { default as LighthousePlugin } from '@backstage/plugin-lighthouse';
export LighthousePlugin;
export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse';
\`\`\`
Then, you need to use the \`lighthouseApiRef\` exported from the plugin to initialize the Rest API in
+10
View File
@@ -10,6 +10,16 @@
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/newrelic"
},
"keywords": [
"backstage",
"newrelic"
],
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+6
View File
@@ -0,0 +1,6 @@
# Org Plugin for Backstage
## Features
- Show Group Page
- Show User Profile
+19
View File
@@ -0,0 +1,19 @@
/*
* 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 { createDevApp } from '@backstage/dev-utils';
import { plugin } from '../src/plugin';
createDevApp().registerPlugin(plugin).render();
+50
View File
@@ -0,0 +1,50 @@
{
"name": "@backstage/plugin-org",
"version": "0.3.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.4.0",
"@backstage/core": "^0.3.2",
"@backstage/plugin-catalog": "^0.2.5",
"@backstage/theme": "^0.2.1",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^15.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.4.0",
"@backstage/dev-utils": "^0.1.5",
"@backstage/test-utils": "^0.1.4",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"cross-fetch": "^3.0.6",
"msw": "^0.21.2"
},
"files": [
"dist"
]
}
@@ -0,0 +1,73 @@
/*
* 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, { CSSProperties } from 'react';
import {
Avatar as MaterialAvatar,
createStyles,
makeStyles,
Theme,
} from '@material-ui/core';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
avatar: {
width: '4rem',
height: '4rem',
color: '#fff',
fontWeight: theme.typography.fontWeightBold,
letterSpacing: '1px',
textTransform: 'uppercase',
},
}),
);
const stringToColour = (str: string) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
let colour = '#';
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xff;
colour += `00${value.toString(16)}`.substr(-2);
}
return colour;
};
export const Avatar = ({
displayName,
picture,
customStyles,
}: {
displayName: string | undefined;
picture: string | undefined;
customStyles?: CSSProperties;
}) => {
const classes = useStyles();
return (
<MaterialAvatar
alt={displayName}
src={picture}
className={classes.avatar}
style={{
backgroundColor: stringToColour(displayName || picture || ''),
...customStyles,
}}
>
{displayName && displayName.match(/\b\w/g)!.join('').substring(0, 2)}
</MaterialAvatar>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { Avatar } from './Avatar';
@@ -0,0 +1,141 @@
/*
* 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.
*/
/*
* 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, Grid, Link, Tooltip, Typography } from '@material-ui/core';
import Alert from '@material-ui/lab/Alert';
import { InfoCard } from '@backstage/core';
import { entityRouteParams } from '@backstage/plugin-catalog';
import {
Entity,
GroupEntity,
RELATION_CHILD_OF,
RELATION_PARENT_OF,
} from '@backstage/catalog-model';
import AccountTreeIcon from '@material-ui/icons/AccountTree';
import GroupIcon from '@material-ui/icons/Group';
import { Link as RouterLink, generatePath } from 'react-router-dom';
const GroupLink = ({
groupName,
index = 0,
entity,
}: {
groupName: string;
index?: number;
entity: Entity;
}) => (
<>
{index >= 1 ? ', ' : ''}
<Link
component={RouterLink}
to={generatePath(
`/catalog/:namespace/group/${groupName}`,
entityRouteParams(entity),
)}
>
[{groupName}]
</Link>
</>
);
const CardTitle = ({ title }: { title: string }) => (
<Box display="flex" alignItems="center">
<GroupIcon fontSize="inherit" />
<Box ml={1}>{title}</Box>
</Box>
);
export const GroupProfileCard = ({
entity: group,
variant,
}: {
entity: GroupEntity;
variant: string;
}) => {
const {
metadata: { name, description },
} = group;
const parent = group?.relations
?.filter(r => r.type === RELATION_CHILD_OF)
?.map(group => group.target.name)
.toString();
const childrens = group?.relations
?.filter(r => r.type === RELATION_PARENT_OF)
?.map(group => group.target.name);
if (!group) return <Alert severity="error">User not found</Alert>;
return (
<InfoCard
title={<CardTitle title={name} />}
subheader={description}
variant={variant}
>
<Grid container spacing={3}>
<Grid item>
{parent ? (
<Typography variant="subtitle1">
<Box display="flex" alignItems="center">
<Tooltip title="Group Parent">
<AccountTreeIcon fontSize="inherit" />
</Tooltip>
<Box ml={1} display="inline">
<GroupLink groupName={parent} entity={group} />
</Box>
</Box>
</Typography>
) : null}
{childrens?.length ? (
<Typography variant="subtitle1">
<Box display="flex" alignItems="center">
<Tooltip title="Parent of">
<GroupIcon fontSize="inherit" />
</Tooltip>
<Box ml={1} display="inline">
{childrens.map((children, index) => (
<GroupLink
groupName={children}
entity={group}
index={index}
key={children}
/>
))}
</Box>
</Box>
</Typography>
) : null}
</Grid>
</Grid>
</InfoCard>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './GroupProfileCard';
@@ -0,0 +1,100 @@
/*
* 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 { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
import React from 'react';
import { ApiProvider, ApiRegistry } from '@backstage/core';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog';
import { Entity } from '@backstage/catalog-model';
import { MembersListCard } from './MembersListCard';
describe('MemberTab Test', () => {
const groupEntity = {
apiVersion: 'v1',
kind: 'Group',
metadata: {
name: 'team-d',
description: 'The evil-corp organization',
namespace: 'default',
},
spec: {
type: 'team',
parent: 'boxoffice',
ancestors: ['boxoffice', 'acme-corp'],
children: [],
descendants: [],
},
};
const catalogApi: Partial<CatalogApi> = {
getEntities: () =>
Promise.resolve({
items: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: {
name: 'tara.macgovern',
namespace: 'default',
uid: 'a5gerth56',
},
relations: [
{
type: 'memberOf',
target: {
kind: 'group',
name: 'team-d',
namespace: 'default',
},
},
],
spec: {
profile: {
displayName: 'Tara MacGovern',
email: 'tara-macgovern@example.com',
picture: 'https://example.com/staff/tara.jpeg',
},
memberOf: ['team-d'],
},
},
] as Entity[],
}),
};
const apis = ApiRegistry.from([[catalogApiRef, catalogApi]]);
it('Display Profile Card', async () => {
const rendered = await renderWithEffects(
wrapInTestApp(
<ApiProvider apis={apis}>
<MembersListCard entity={groupEntity} />
</ApiProvider>,
),
);
expect(rendered.getByAltText('Tara MacGovern')).toHaveAttribute(
'src',
'https://example.com/staff/tara.jpeg',
);
expect(
rendered.getByText('tara-macgovern@example.com'),
).toBeInTheDocument();
expect(rendered.getByText('Tara MacGovern')).toHaveAttribute(
'href',
'/catalog/default/user/tara.macgovern',
);
});
});
@@ -0,0 +1,155 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import Alert from '@material-ui/lab/Alert';
import {
Box,
createStyles,
Grid,
Link,
makeStyles,
Theme,
Typography,
} from '@material-ui/core';
import { InfoCard, Progress, useApi } from '@backstage/core';
import {
UserEntity,
RELATION_MEMBER_OF,
Entity,
} from '@backstage/catalog-model';
import { Link as RouterLink, generatePath } from 'react-router-dom';
import { catalogApiRef, entityRouteParams } from '@backstage/plugin-catalog';
import { useAsync } from 'react-use';
import { Avatar } from '../../../Avatar';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
card: {
border: `1px solid ${theme.palette.divider}`,
boxShadow: theme.shadows[2],
borderRadius: '4px',
overflow: 'visible',
position: 'relative',
margin: theme.spacing(3, 0, 0),
},
}),
);
const MemberComponent = ({
member,
groupEntity,
}: {
member: UserEntity;
groupEntity: Entity;
}) => {
const classes = useStyles();
const { name: metaName } = member.metadata;
const { profile } = member.spec;
return (
<Grid item xs={12} sm={6} md={3} xl={2}>
<Box className={classes.card}>
<Box
display="flex"
flexDirection="column"
m={3}
alignItems="center"
justifyContent="center"
>
<Avatar
displayName={profile?.displayName}
picture={profile?.picture}
customStyles={{
position: 'absolute',
top: '-2rem',
}}
/>
<Box pt={2} textAlign="center">
<Typography variant="h5">
<Link
component={RouterLink}
to={generatePath(
`/catalog/:namespace/user/${metaName}`,
entityRouteParams(groupEntity),
)}
>
{profile?.displayName}
</Link>
</Typography>
<Typography variant="caption">{profile?.email}</Typography>
</Box>
</Box>
</Box>
</Grid>
);
};
export const MembersListCard = ({
entity: groupEntity,
}: {
entity: Entity;
}) => {
const {
metadata: { name: groupName },
} = groupEntity;
const catalogApi = useApi(catalogApiRef);
const { loading, error, value: members } = useAsync(async () => {
const membersList = await catalogApi.getEntities({
filter: {
kind: 'User',
},
});
const groupMembersList = ((membersList.items as unknown) as Array<
UserEntity
>).filter(member =>
member?.relations?.some(
r => r.type === RELATION_MEMBER_OF && r.target.name === groupName,
),
);
return groupMembersList;
}, [catalogApi]);
if (loading) {
return <Progress />;
} else if (error) {
return <Alert severity="error">{error.message}</Alert>;
}
return (
<Grid item>
<InfoCard
title={`Members (${members?.length || 0})`}
subheader={`of ${groupName}`}
>
<Grid container spacing={3}>
{members && members.length ? (
members.map(member => (
<MemberComponent
member={member}
groupEntity={groupEntity}
key={member.metadata.uid}
/>
))
) : (
<Box p={2}>
<Typography>This group has no members.</Typography>
</Box>
)}
</Grid>
</InfoCard>
</Grid>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './MembersListCard';

Some files were not shown because too many files have changed in this diff Show More