Merge branch 'master' into ryanv/product-insights-intervals
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
+10
-8
@@ -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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+11
-12
@@ -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();
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,5 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './catalog';
|
||||
export * from './components';
|
||||
export { plugin } from './plugin';
|
||||
|
||||
@@ -23,9 +23,3 @@ export const rootRoute = createRouteRef({
|
||||
path: '/api-docs',
|
||||
title: 'APIs',
|
||||
});
|
||||
|
||||
export const catalogRoute = createRouteRef({
|
||||
icon: NoIcon,
|
||||
path: '',
|
||||
title: 'API',
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
+113
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+9
-9
@@ -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>
|
||||
|
||||
+2
-2
@@ -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' : ''
|
||||
}`;
|
||||
|
||||
|
||||
+22
-20
@@ -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 (
|
||||
|
||||
+3
-3
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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,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",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint')],
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
# Org Plugin for Backstage
|
||||
|
||||
## Features
|
||||
|
||||
- Show Group Page
|
||||
- Show User Profile
|
||||
@@ -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();
|
||||
@@ -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
Reference in New Issue
Block a user