Merge branch 'master' of github.com:backstage/backstage into mob/scaffolder-frontend
* 'master' of github.com:backstage/backstage: (118 commits) cli: Fix handling of dynamic imports in esm.js files minor typo in migration chore(deps): bump archiver from 5.1.0 to 5.2.0 dockerfile: mention build-image command Apply suggestions from code review update backend Dockerfile to use config example and fix comment docs: add full docker deployment docs chore: fix code review chore: fixing syntax docs: fixing custom implementations of utitiy apis a small start to the integrations section of the config TechDocs: Add changeset about Docker permission fix Updated unit tests for the new UI TechDocs: Pass user and group ID when invoking docker container Replace logging erro and return undefined for a throw new Error @types/react 16 not 17 Use a more strict type for `variant` of cards docs(TechDocs): Add more context with AWS docs hyperlinks Added missing dep on @types/react Removed unused import ...
This commit is contained in:
@@ -1,151 +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 {
|
||||
ApiEntity,
|
||||
EntityName,
|
||||
RELATION_OWNED_BY,
|
||||
RELATION_PART_OF,
|
||||
} from '@backstage/catalog-model';
|
||||
import { Table, TableColumn } from '@backstage/core';
|
||||
import {
|
||||
EntityRefLink,
|
||||
EntityRefLinks,
|
||||
formatEntityRefTitle,
|
||||
getEntityRelations,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import React from 'react';
|
||||
import { ApiTypeTitle } from '../ApiDefinitionCard';
|
||||
|
||||
type EntityRow = {
|
||||
entity: ApiEntity;
|
||||
resolved: {
|
||||
partOfSystemRelationTitle?: string;
|
||||
partOfSystemRelations: EntityName[];
|
||||
ownedByRelationsTitle?: string;
|
||||
ownedByRelations: EntityName[];
|
||||
};
|
||||
};
|
||||
|
||||
const columns: TableColumn<EntityRow>[] = [
|
||||
{
|
||||
title: 'Name',
|
||||
field: 'entity.metadata.name',
|
||||
highlight: true,
|
||||
render: ({ entity }) => (
|
||||
<EntityRefLink entityRef={entity}>{entity.metadata.name}</EntityRefLink>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'System',
|
||||
field: 'resolved.partOfSystemRelationTitle',
|
||||
render: ({ resolved }) => (
|
||||
<EntityRefLinks
|
||||
entityRefs={resolved.partOfSystemRelations}
|
||||
defaultKind="system"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Owner',
|
||||
field: 'resolved.ownedByRelationsTitle',
|
||||
render: ({ resolved }) => (
|
||||
<EntityRefLinks
|
||||
entityRefs={resolved.ownedByRelations}
|
||||
defaultKind="group"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Lifecycle',
|
||||
field: 'entity.spec.lifecycle',
|
||||
},
|
||||
{
|
||||
title: 'Type',
|
||||
field: 'entity.spec.type',
|
||||
render: ({ entity }) => <ApiTypeTitle apiEntity={entity} />,
|
||||
},
|
||||
{
|
||||
title: 'Description',
|
||||
field: 'entity.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)';
|
||||
}
|
||||
|
||||
const rows = entities
|
||||
// TODO: For now we skip all APIs that we can't find without a warning!
|
||||
.filter(e => e !== undefined)
|
||||
.map(entity => {
|
||||
const partOfSystemRelations = getEntityRelations(
|
||||
entity,
|
||||
RELATION_PART_OF,
|
||||
{
|
||||
kind: 'system',
|
||||
},
|
||||
);
|
||||
const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY);
|
||||
|
||||
return {
|
||||
entity: entity as ApiEntity,
|
||||
resolved: {
|
||||
ownedByRelationsTitle: ownedByRelations
|
||||
.map(r => formatEntityRefTitle(r, { defaultKind: 'group' }))
|
||||
.join(', '),
|
||||
ownedByRelations,
|
||||
partOfSystemRelationTitle: partOfSystemRelations
|
||||
.map(r =>
|
||||
formatEntityRefTitle(r, {
|
||||
defaultKind: 'system',
|
||||
}),
|
||||
)
|
||||
.join(', '),
|
||||
partOfSystemRelations,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Table<EntityRow>
|
||||
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',
|
||||
}}
|
||||
data={rows}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -14,12 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
RELATION_CONSUMES_API,
|
||||
RELATION_OWNED_BY,
|
||||
RELATION_PART_OF,
|
||||
} from '@backstage/catalog-model';
|
||||
import { Entity, RELATION_CONSUMES_API } from '@backstage/catalog-model';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core';
|
||||
import {
|
||||
CatalogApi,
|
||||
@@ -79,7 +74,7 @@ describe('<ConsumedApisCard />', () => {
|
||||
);
|
||||
|
||||
expect(getByText(/Consumed APIs/i)).toBeInTheDocument();
|
||||
expect(getByText(/No APIs consumed by this entity/i)).toBeInTheDocument();
|
||||
expect(getByText(/No Component consumes this API/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows consumed APIs', async () => {
|
||||
@@ -108,34 +103,7 @@ describe('<ConsumedApisCard />', () => {
|
||||
name: 'target-name',
|
||||
namespace: 'my-namespace',
|
||||
},
|
||||
spec: {
|
||||
type: 'openapi',
|
||||
lifecycle: 'production',
|
||||
definition: '...',
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: RELATION_PART_OF,
|
||||
target: {
|
||||
kind: 'System',
|
||||
name: 'MySystem',
|
||||
namespace: 'default',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: RELATION_OWNED_BY,
|
||||
target: {
|
||||
kind: 'Group',
|
||||
name: 'Test',
|
||||
namespace: 'default',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
apiDocsConfig.getApiDefinitionWidget.mockReturnValue({
|
||||
type: 'openapi',
|
||||
title: 'OpenAPI',
|
||||
component: () => <div />,
|
||||
spec: {},
|
||||
});
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
@@ -147,12 +115,8 @@ describe('<ConsumedApisCard />', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText(/Consumed APIs/i)).toBeInTheDocument();
|
||||
expect(getByText('Consumed APIs')).toBeInTheDocument();
|
||||
expect(getByText(/target-name/i)).toBeInTheDocument();
|
||||
expect(getByText(/OpenAPI/)).toBeInTheDocument();
|
||||
expect(getByText(/Test/i)).toBeInTheDocument();
|
||||
expect(getByText(/MySystem/i)).toBeInTheDocument();
|
||||
expect(getByText(/production/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,70 +19,67 @@ import {
|
||||
Entity,
|
||||
RELATION_CONSUMES_API,
|
||||
} from '@backstage/catalog-model';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
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>
|
||||
);
|
||||
};
|
||||
import {
|
||||
CodeSnippet,
|
||||
InfoCard,
|
||||
Link,
|
||||
Progress,
|
||||
WarningPanel,
|
||||
} from '@backstage/core';
|
||||
import {
|
||||
EntityTable,
|
||||
useEntity,
|
||||
useRelatedEntities,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import React from 'react';
|
||||
import { apiEntityColumns } from './presets';
|
||||
|
||||
type Props = {
|
||||
/** @deprecated The entity is now grabbed from context instead */
|
||||
entity?: Entity;
|
||||
variant?: string;
|
||||
variant?: 'gridItem';
|
||||
};
|
||||
|
||||
export const ConsumedApisCard = ({ variant = 'gridItem' }: Props) => {
|
||||
const { entity } = useEntity();
|
||||
const { entities, loading, error } = useRelatedEntities(
|
||||
entity,
|
||||
RELATION_CONSUMES_API,
|
||||
);
|
||||
const { entities, loading, error } = useRelatedEntities(entity, {
|
||||
type: RELATION_CONSUMES_API,
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ApisCard variant={variant}>
|
||||
<InfoCard variant={variant} title="Consumed APIs">
|
||||
<Progress />
|
||||
</ApisCard>
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (error || !entities) {
|
||||
return (
|
||||
<ApisCard variant={variant}>
|
||||
<EmptyState
|
||||
missing="info"
|
||||
title="No information to display"
|
||||
description="There was an error while loading the consumed APIs."
|
||||
<InfoCard variant={variant} title="Consumed APIs">
|
||||
<WarningPanel
|
||||
severity="error"
|
||||
title="Could not load APIs"
|
||||
message={<CodeSnippet text={`${error}`} language="text" />}
|
||||
/>
|
||||
</ApisCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (!entities || entities.length === 0) {
|
||||
return (
|
||||
<ApisCard variant={variant}>
|
||||
<MissingConsumesApisEmptyState />
|
||||
</ApisCard>
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ApisTable
|
||||
<EntityTable
|
||||
title="Consumed APIs"
|
||||
variant={variant}
|
||||
entities={entities as (ApiEntity | undefined)[]}
|
||||
emptyContent={
|
||||
<div>
|
||||
No Component consumes this API.{' '}
|
||||
<Link to="https://backstage.io/docs/features/software-catalog/descriptor-format#specconsumesapis-optional">
|
||||
Learn how to consume APIs.
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
columns={apiEntityColumns}
|
||||
entities={entities as ApiEntity[]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* 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_HAS_PART } from '@backstage/catalog-model';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core';
|
||||
import {
|
||||
CatalogApi,
|
||||
catalogApiRef,
|
||||
EntityProvider,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { ApiDocsConfig, apiDocsConfigRef } from '../../config';
|
||||
import { HasApisCard } from './HasApisCard';
|
||||
|
||||
describe('<HasApisCard />', () => {
|
||||
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: 'System',
|
||||
metadata: {
|
||||
name: 'my-system',
|
||||
namespace: 'my-namespace',
|
||||
},
|
||||
relations: [],
|
||||
};
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
<Wrapper>
|
||||
<EntityProvider entity={entity}>
|
||||
<HasApisCard />
|
||||
</EntityProvider>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(getByText('APIs')).toBeInTheDocument();
|
||||
expect(getByText(/No API is part of this system/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows related APIs', async () => {
|
||||
const entity: Entity = {
|
||||
apiVersion: 'v1',
|
||||
kind: 'System',
|
||||
metadata: {
|
||||
name: 'my-system',
|
||||
namespace: 'my-namespace',
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
target: {
|
||||
kind: 'API',
|
||||
namespace: 'my-namespace',
|
||||
name: 'target-name',
|
||||
},
|
||||
type: RELATION_HAS_PART,
|
||||
},
|
||||
],
|
||||
};
|
||||
catalogApi.getEntityByName.mockResolvedValue({
|
||||
apiVersion: 'v1',
|
||||
kind: 'API',
|
||||
metadata: {
|
||||
name: 'target-name',
|
||||
namespace: 'my-namespace',
|
||||
},
|
||||
spec: {},
|
||||
});
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
<Wrapper>
|
||||
<EntityProvider entity={entity}>
|
||||
<HasApisCard />
|
||||
</EntityProvider>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('APIs')).toBeInTheDocument();
|
||||
expect(getByText(/target-name/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* 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, RELATION_HAS_PART } from '@backstage/catalog-model';
|
||||
import {
|
||||
CodeSnippet,
|
||||
InfoCard,
|
||||
Link,
|
||||
Progress,
|
||||
WarningPanel,
|
||||
} from '@backstage/core';
|
||||
import {
|
||||
EntityTable,
|
||||
useEntity,
|
||||
useRelatedEntities,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import React from 'react';
|
||||
import { apiEntityColumns } from './presets';
|
||||
|
||||
type Props = {
|
||||
variant?: 'gridItem';
|
||||
};
|
||||
|
||||
export const HasApisCard = ({ variant = 'gridItem' }: Props) => {
|
||||
const { entity } = useEntity();
|
||||
const { entities, loading, error } = useRelatedEntities(entity, {
|
||||
type: RELATION_HAS_PART,
|
||||
kind: 'API',
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<InfoCard variant={variant} title="APIs">
|
||||
<Progress />
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !entities) {
|
||||
return (
|
||||
<InfoCard variant={variant} title="APIs">
|
||||
<WarningPanel
|
||||
severity="error"
|
||||
title="Could not load APIs"
|
||||
message={<CodeSnippet text={`${error}`} language="text" />}
|
||||
/>
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EntityTable
|
||||
title="APIs"
|
||||
variant={variant}
|
||||
emptyContent={
|
||||
<div>
|
||||
No API is part of this system.{' '}
|
||||
<Link to="https://backstage.io/docs/features/software-catalog/descriptor-format#kind-api">
|
||||
Learn how to add APIs.
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
columns={apiEntityColumns}
|
||||
entities={entities as ApiEntity[]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -14,12 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
RELATION_OWNED_BY,
|
||||
RELATION_PART_OF,
|
||||
RELATION_PROVIDES_API,
|
||||
} from '@backstage/catalog-model';
|
||||
import { Entity, RELATION_PROVIDES_API } from '@backstage/catalog-model';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core';
|
||||
import {
|
||||
CatalogApi,
|
||||
@@ -79,7 +74,7 @@ describe('<ProvidedApisCard />', () => {
|
||||
);
|
||||
|
||||
expect(getByText(/Provided APIs/i)).toBeInTheDocument();
|
||||
expect(getByText(/No APIs provided by this entity/i)).toBeInTheDocument();
|
||||
expect(getByText(/No component provides this API/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows consumed APIs', async () => {
|
||||
@@ -108,34 +103,7 @@ describe('<ProvidedApisCard />', () => {
|
||||
name: 'target-name',
|
||||
namespace: 'my-namespace',
|
||||
},
|
||||
spec: {
|
||||
type: 'openapi',
|
||||
lifecycle: 'production',
|
||||
definition: '...',
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: RELATION_PART_OF,
|
||||
target: {
|
||||
kind: 'System',
|
||||
name: 'MySystem',
|
||||
namespace: 'default',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: RELATION_OWNED_BY,
|
||||
target: {
|
||||
kind: 'Group',
|
||||
name: 'Test',
|
||||
namespace: 'default',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
apiDocsConfig.getApiDefinitionWidget.mockReturnValue({
|
||||
type: 'openapi',
|
||||
title: 'OpenAPI',
|
||||
component: () => <div />,
|
||||
spec: {},
|
||||
});
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
@@ -149,10 +117,6 @@ describe('<ProvidedApisCard />', () => {
|
||||
await waitFor(() => {
|
||||
expect(getByText(/Provided APIs/i)).toBeInTheDocument();
|
||||
expect(getByText(/target-name/i)).toBeInTheDocument();
|
||||
expect(getByText(/OpenAPI/)).toBeInTheDocument();
|
||||
expect(getByText(/MySystem/)).toBeInTheDocument();
|
||||
expect(getByText(/Test/i)).toBeInTheDocument();
|
||||
expect(getByText(/production/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,70 +19,67 @@ import {
|
||||
Entity,
|
||||
RELATION_PROVIDES_API,
|
||||
} from '@backstage/catalog-model';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
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>
|
||||
);
|
||||
};
|
||||
import {
|
||||
CodeSnippet,
|
||||
InfoCard,
|
||||
Link,
|
||||
Progress,
|
||||
WarningPanel,
|
||||
} from '@backstage/core';
|
||||
import {
|
||||
EntityTable,
|
||||
useEntity,
|
||||
useRelatedEntities,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import React from 'react';
|
||||
import { apiEntityColumns } from './presets';
|
||||
|
||||
type Props = {
|
||||
/** @deprecated The entity is now grabbed from context instead */
|
||||
entity?: Entity;
|
||||
variant?: string;
|
||||
variant?: 'gridItem';
|
||||
};
|
||||
|
||||
export const ProvidedApisCard = ({ variant = 'gridItem' }: Props) => {
|
||||
const { entity } = useEntity();
|
||||
const { entities, loading, error } = useRelatedEntities(
|
||||
entity,
|
||||
RELATION_PROVIDES_API,
|
||||
);
|
||||
const { entities, loading, error } = useRelatedEntities(entity, {
|
||||
type: RELATION_PROVIDES_API,
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ApisCard variant={variant}>
|
||||
<InfoCard variant={variant} title="Provided APIs">
|
||||
<Progress />
|
||||
</ApisCard>
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (error || !entities) {
|
||||
return (
|
||||
<ApisCard variant={variant}>
|
||||
<EmptyState
|
||||
missing="info"
|
||||
title="No information to display"
|
||||
description="There was an error while loading the provided APIs."
|
||||
<InfoCard variant={variant} title="Provided APIs">
|
||||
<WarningPanel
|
||||
severity="error"
|
||||
title="Could not load APIs"
|
||||
message={<CodeSnippet text={`${error}`} language="text" />}
|
||||
/>
|
||||
</ApisCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (!entities || entities.length === 0) {
|
||||
return (
|
||||
<ApisCard variant={variant}>
|
||||
<MissingProvidesApisEmptyState />
|
||||
</ApisCard>
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ApisTable
|
||||
<EntityTable
|
||||
title="Provided APIs"
|
||||
variant={variant}
|
||||
entities={entities as (ApiEntity | undefined)[]}
|
||||
emptyContent={
|
||||
<div>
|
||||
No component provides this API.{' '}
|
||||
<Link to="https://backstage.io/docs/features/software-catalog/descriptor-format#specprovidesapis-optional">
|
||||
Learn how to provide APIs.
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
columns={apiEntityColumns}
|
||||
entities={entities as ApiEntity[]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,4 +15,5 @@
|
||||
*/
|
||||
|
||||
export { ConsumedApisCard } from './ConsumedApisCard';
|
||||
export { HasApisCard } from './HasApisCard';
|
||||
export { ProvidedApisCard } from './ProvidedApisCard';
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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 { TableColumn } from '@backstage/core';
|
||||
import { EntityTable } from '@backstage/plugin-catalog-react';
|
||||
import React from 'react';
|
||||
import { ApiTypeTitle } from '../ApiDefinitionCard';
|
||||
|
||||
export function createSpecApiTypeColumn(): TableColumn<ApiEntity> {
|
||||
return {
|
||||
title: 'Type',
|
||||
field: 'spec.type',
|
||||
render: entity => <ApiTypeTitle apiEntity={entity} />,
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: This could be moved to plugin-catalog-react if we wouldn't have a
|
||||
// special createSpecApiTypeColumn. But this is required to use ApiTypeTitle to
|
||||
// resolve the display name of an entity. Is the display name really worth it?
|
||||
|
||||
export const apiEntityColumns: TableColumn<ApiEntity>[] = [
|
||||
EntityTable.columns.createEntityRefColumn({ defaultKind: 'API' }),
|
||||
EntityTable.columns.createSystemColumn(),
|
||||
EntityTable.columns.createOwnerColumn(),
|
||||
EntityTable.columns.createSpecLifecycleColumn(),
|
||||
createSpecApiTypeColumn(),
|
||||
EntityTable.columns.createMetadataDescriptionColumn(),
|
||||
];
|
||||
@@ -1,154 +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,
|
||||
EntityName,
|
||||
RELATION_OWNED_BY,
|
||||
RELATION_PART_OF,
|
||||
} from '@backstage/catalog-model';
|
||||
import { Table, TableColumn } from '@backstage/core';
|
||||
import {
|
||||
EntityRefLink,
|
||||
EntityRefLinks,
|
||||
formatEntityRefTitle,
|
||||
getEntityRelations,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import React from 'react';
|
||||
|
||||
type EntityRow = {
|
||||
entity: ComponentEntity;
|
||||
resolved: {
|
||||
partOfSystemRelationTitle?: string;
|
||||
partOfSystemRelations: EntityName[];
|
||||
ownedByRelationsTitle?: string;
|
||||
ownedByRelations: EntityName[];
|
||||
};
|
||||
};
|
||||
|
||||
const columns: TableColumn<EntityRow>[] = [
|
||||
{
|
||||
title: 'Name',
|
||||
field: 'entity.metadata.name',
|
||||
highlight: true,
|
||||
render: ({ entity }) => (
|
||||
<EntityRefLink entityRef={entity}>{entity.metadata.name}</EntityRefLink>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'System',
|
||||
field: 'resolved.partOfSystemRelationTitle',
|
||||
render: ({ resolved }) => (
|
||||
<EntityRefLinks
|
||||
entityRefs={resolved.partOfSystemRelations}
|
||||
defaultKind="system"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Owner',
|
||||
field: 'resolved.ownedByRelationsTitle',
|
||||
render: ({ resolved }) => (
|
||||
<EntityRefLinks
|
||||
entityRefs={resolved.ownedByRelations}
|
||||
defaultKind="group"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Lifecycle',
|
||||
field: 'entity.spec.lifecycle',
|
||||
},
|
||||
{
|
||||
title: 'Type',
|
||||
field: 'entity.spec.type',
|
||||
},
|
||||
{
|
||||
title: 'Description',
|
||||
field: 'entity.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)';
|
||||
}
|
||||
|
||||
const rows = entities
|
||||
// TODO: For now we skip all Components that we can't find without a warning!
|
||||
.filter(e => e !== undefined)
|
||||
.map(entity => {
|
||||
const partOfSystemRelations = getEntityRelations(
|
||||
entity,
|
||||
RELATION_PART_OF,
|
||||
{
|
||||
kind: 'system',
|
||||
},
|
||||
);
|
||||
const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY);
|
||||
|
||||
return {
|
||||
entity: entity as ComponentEntity,
|
||||
resolved: {
|
||||
ownedByRelationsTitle: ownedByRelations
|
||||
.map(r => formatEntityRefTitle(r, { defaultKind: 'group' }))
|
||||
.join(', '),
|
||||
ownedByRelations,
|
||||
partOfSystemRelationTitle: partOfSystemRelations
|
||||
.map(r =>
|
||||
formatEntityRefTitle(r, {
|
||||
defaultKind: 'system',
|
||||
}),
|
||||
)
|
||||
.join(', '),
|
||||
partOfSystemRelations,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Table<EntityRow>
|
||||
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',
|
||||
}}
|
||||
data={rows}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -14,12 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
RELATION_API_CONSUMED_BY,
|
||||
RELATION_OWNED_BY,
|
||||
RELATION_PART_OF,
|
||||
} from '@backstage/catalog-model';
|
||||
import { Entity, RELATION_API_CONSUMED_BY } from '@backstage/catalog-model';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core';
|
||||
import {
|
||||
CatalogApi,
|
||||
@@ -77,8 +72,8 @@ describe('<ConsumingComponentsCard />', () => {
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(getByText(/Consumers/i)).toBeInTheDocument();
|
||||
expect(getByText(/No APIs consumed by this entity/i)).toBeInTheDocument();
|
||||
expect(getByText('Consumers')).toBeInTheDocument();
|
||||
expect(getByText(/No component consumes this API/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows consuming components', async () => {
|
||||
@@ -113,28 +108,7 @@ describe('<ConsumingComponentsCard />', () => {
|
||||
name: 'target-name',
|
||||
namespace: 'my-namespace',
|
||||
},
|
||||
spec: {
|
||||
type: 'service',
|
||||
lifecycle: 'production',
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: RELATION_PART_OF,
|
||||
target: {
|
||||
kind: 'System',
|
||||
name: 'MySystem',
|
||||
namespace: 'default',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: RELATION_OWNED_BY,
|
||||
target: {
|
||||
kind: 'Group',
|
||||
name: 'Test',
|
||||
namespace: 'default',
|
||||
},
|
||||
},
|
||||
],
|
||||
spec: {},
|
||||
});
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
@@ -146,11 +120,8 @@ describe('<ConsumingComponentsCard />', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText(/Consumers/i)).toBeInTheDocument();
|
||||
expect(getByText('Consumers')).toBeInTheDocument();
|
||||
expect(getByText(/target-name/i)).toBeInTheDocument();
|
||||
expect(getByText(/Test/i)).toBeInTheDocument();
|
||||
expect(getByText(/MySystem/i)).toBeInTheDocument();
|
||||
expect(getByText(/production/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,70 +19,66 @@ import {
|
||||
Entity,
|
||||
RELATION_API_CONSUMED_BY,
|
||||
} from '@backstage/catalog-model';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
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>
|
||||
);
|
||||
};
|
||||
import {
|
||||
CodeSnippet,
|
||||
InfoCard,
|
||||
Link,
|
||||
Progress,
|
||||
WarningPanel,
|
||||
} from '@backstage/core';
|
||||
import {
|
||||
EntityTable,
|
||||
useEntity,
|
||||
useRelatedEntities,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
/** @deprecated The entity is now grabbed from context instead */
|
||||
entity?: Entity;
|
||||
variant?: string;
|
||||
variant?: 'gridItem';
|
||||
};
|
||||
|
||||
export const ConsumingComponentsCard = ({ variant = 'gridItem' }: Props) => {
|
||||
const { entity } = useEntity();
|
||||
const { entities, loading, error } = useRelatedEntities(
|
||||
entity,
|
||||
RELATION_API_CONSUMED_BY,
|
||||
);
|
||||
const { entities, loading, error } = useRelatedEntities(entity, {
|
||||
type: RELATION_API_CONSUMED_BY,
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ComponentsCard variant={variant}>
|
||||
<InfoCard variant={variant} title="Consumers">
|
||||
<Progress />
|
||||
</ComponentsCard>
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (error || !entities) {
|
||||
return (
|
||||
<ComponentsCard variant={variant}>
|
||||
<EmptyState
|
||||
missing="info"
|
||||
title="No information to display"
|
||||
description="There was an error while loading the consumers."
|
||||
<InfoCard variant={variant} title="Consumers">
|
||||
<WarningPanel
|
||||
severity="error"
|
||||
title="Could not load components"
|
||||
message={<CodeSnippet text={`${error}`} language="text" />}
|
||||
/>
|
||||
</ComponentsCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (!entities || entities.length === 0) {
|
||||
return (
|
||||
<ComponentsCard variant={variant}>
|
||||
<MissingConsumesApisEmptyState />
|
||||
</ComponentsCard>
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ComponentsTable
|
||||
<EntityTable
|
||||
title="Consumers"
|
||||
variant={variant}
|
||||
entities={entities as (ComponentEntity | undefined)[]}
|
||||
emptyContent={
|
||||
<div>
|
||||
No component consumes this API.{' '}
|
||||
<Link to="https://backstage.io/docs/features/software-catalog/descriptor-format#specconsumesapis-optional">
|
||||
Learn how to consume APIs.
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
columns={EntityTable.componentEntityColumns}
|
||||
entities={entities as ComponentEntity[]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,12 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
RELATION_API_PROVIDED_BY,
|
||||
RELATION_OWNED_BY,
|
||||
RELATION_PART_OF,
|
||||
} from '@backstage/catalog-model';
|
||||
import { Entity, RELATION_API_PROVIDED_BY } from '@backstage/catalog-model';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core';
|
||||
import {
|
||||
CatalogApi,
|
||||
@@ -77,8 +72,8 @@ describe('<ProvidingComponentsCard />', () => {
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(getByText(/Providers/i)).toBeInTheDocument();
|
||||
expect(getByText(/No APIs provided by this entity/i)).toBeInTheDocument();
|
||||
expect(getByText('Providers')).toBeInTheDocument();
|
||||
expect(getByText(/No component provides this API/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows providing components', async () => {
|
||||
@@ -113,28 +108,7 @@ describe('<ProvidingComponentsCard />', () => {
|
||||
name: 'target-name',
|
||||
namespace: 'my-namespace',
|
||||
},
|
||||
spec: {
|
||||
type: 'service',
|
||||
lifecycle: 'production',
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: RELATION_PART_OF,
|
||||
target: {
|
||||
kind: 'System',
|
||||
name: 'MySystem',
|
||||
namespace: 'default',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: RELATION_OWNED_BY,
|
||||
target: {
|
||||
kind: 'Group',
|
||||
name: 'Test',
|
||||
namespace: 'default',
|
||||
},
|
||||
},
|
||||
],
|
||||
spec: {},
|
||||
});
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
@@ -146,11 +120,8 @@ describe('<ProvidingComponentsCard />', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText(/Providers/i)).toBeInTheDocument();
|
||||
expect(getByText('Providers')).toBeInTheDocument();
|
||||
expect(getByText(/target-name/i)).toBeInTheDocument();
|
||||
expect(getByText(/Test/i)).toBeInTheDocument();
|
||||
expect(getByText(/MySystem/i)).toBeInTheDocument();
|
||||
expect(getByText(/production/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,70 +19,66 @@ import {
|
||||
Entity,
|
||||
RELATION_API_PROVIDED_BY,
|
||||
} from '@backstage/catalog-model';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
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>
|
||||
);
|
||||
};
|
||||
import {
|
||||
CodeSnippet,
|
||||
InfoCard,
|
||||
Link,
|
||||
Progress,
|
||||
WarningPanel,
|
||||
} from '@backstage/core';
|
||||
import {
|
||||
EntityTable,
|
||||
useEntity,
|
||||
useRelatedEntities,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
/** @deprecated The entity is now grabbed from context instead */
|
||||
entity?: Entity;
|
||||
variant?: string;
|
||||
variant?: 'gridItem';
|
||||
};
|
||||
|
||||
export const ProvidingComponentsCard = ({ variant = 'gridItem' }: Props) => {
|
||||
const { entity } = useEntity();
|
||||
const { entities, loading, error } = useRelatedEntities(
|
||||
entity,
|
||||
RELATION_API_PROVIDED_BY,
|
||||
);
|
||||
const { entities, loading, error } = useRelatedEntities(entity, {
|
||||
type: RELATION_API_PROVIDED_BY,
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ComponentsCard variant={variant}>
|
||||
<InfoCard variant={variant} title="Providers">
|
||||
<Progress />
|
||||
</ComponentsCard>
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (error || !entities) {
|
||||
return (
|
||||
<ComponentsCard variant={variant}>
|
||||
<EmptyState
|
||||
missing="info"
|
||||
title="No information to display"
|
||||
description="There was an error while loading the providers."
|
||||
<InfoCard variant={variant} title="Providers">
|
||||
<WarningPanel
|
||||
severity="error"
|
||||
title="Could not load components"
|
||||
message={<CodeSnippet text={`${error}`} language="text" />}
|
||||
/>
|
||||
</ComponentsCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (!entities || entities.length === 0) {
|
||||
return (
|
||||
<ComponentsCard variant={variant}>
|
||||
<MissingProvidesApisEmptyState />
|
||||
</ComponentsCard>
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ComponentsTable
|
||||
<EntityTable
|
||||
title="Providers"
|
||||
variant={variant}
|
||||
entities={entities as (ComponentEntity | undefined)[]}
|
||||
emptyContent={
|
||||
<div>
|
||||
No component provides this API.{' '}
|
||||
<Link to="https://backstage.io/docs/features/software-catalog/descriptor-format#specprovidesapis-optional">
|
||||
Learn how to provide APIs.
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
columns={EntityTable.componentEntityColumns}
|
||||
entities={entities as ComponentEntity[]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,81 +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 { 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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,81 +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 { 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
|
||||
providesApis:
|
||||
- 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 MissingProvidesApisEmptyState = () => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<EmptyState
|
||||
missing="field"
|
||||
title="No APIs provided by this entity"
|
||||
description={
|
||||
<>
|
||||
Components can implement APIs that are displayed on this page. You
|
||||
need to fill the <code>providesApis</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#specprovidesapis-optional"
|
||||
>
|
||||
Read more
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -24,4 +24,5 @@ export {
|
||||
EntityConsumingComponentsCard,
|
||||
EntityProvidedApisCard,
|
||||
EntityProvidingComponentsCard,
|
||||
EntityHasApisCard,
|
||||
} from './plugin';
|
||||
|
||||
@@ -106,3 +106,11 @@ export const EntityProvidingComponentsCard = apiDocsPlugin.provide(
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export const EntityHasApisCard = apiDocsPlugin.provide(
|
||||
createComponentExtension({
|
||||
component: {
|
||||
lazy: () => import('./components/ApisCards').then(m => m.HasApisCard),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -14,7 +14,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { configApiRef, InfoCard, useApi } from '@backstage/core';
|
||||
import {
|
||||
configApiRef,
|
||||
InfoCard,
|
||||
InfoCardVariants,
|
||||
useApi,
|
||||
} from '@backstage/core';
|
||||
import { Step, StepContent, Stepper } from '@material-ui/core';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import React, { useMemo } from 'react';
|
||||
@@ -39,7 +44,7 @@ type Props = {
|
||||
flow: ImportFlows,
|
||||
defaults: StepperProvider,
|
||||
) => StepperProvider;
|
||||
variant?: string;
|
||||
variant?: InfoCardVariants;
|
||||
opts?: StepperProviderOpts;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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 { renderInTestApp } from '@backstage/test-utils';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { EntityTable } from './EntityTable';
|
||||
|
||||
describe('<EntityTable />', () => {
|
||||
it('shows empty table', async () => {
|
||||
const { getByText } = await renderInTestApp(
|
||||
<EntityTable
|
||||
title="Entities"
|
||||
entities={[]}
|
||||
emptyContent={<div>EMPTY</div>}
|
||||
columns={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByText('Entities')).toBeInTheDocument();
|
||||
expect(getByText('EMPTY')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows entities', async () => {
|
||||
const entities: Entity[] = [
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'System',
|
||||
metadata: {
|
||||
name: 'my-entity',
|
||||
},
|
||||
spec: {},
|
||||
},
|
||||
];
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
<EntityTable
|
||||
title="Entities"
|
||||
entities={entities}
|
||||
emptyContent={<div>EMPTY</div>}
|
||||
columns={[
|
||||
{
|
||||
title: 'Name',
|
||||
field: 'metadata.name',
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('my-entity')).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 { Entity } from '@backstage/catalog-model';
|
||||
import { Table, TableColumn } from '@backstage/core';
|
||||
import { makeStyles } from '@material-ui/core';
|
||||
import React, { ReactNode } from 'react';
|
||||
import * as columnFactories from './columns';
|
||||
import { componentEntityColumns, systemEntityColumns } from './presets';
|
||||
|
||||
type Props<T extends Entity> = {
|
||||
title: string;
|
||||
variant?: 'gridItem';
|
||||
entities: T[];
|
||||
emptyContent?: ReactNode;
|
||||
columns: TableColumn<T>[];
|
||||
};
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
empty: {
|
||||
padding: theme.spacing(2),
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
}));
|
||||
|
||||
export function EntityTable<T extends Entity>({
|
||||
entities,
|
||||
title,
|
||||
emptyContent,
|
||||
variant = 'gridItem',
|
||||
columns,
|
||||
}: Props<T>) {
|
||||
const classes = useStyles();
|
||||
const tableStyle: React.CSSProperties = {
|
||||
minWidth: '0',
|
||||
width: '100%',
|
||||
};
|
||||
|
||||
if (variant === 'gridItem') {
|
||||
tableStyle.height = 'calc(100% - 10px)';
|
||||
}
|
||||
|
||||
return (
|
||||
<Table<T>
|
||||
columns={columns}
|
||||
title={title}
|
||||
style={tableStyle}
|
||||
emptyContent={
|
||||
emptyContent && <div className={classes.empty}>{emptyContent}</div>
|
||||
}
|
||||
options={{
|
||||
// TODO: Toolbar padding if off compared to other cards, should be: padding: 16px 24px;
|
||||
search: false,
|
||||
paging: false,
|
||||
actionsColumnIndex: -1,
|
||||
padding: 'dense',
|
||||
}}
|
||||
data={entities}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
EntityTable.columns = columnFactories;
|
||||
|
||||
EntityTable.systemEntityColumns = systemEntityColumns;
|
||||
|
||||
EntityTable.componentEntityColumns = componentEntityColumns;
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* 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,
|
||||
EntityName,
|
||||
RELATION_OWNED_BY,
|
||||
RELATION_PART_OF,
|
||||
} from '@backstage/catalog-model';
|
||||
import { TableColumn } from '@backstage/core';
|
||||
import React from 'react';
|
||||
import { getEntityRelations } from '../../utils';
|
||||
import {
|
||||
EntityRefLink,
|
||||
EntityRefLinks,
|
||||
formatEntityRefTitle,
|
||||
} from '../EntityRefLink';
|
||||
|
||||
export function createEntityRefColumn<T extends Entity>({
|
||||
defaultKind,
|
||||
}: {
|
||||
defaultKind?: string;
|
||||
}): TableColumn<T> {
|
||||
function formatContent(entity: T): string {
|
||||
return formatEntityRefTitle(entity, {
|
||||
defaultKind,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Name',
|
||||
highlight: true,
|
||||
customFilterAndSearch(filter, entity) {
|
||||
// TODO: We could implement this more efficiently, like searching over
|
||||
// each field that is displayed individually (kind, namespace, name).
|
||||
// but that migth confuse the user as it will behave different than a
|
||||
// simple text search.
|
||||
// Another alternative would be to cache the values. But writing them
|
||||
// into the entity feels bad too.
|
||||
return formatContent(entity).includes(filter);
|
||||
},
|
||||
customSort(entity1, entity2) {
|
||||
// TODO: We could implement this more efficiently by comparing field by field.
|
||||
// This has similar issues as above.
|
||||
return formatContent(entity1).localeCompare(formatContent(entity2));
|
||||
},
|
||||
render: entity => (
|
||||
<EntityRefLink entityRef={entity} defaultKind={defaultKind} />
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function createEntityRelationColumn<T extends Entity>({
|
||||
title,
|
||||
relation,
|
||||
defaultKind,
|
||||
filter: entityFilter,
|
||||
}: {
|
||||
title: string;
|
||||
relation: string;
|
||||
defaultKind?: string;
|
||||
filter?: { kind: string };
|
||||
}): TableColumn<T> {
|
||||
function getRelations(entity: T): EntityName[] {
|
||||
return getEntityRelations(entity, relation, entityFilter);
|
||||
}
|
||||
|
||||
function formatContent(entity: T): string {
|
||||
return getRelations(entity)
|
||||
.map(r => formatEntityRefTitle(r, { defaultKind }))
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
customFilterAndSearch(filter, entity) {
|
||||
return formatContent(entity).includes(filter);
|
||||
},
|
||||
customSort(entity1, entity2) {
|
||||
return formatContent(entity1).localeCompare(formatContent(entity2));
|
||||
},
|
||||
render: entity => {
|
||||
return (
|
||||
<EntityRefLinks
|
||||
entityRefs={getRelations(entity)}
|
||||
defaultKind={defaultKind}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createOwnerColumn<T extends Entity>(): TableColumn<T> {
|
||||
return createEntityRelationColumn({
|
||||
title: 'Owner',
|
||||
relation: RELATION_OWNED_BY,
|
||||
defaultKind: 'group',
|
||||
});
|
||||
}
|
||||
|
||||
export function createDomainColumn<T extends Entity>(): TableColumn<T> {
|
||||
return createEntityRelationColumn({
|
||||
title: 'Domain',
|
||||
relation: RELATION_PART_OF,
|
||||
defaultKind: 'domain',
|
||||
filter: {
|
||||
kind: 'domain',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createSystemColumn<T extends Entity>(): TableColumn<T> {
|
||||
return createEntityRelationColumn({
|
||||
title: 'System',
|
||||
relation: RELATION_PART_OF,
|
||||
defaultKind: 'system',
|
||||
filter: {
|
||||
kind: 'system',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createMetadataDescriptionColumn<
|
||||
T extends Entity
|
||||
>(): TableColumn<T> {
|
||||
return {
|
||||
title: 'Description',
|
||||
field: 'metadata.description',
|
||||
width: 'auto',
|
||||
};
|
||||
}
|
||||
|
||||
export function createSpecLifecycleColumn<T extends Entity>(): TableColumn<T> {
|
||||
return {
|
||||
title: 'Lifecycle',
|
||||
field: 'spec.lifecycle',
|
||||
};
|
||||
}
|
||||
|
||||
export function createSpecTypeColumn<T extends Entity>(): TableColumn<T> {
|
||||
return {
|
||||
title: 'Type',
|
||||
field: 'spec.type',
|
||||
};
|
||||
}
|
||||
@@ -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 { EntityTable } from './EntityTable';
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* 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,
|
||||
RELATION_OWNED_BY,
|
||||
RELATION_PART_OF,
|
||||
SystemEntity,
|
||||
} from '@backstage/catalog-model';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { EntityTable } from './EntityTable';
|
||||
import { componentEntityColumns, systemEntityColumns } from './presets';
|
||||
|
||||
describe('systemEntityColumns', () => {
|
||||
it('shows systems', async () => {
|
||||
const entities: SystemEntity[] = [
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'System',
|
||||
metadata: {
|
||||
name: 'my-system',
|
||||
namespace: 'my-namespace',
|
||||
description: 'Some description',
|
||||
},
|
||||
spec: {
|
||||
owner: 'owner-data',
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: RELATION_PART_OF,
|
||||
target: {
|
||||
kind: 'Domain',
|
||||
name: 'my-domain',
|
||||
namespace: 'my-namespace',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: RELATION_OWNED_BY,
|
||||
target: {
|
||||
kind: 'Group',
|
||||
name: 'Test',
|
||||
namespace: 'default',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
<EntityTable
|
||||
title="My Systems"
|
||||
entities={entities}
|
||||
emptyContent={<div>EMPTY</div>}
|
||||
columns={systemEntityColumns}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('my-namespace/my-system')).toBeInTheDocument();
|
||||
expect(getByText('my-namespace/my-domain')).toBeInTheDocument();
|
||||
expect(getByText('Test')).toBeInTheDocument();
|
||||
expect(getByText('Some description')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('componentEntityColumns', () => {
|
||||
it('shows components', async () => {
|
||||
const entities: ComponentEntity[] = [
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'my-component',
|
||||
namespace: 'my-namespace',
|
||||
description: 'Some description',
|
||||
},
|
||||
spec: {
|
||||
type: 'service',
|
||||
lifecycle: 'production',
|
||||
owner: 'owner-data',
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: RELATION_PART_OF,
|
||||
target: {
|
||||
kind: 'System',
|
||||
name: 'my-system',
|
||||
namespace: 'my-namespace',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: RELATION_OWNED_BY,
|
||||
target: {
|
||||
kind: 'Group',
|
||||
name: 'Test',
|
||||
namespace: 'default',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
<EntityTable
|
||||
title="My Components"
|
||||
entities={entities}
|
||||
emptyContent={<div>EMPTY</div>}
|
||||
columns={componentEntityColumns}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('my-namespace/my-component')).toBeInTheDocument();
|
||||
expect(getByText('my-namespace/my-system')).toBeInTheDocument();
|
||||
expect(getByText('Test')).toBeInTheDocument();
|
||||
expect(getByText('production')).toBeInTheDocument();
|
||||
expect(getByText('service')).toBeInTheDocument();
|
||||
expect(getByText('Some description')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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, SystemEntity } from '@backstage/catalog-model';
|
||||
import { TableColumn } from '@backstage/core';
|
||||
import {
|
||||
createDomainColumn,
|
||||
createEntityRefColumn,
|
||||
createMetadataDescriptionColumn,
|
||||
createOwnerColumn,
|
||||
createSpecLifecycleColumn,
|
||||
createSpecTypeColumn,
|
||||
createSystemColumn,
|
||||
} from './columns';
|
||||
|
||||
export const systemEntityColumns: TableColumn<SystemEntity>[] = [
|
||||
createEntityRefColumn({ defaultKind: 'system' }),
|
||||
createDomainColumn(),
|
||||
createOwnerColumn(),
|
||||
createMetadataDescriptionColumn(),
|
||||
];
|
||||
|
||||
export const componentEntityColumns: TableColumn<ComponentEntity>[] = [
|
||||
createEntityRefColumn({ defaultKind: 'component' }),
|
||||
createSystemColumn(),
|
||||
createOwnerColumn(),
|
||||
createSpecTypeColumn(),
|
||||
createSpecLifecycleColumn(),
|
||||
createMetadataDescriptionColumn(),
|
||||
];
|
||||
@@ -13,5 +13,6 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export * from './EntityRefLink';
|
||||
export * from './EntityProvider';
|
||||
export * from './EntityRefLink';
|
||||
export * from './EntityTable';
|
||||
|
||||
@@ -15,3 +15,4 @@
|
||||
*/
|
||||
export { EntityContext, useEntity, useEntityFromUrl } from './useEntity';
|
||||
export { useEntityCompoundName } from './useEntityCompoundName';
|
||||
export { useRelatedEntities } from './useRelatedEntities';
|
||||
|
||||
+20
-11
@@ -15,36 +15,45 @@
|
||||
*/
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { useApi } from '@backstage/core';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import { useAsyncRetry } from 'react-use';
|
||||
import { useAsync } from 'react-use';
|
||||
import { catalogApiRef } from '../api';
|
||||
|
||||
// TODO: Maybe this hook is interesting for others too?
|
||||
export function useRelatedEntities(
|
||||
entity: Entity,
|
||||
type: string,
|
||||
{ type, kind }: { type?: string; kind?: string },
|
||||
): {
|
||||
entities: (Entity | undefined)[] | undefined;
|
||||
entities: Entity[] | undefined;
|
||||
loading: boolean;
|
||||
error: Error | undefined;
|
||||
} {
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const { loading, value, error } = useAsyncRetry<
|
||||
(Entity | undefined)[]
|
||||
>(async () => {
|
||||
const { loading, value: entities, error } = useAsync(async () => {
|
||||
const relations =
|
||||
entity.relations && entity.relations.filter(r => r.type === type);
|
||||
entity.relations &&
|
||||
entity.relations.filter(
|
||||
r =>
|
||||
(!type || r.type.toLowerCase() === type.toLowerCase()) &&
|
||||
(!kind || r.target.kind.toLowerCase() === kind.toLowerCase()),
|
||||
);
|
||||
|
||||
if (!relations) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await Promise.all(
|
||||
// TODO: This code could be more efficient if there was an endpoint in the
|
||||
// backend that either returns the relations of entity (filtered by type)
|
||||
// or if there is a way to perform a batch request by entity name. However,
|
||||
// such an implementation would probably be better placed in the graphql API.
|
||||
const results = await Promise.all(
|
||||
relations?.map(r => catalogApi.getEntityByName(r.target)),
|
||||
);
|
||||
// Skip entities that where not found, for example if a relation references
|
||||
// an entity that doesn't exist.
|
||||
return results.filter(e => e) as Entity[];
|
||||
}, [entity, type]);
|
||||
|
||||
return {
|
||||
entities: value,
|
||||
entities,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
@@ -79,7 +79,7 @@ function getCodeLinkInfo(entity: Entity): CodeLinkInfo {
|
||||
type AboutCardProps = {
|
||||
/** @deprecated The entity is now grabbed from context instead */
|
||||
entity?: Entity;
|
||||
variant?: string;
|
||||
variant?: 'gridItem';
|
||||
};
|
||||
|
||||
export function AboutCard({ variant }: AboutCardProps) {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* 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_HAS_PART } from '@backstage/catalog-model';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core';
|
||||
import {
|
||||
CatalogApi,
|
||||
catalogApiRef,
|
||||
EntityProvider,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { HasComponentsCard } from './HasComponentsCard';
|
||||
|
||||
describe('<HasComponentsCard />', () => {
|
||||
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: 'System',
|
||||
metadata: {
|
||||
name: 'my-system',
|
||||
namespace: 'my-namespace',
|
||||
},
|
||||
relations: [],
|
||||
};
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
<Wrapper>
|
||||
<EntityProvider entity={entity}>
|
||||
<HasComponentsCard />
|
||||
</EntityProvider>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(getByText('Components')).toBeInTheDocument();
|
||||
expect(
|
||||
getByText(/No component is part of this system/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows related components', async () => {
|
||||
const entity: Entity = {
|
||||
apiVersion: 'v1',
|
||||
kind: 'System',
|
||||
metadata: {
|
||||
name: 'my-system',
|
||||
namespace: 'my-namespace',
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
target: {
|
||||
kind: 'Component',
|
||||
namespace: 'my-namespace',
|
||||
name: 'target-name',
|
||||
},
|
||||
type: RELATION_HAS_PART,
|
||||
},
|
||||
],
|
||||
};
|
||||
catalogApi.getEntityByName.mockResolvedValue({
|
||||
apiVersion: 'v1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'target-name',
|
||||
namespace: 'my-namespace',
|
||||
},
|
||||
spec: {},
|
||||
});
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
<Wrapper>
|
||||
<EntityProvider entity={entity}>
|
||||
<HasComponentsCard />
|
||||
</EntityProvider>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Components')).toBeInTheDocument();
|
||||
expect(getByText(/target-name/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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, RELATION_HAS_PART } from '@backstage/catalog-model';
|
||||
import {
|
||||
CodeSnippet,
|
||||
InfoCard,
|
||||
Link,
|
||||
Progress,
|
||||
WarningPanel,
|
||||
} from '@backstage/core';
|
||||
import {
|
||||
EntityTable,
|
||||
useEntity,
|
||||
useRelatedEntities,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
variant?: 'gridItem';
|
||||
};
|
||||
|
||||
export const HasComponentsCard = ({ variant = 'gridItem' }: Props) => {
|
||||
const { entity } = useEntity();
|
||||
const { entities, loading, error } = useRelatedEntities(entity, {
|
||||
type: RELATION_HAS_PART,
|
||||
kind: 'Component',
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<InfoCard variant={variant} title="Components">
|
||||
<Progress />
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !entities) {
|
||||
return (
|
||||
<InfoCard variant={variant} title="Components">
|
||||
<WarningPanel
|
||||
severity="error"
|
||||
title="Could not load components"
|
||||
message={<CodeSnippet text={`${error}`} language="text" />}
|
||||
/>
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EntityTable
|
||||
title="Components"
|
||||
variant={variant}
|
||||
emptyContent={
|
||||
<div>
|
||||
No component is part of this system.{' '}
|
||||
<Link to="https://backstage.io/docs/features/software-catalog/descriptor-format#kind-component">
|
||||
Learn how to add components.
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
columns={EntityTable.componentEntityColumns}
|
||||
entities={entities as ComponentEntity[]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+1
-2
@@ -14,5 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { MissingConsumesApisEmptyState } from './MissingConsumesApisEmptyState';
|
||||
export { MissingProvidesApisEmptyState } from './MissingProvidesApisEmptyState';
|
||||
export { HasComponentsCard } from './HasComponentsCard';
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* 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_HAS_PART } from '@backstage/catalog-model';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core';
|
||||
import {
|
||||
CatalogApi,
|
||||
catalogApiRef,
|
||||
EntityProvider,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { HasSubcomponentsCard } from './HasSubcomponentsCard';
|
||||
|
||||
describe('<HasSubcomponentsCard />', () => {
|
||||
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: 'Component',
|
||||
metadata: {
|
||||
name: 'my-components',
|
||||
namespace: 'my-namespace',
|
||||
},
|
||||
relations: [],
|
||||
};
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
<Wrapper>
|
||||
<EntityProvider entity={entity}>
|
||||
<HasSubcomponentsCard />
|
||||
</EntityProvider>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(getByText('Subcomponents')).toBeInTheDocument();
|
||||
expect(
|
||||
getByText(/No subcomponent is part of this component/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows related subcomponents', async () => {
|
||||
const entity: Entity = {
|
||||
apiVersion: 'v1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'my-component',
|
||||
namespace: 'my-namespace',
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
target: {
|
||||
kind: 'Component',
|
||||
namespace: 'my-namespace',
|
||||
name: 'target-name',
|
||||
},
|
||||
type: RELATION_HAS_PART,
|
||||
},
|
||||
],
|
||||
};
|
||||
catalogApi.getEntityByName.mockResolvedValue({
|
||||
apiVersion: 'v1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'target-name',
|
||||
namespace: 'my-namespace',
|
||||
},
|
||||
spec: {},
|
||||
});
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
<Wrapper>
|
||||
<EntityProvider entity={entity}>
|
||||
<HasSubcomponentsCard />
|
||||
</EntityProvider>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Subcomponents')).toBeInTheDocument();
|
||||
expect(getByText(/target-name/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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, RELATION_HAS_PART } from '@backstage/catalog-model';
|
||||
import {
|
||||
CodeSnippet,
|
||||
InfoCard,
|
||||
Link,
|
||||
Progress,
|
||||
WarningPanel,
|
||||
} from '@backstage/core';
|
||||
import {
|
||||
EntityTable,
|
||||
useEntity,
|
||||
useRelatedEntities,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
variant?: 'gridItem';
|
||||
};
|
||||
|
||||
export const HasSubcomponentsCard = ({ variant = 'gridItem' }: Props) => {
|
||||
const { entity } = useEntity();
|
||||
const { entities, loading, error } = useRelatedEntities(entity, {
|
||||
type: RELATION_HAS_PART,
|
||||
kind: 'Component',
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<InfoCard variant={variant} title="Subcomponents">
|
||||
<Progress />
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !entities) {
|
||||
return (
|
||||
<InfoCard variant={variant} title="Subcomponents">
|
||||
<WarningPanel
|
||||
severity="error"
|
||||
title="Could not load subcomponents"
|
||||
message={<CodeSnippet text={`${error}`} language="text" />}
|
||||
/>
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EntityTable
|
||||
title="Subcomponents"
|
||||
variant={variant}
|
||||
emptyContent={
|
||||
<div>
|
||||
No subcomponent is part of this component.{' '}
|
||||
<Link to="https://backstage.io/docs/features/software-catalog/descriptor-format#specsubcomponentof-optional">
|
||||
Learn how to add subcomponents.
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
columns={EntityTable.componentEntityColumns}
|
||||
entities={entities as ComponentEntity[]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+1
-12
@@ -14,15 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
export { HasSubcomponentsCard } from './HasSubcomponentsCard';
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* 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_HAS_PART } from '@backstage/catalog-model';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core';
|
||||
import {
|
||||
CatalogApi,
|
||||
catalogApiRef,
|
||||
EntityProvider,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { HasSystemsCard } from './HasSystemsCard';
|
||||
|
||||
describe('<HasSystemsCard />', () => {
|
||||
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: 'Domain',
|
||||
metadata: {
|
||||
name: 'my-domain',
|
||||
namespace: 'my-namespace',
|
||||
},
|
||||
relations: [],
|
||||
};
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
<Wrapper>
|
||||
<EntityProvider entity={entity}>
|
||||
<HasSystemsCard />
|
||||
</EntityProvider>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(getByText('Systems')).toBeInTheDocument();
|
||||
expect(getByText(/No system is part of this domain/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows related systems', async () => {
|
||||
const entity: Entity = {
|
||||
apiVersion: 'v1',
|
||||
kind: 'Domain',
|
||||
metadata: {
|
||||
name: 'my-domain',
|
||||
namespace: 'my-namespace',
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
target: {
|
||||
kind: 'System',
|
||||
namespace: 'my-namespace',
|
||||
name: 'target-name',
|
||||
},
|
||||
type: RELATION_HAS_PART,
|
||||
},
|
||||
],
|
||||
};
|
||||
catalogApi.getEntityByName.mockResolvedValue({
|
||||
apiVersion: 'v1',
|
||||
kind: 'System',
|
||||
metadata: {
|
||||
name: 'target-name',
|
||||
namespace: 'my-namespace',
|
||||
},
|
||||
spec: {},
|
||||
});
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
<Wrapper>
|
||||
<EntityProvider entity={entity}>
|
||||
<HasSystemsCard />
|
||||
</EntityProvider>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Systems')).toBeInTheDocument();
|
||||
expect(getByText(/target-name/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* 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 { RELATION_HAS_PART, SystemEntity } from '@backstage/catalog-model';
|
||||
import {
|
||||
CodeSnippet,
|
||||
InfoCard,
|
||||
Link,
|
||||
Progress,
|
||||
WarningPanel,
|
||||
} from '@backstage/core';
|
||||
import {
|
||||
EntityTable,
|
||||
useEntity,
|
||||
useRelatedEntities,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
variant?: 'gridItem';
|
||||
};
|
||||
|
||||
export const HasSystemsCard = ({ variant = 'gridItem' }: Props) => {
|
||||
const { entity } = useEntity();
|
||||
const { entities, loading, error } = useRelatedEntities(entity, {
|
||||
type: RELATION_HAS_PART,
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<InfoCard variant={variant} title="Systems">
|
||||
<Progress />
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !entities) {
|
||||
return (
|
||||
<InfoCard variant={variant} title="Systems">
|
||||
<WarningPanel
|
||||
severity="error"
|
||||
title="Could not load systems"
|
||||
message={<CodeSnippet text={`${error}`} language="text" />}
|
||||
/>
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EntityTable
|
||||
title="Systems"
|
||||
variant={variant}
|
||||
emptyContent={
|
||||
<div>
|
||||
No system is part of this domain.{' '}
|
||||
<Link to="https://backstage.io/docs/features/software-catalog/descriptor-format#kind-system">
|
||||
Learn how to add systems.
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
columns={EntityTable.systemEntityColumns}
|
||||
entities={entities as SystemEntity[]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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 { HasSystemsCard } from './HasSystemsCard';
|
||||
@@ -20,10 +20,13 @@ export { EntityPageLayout } from './components/EntityPageLayout';
|
||||
export * from './components/EntitySwitch';
|
||||
export { Router } from './components/Router';
|
||||
export {
|
||||
CatalogEntityPage,
|
||||
CatalogIndexPage,
|
||||
catalogPlugin,
|
||||
catalogPlugin as plugin,
|
||||
CatalogIndexPage,
|
||||
CatalogEntityPage,
|
||||
EntityAboutCard,
|
||||
EntityHasComponentsCard,
|
||||
EntityHasSubcomponentsCard,
|
||||
EntityHasSystemsCard,
|
||||
EntityLinksCard,
|
||||
} from './plugin';
|
||||
|
||||
@@ -17,10 +17,10 @@
|
||||
import { CatalogClient } from '@backstage/catalog-client';
|
||||
import {
|
||||
createApiFactory,
|
||||
createPlugin,
|
||||
discoveryApiRef,
|
||||
createComponentExtension,
|
||||
createPlugin,
|
||||
createRoutableExtension,
|
||||
discoveryApiRef,
|
||||
identityApiRef,
|
||||
} from '@backstage/core';
|
||||
import {
|
||||
@@ -64,9 +64,7 @@ export const CatalogIndexPage = catalogPlugin.provide(
|
||||
export const CatalogEntityPage = catalogPlugin.provide(
|
||||
createRoutableExtension({
|
||||
component: () =>
|
||||
import('./components/CatalogEntityPage/CatalogEntityPage').then(
|
||||
m => m.CatalogEntityPage,
|
||||
),
|
||||
import('./components/CatalogEntityPage').then(m => m.CatalogEntityPage),
|
||||
mountPoint: entityRouteRef,
|
||||
}),
|
||||
);
|
||||
@@ -87,3 +85,32 @@ export const EntityLinksCard = catalogPlugin.provide(
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export const EntityHasSystemsCard = catalogPlugin.provide(
|
||||
createComponentExtension({
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/HasSystemsCard').then(m => m.HasSystemsCard),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export const EntityHasComponentsCard = catalogPlugin.provide(
|
||||
createComponentExtension({
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/HasComponentsCard').then(m => m.HasComponentsCard),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export const EntityHasSubcomponentsCard = catalogPlugin.provide(
|
||||
createComponentExtension({
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/HasSubcomponentsCard').then(
|
||||
m => m.HasSubcomponentsCard,
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -11,6 +11,8 @@ At Spotify, we find that cloud costs are optimized organically when:
|
||||
|
||||
Cost Insights shows trends over time, at the granularity of Backstage catalog entities - rather than the cloud provider's concepts. It can be used to troubleshoot cost anomalies, and promote cost-saving infrastructure migrations.
|
||||
|
||||
Learn more with the Backstage blog post [New Cost Insights plugin: The engineer's solution to taming cloud costs](https://backstage.io/blog/2020/10/22/cost-insights-plugin).
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
|
||||
@@ -13,29 +13,30 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React, { useEffect } from 'react';
|
||||
import { useWorkflowRuns } from '../useWorkflowRuns';
|
||||
import { WorkflowRun, WorkflowRunsTable } from '../WorkflowRunsTable';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import { readGitHubIntegrationConfigs } from '@backstage/integration';
|
||||
import { WorkflowRunStatus } from '../WorkflowRunStatus';
|
||||
import {
|
||||
Link,
|
||||
Theme,
|
||||
makeStyles,
|
||||
LinearProgress,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import {
|
||||
InfoCard,
|
||||
StructuredMetadataTable,
|
||||
configApiRef,
|
||||
errorApiRef,
|
||||
InfoCard,
|
||||
InfoCardVariants,
|
||||
StructuredMetadataTable,
|
||||
useApi,
|
||||
} from '@backstage/core';
|
||||
import { readGitHubIntegrationConfigs } from '@backstage/integration';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
LinearProgress,
|
||||
Link,
|
||||
makeStyles,
|
||||
Theme,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import ExternalLinkIcon from '@material-ui/icons/Launch';
|
||||
import React, { useEffect } from 'react';
|
||||
import { GITHUB_ACTIONS_ANNOTATION } from '../useProjectName';
|
||||
import { useWorkflowRuns } from '../useWorkflowRuns';
|
||||
import { WorkflowRun, WorkflowRunsTable } from '../WorkflowRunsTable';
|
||||
import { WorkflowRunStatus } from '../WorkflowRunStatus';
|
||||
|
||||
const useStyles = makeStyles<Theme>({
|
||||
externalLinkIcon: {
|
||||
@@ -125,7 +126,7 @@ type Props = {
|
||||
/** @deprecated The entity is now grabbed from context instead */
|
||||
entity?: Entity;
|
||||
branch: string;
|
||||
variant?: string;
|
||||
variant?: InfoCardVariants;
|
||||
};
|
||||
|
||||
export const LatestWorkflowsForBranchCard = ({
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
EmptyState,
|
||||
errorApiRef,
|
||||
InfoCard,
|
||||
InfoCardVariants,
|
||||
Table,
|
||||
useApi,
|
||||
} from '@backstage/core';
|
||||
@@ -39,7 +40,7 @@ export type Props = {
|
||||
branch?: string;
|
||||
dense?: boolean;
|
||||
limit?: number;
|
||||
variant?: string;
|
||||
variant?: InfoCardVariants;
|
||||
};
|
||||
|
||||
export const RecentWorkflowRunsCard = ({
|
||||
|
||||
@@ -13,13 +13,17 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import { Link, Theme, makeStyles, LinearProgress } from '@material-ui/core';
|
||||
import { InfoCard, StructuredMetadataTable } from '@backstage/core';
|
||||
import {
|
||||
InfoCard,
|
||||
InfoCardVariants,
|
||||
StructuredMetadataTable,
|
||||
} from '@backstage/core';
|
||||
import { LinearProgress, Link, makeStyles, Theme } from '@material-ui/core';
|
||||
import ExternalLinkIcon from '@material-ui/icons/Launch';
|
||||
import { useBuilds } from '../useBuilds';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import React from 'react';
|
||||
import { JenkinsRunStatus } from '../BuildsPage/lib/Status';
|
||||
import { useBuilds } from '../useBuilds';
|
||||
import { useProjectSlugFromEntity } from '../useProjectSlugFromEntity';
|
||||
|
||||
const useStyles = makeStyles<Theme>({
|
||||
@@ -76,7 +80,7 @@ export const LatestRunCard = ({
|
||||
variant,
|
||||
}: {
|
||||
branch: string;
|
||||
variant?: string;
|
||||
variant?: InfoCardVariants;
|
||||
}) => {
|
||||
const { owner, repo } = useProjectSlugFromEntity();
|
||||
const [{ builds, loading }] = useBuilds(owner, repo, branch);
|
||||
|
||||
@@ -13,16 +13,17 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Audit, AuditCompleted, LighthouseCategoryId } from '../../api';
|
||||
import {
|
||||
InfoCard,
|
||||
InfoCardVariants,
|
||||
Progress,
|
||||
StatusError,
|
||||
StatusOK,
|
||||
StatusWarning,
|
||||
StructuredMetadataTable,
|
||||
} from '@backstage/core';
|
||||
import React from 'react';
|
||||
import { Audit, AuditCompleted, LighthouseCategoryId } from '../../api';
|
||||
import { useWebsiteForEntity } from '../../hooks/useWebsiteForEntity';
|
||||
import AuditStatusIcon from '../AuditStatusIcon';
|
||||
|
||||
@@ -96,7 +97,7 @@ export const LastLighthouseAuditCard = ({
|
||||
variant,
|
||||
}: {
|
||||
dense?: boolean;
|
||||
variant?: string;
|
||||
variant?: InfoCardVariants;
|
||||
}) => {
|
||||
const { value: website, loading, error } = useWebsiteForEntity();
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"dependencies": {
|
||||
"@backstage/catalog-model": "^0.7.1",
|
||||
"@backstage/core": "^0.6.1",
|
||||
"@backstage/core-api": "^0.2.9",
|
||||
"@backstage/plugin-catalog-react": "^0.0.3",
|
||||
"@backstage/theme": "^0.2.3",
|
||||
"@material-ui/core": "^4.11.0",
|
||||
@@ -29,6 +30,7 @@
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-router": "6.0.0-beta.0",
|
||||
"react-router-dom": "6.0.0-beta.0",
|
||||
"react-use": "^15.3.3"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright 2021 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 { Grid } from '@material-ui/core';
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { GroupEntity } from '@backstage/catalog-model';
|
||||
import { EntityContext } from '@backstage/plugin-catalog-react';
|
||||
import { GroupProfileCard } from '.';
|
||||
|
||||
export default {
|
||||
title: 'Plugins/Org/Group Profile Card',
|
||||
component: GroupProfileCard,
|
||||
};
|
||||
|
||||
const dummyDepartment = {
|
||||
type: 'childOf',
|
||||
target: {
|
||||
namespace: 'default',
|
||||
kind: 'group',
|
||||
name: 'department-a',
|
||||
},
|
||||
};
|
||||
|
||||
const defaultEntity: GroupEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'team-a',
|
||||
description: 'Team A',
|
||||
},
|
||||
spec: {
|
||||
profile: {
|
||||
displayName: 'Team A',
|
||||
email: 'team-a@example.com',
|
||||
picture:
|
||||
'https://avatars.dicebear.com/api/identicon/team-a@example.com.svg?background=%23fff&margin=25',
|
||||
},
|
||||
type: 'group',
|
||||
children: [],
|
||||
},
|
||||
relations: [dummyDepartment],
|
||||
};
|
||||
|
||||
export const Default = () => (
|
||||
<MemoryRouter>
|
||||
<EntityContext.Provider value={{ entity: defaultEntity, loading: false }}>
|
||||
<Grid container spacing={4}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<GroupProfileCard variant="gridItem" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</EntityContext.Provider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
@@ -20,10 +20,10 @@ import {
|
||||
RELATION_CHILD_OF,
|
||||
RELATION_PARENT_OF,
|
||||
} from '@backstage/catalog-model';
|
||||
import { Avatar, InfoCard } from '@backstage/core';
|
||||
import { Avatar, InfoCard, InfoCardVariants } from '@backstage/core';
|
||||
import {
|
||||
getEntityRelations,
|
||||
entityRouteParams,
|
||||
getEntityRelations,
|
||||
useEntity,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
@@ -81,7 +81,7 @@ export const GroupProfileCard = ({
|
||||
}: {
|
||||
/** @deprecated The entity is now grabbed from context instead */
|
||||
entity?: GroupEntity;
|
||||
variant: string;
|
||||
variant?: InfoCardVariants;
|
||||
}) => {
|
||||
const group = useEntity().entity as GroupEntity;
|
||||
const {
|
||||
@@ -117,7 +117,7 @@ export const GroupProfileCard = ({
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<Tooltip title="Email">
|
||||
<EmailIcon fontSize="inherit" />
|
||||
<EmailIcon />
|
||||
</Tooltip>
|
||||
</ListItemIcon>
|
||||
<ListItemText>{profile.email}</ListItemText>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* Copyright 2021 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 { Grid } from '@material-ui/core';
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { Entity, GroupEntity } from '@backstage/catalog-model';
|
||||
import { EntityContext, catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core-api';
|
||||
|
||||
import { MembersListCard } from '.';
|
||||
|
||||
export default {
|
||||
title: 'Plugins/Org/Group Members List Card',
|
||||
component: MembersListCard,
|
||||
};
|
||||
|
||||
const makeUser = ({
|
||||
name,
|
||||
uid,
|
||||
displayName,
|
||||
email,
|
||||
}: {
|
||||
name: string;
|
||||
uid: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
}) => ({
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: {
|
||||
name,
|
||||
uid,
|
||||
},
|
||||
spec: {
|
||||
profile: {
|
||||
displayName,
|
||||
email,
|
||||
picture: `https://avatars.dicebear.com/api/avataaars/${email}.svg?background=%23fff`,
|
||||
},
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: 'memberOf',
|
||||
target: {
|
||||
namespace: 'default',
|
||||
kind: 'group',
|
||||
name: 'team-a',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const defaultEntity: GroupEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'team-a',
|
||||
description: 'Team A',
|
||||
},
|
||||
spec: {
|
||||
profile: {
|
||||
displayName: 'Team A',
|
||||
email: 'team-a@example.com',
|
||||
picture:
|
||||
'https://avatars.dicebear.com/api/identicon/team-a@example.com.svg?background=%23fff&margin=25',
|
||||
},
|
||||
type: 'group',
|
||||
children: [],
|
||||
},
|
||||
};
|
||||
|
||||
const alice = makeUser({
|
||||
name: 'alice',
|
||||
uid: '123',
|
||||
displayName: 'Alice Doe',
|
||||
email: 'alice@example.com',
|
||||
});
|
||||
const bob = makeUser({
|
||||
name: 'bob',
|
||||
uid: '456',
|
||||
displayName: 'Bob Jones',
|
||||
email: 'bob@example.com',
|
||||
});
|
||||
|
||||
const catalogApi = (items: Entity[]) => ({
|
||||
getEntities: () => Promise.resolve({ items }),
|
||||
});
|
||||
|
||||
const apiRegistry = (items: Entity[]) =>
|
||||
ApiRegistry.from([[catalogApiRef, catalogApi(items)]]);
|
||||
|
||||
export const Default = () => (
|
||||
<MemoryRouter>
|
||||
<ApiProvider apis={apiRegistry([alice, bob])}>
|
||||
<EntityContext.Provider value={{ entity: defaultEntity, loading: false }}>
|
||||
<Grid container spacing={4}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<MembersListCard />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</EntityContext.Provider>
|
||||
</ApiProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
export const Empty = () => (
|
||||
<MemoryRouter>
|
||||
<ApiProvider apis={apiRegistry([])}>
|
||||
<EntityContext.Provider value={{ entity: defaultEntity, loading: false }}>
|
||||
<Grid container spacing={4}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<MembersListCard />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</EntityContext.Provider>
|
||||
</ApiProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
@@ -47,7 +47,7 @@ const useStyles = makeStyles((theme: Theme) =>
|
||||
borderRadius: '4px',
|
||||
overflow: 'visible',
|
||||
position: 'relative',
|
||||
margin: theme.spacing(3, 0, 1),
|
||||
margin: theme.spacing(4, 1, 1),
|
||||
flex: '1',
|
||||
minWidth: '0px',
|
||||
},
|
||||
@@ -69,7 +69,7 @@ const MemberComponent = ({
|
||||
const displayName = profile?.displayName ?? metaName;
|
||||
|
||||
return (
|
||||
<Grid item container xs={12} sm={6} md={3} xl={2}>
|
||||
<Grid item container xs={12} sm={6} md={4} xl={2}>
|
||||
<Box className={classes.card}>
|
||||
<Box
|
||||
display="flex"
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright 2021 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 { Grid } from '@material-ui/core';
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { GroupEntity } from '@backstage/catalog-model';
|
||||
import {
|
||||
EntityContext,
|
||||
CatalogApi,
|
||||
catalogApiRef,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core-api';
|
||||
|
||||
import { OwnershipCard } from '.';
|
||||
|
||||
export default {
|
||||
title: 'Plugins/Org/Ownership Card',
|
||||
component: OwnershipCard,
|
||||
};
|
||||
|
||||
const defaultEntity: GroupEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'team-a',
|
||||
description: 'Team A',
|
||||
},
|
||||
spec: {
|
||||
profile: {
|
||||
displayName: 'Team A',
|
||||
email: 'team-a@example.com',
|
||||
picture:
|
||||
'https://avatars.dicebear.com/api/identicon/team-a@example.com.svg?background=%23fff&margin=25',
|
||||
},
|
||||
type: 'group',
|
||||
children: [],
|
||||
},
|
||||
};
|
||||
|
||||
const makeComponent = ({ type, name }: { type: string; name: string }) => ({
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name,
|
||||
},
|
||||
spec: {
|
||||
type,
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: 'ownedBy',
|
||||
target: {
|
||||
namespace: 'default',
|
||||
kind: 'Group',
|
||||
name: 'team-a',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const serviceA = makeComponent({ type: 'service', name: 'service-a' });
|
||||
const serviceB = makeComponent({ type: 'service', name: 'service-a' });
|
||||
const websiteA = makeComponent({ type: 'website', name: 'website-a' });
|
||||
|
||||
const catalogApi: Partial<CatalogApi> = {
|
||||
getEntities: () => Promise.resolve({ items: [serviceA, serviceB, websiteA] }),
|
||||
};
|
||||
|
||||
const apiRegistry = ApiRegistry.from([[catalogApiRef, catalogApi]]);
|
||||
|
||||
export const Default = () => (
|
||||
<MemoryRouter>
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<EntityContext.Provider value={{ entity: defaultEntity, loading: false }}>
|
||||
<Grid container spacing={4}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<OwnershipCard variant="gridItem" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</EntityContext.Provider>
|
||||
</ApiProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { InfoCard, Progress, useApi } from '@backstage/core';
|
||||
import { InfoCard, InfoCardVariants, Progress, useApi } from '@backstage/core';
|
||||
import {
|
||||
catalogApiRef,
|
||||
isOwnerOf,
|
||||
@@ -121,7 +121,7 @@ export const OwnershipCard = ({
|
||||
}: {
|
||||
/** @deprecated The entity is now grabbed from context instead */
|
||||
entity?: Entity;
|
||||
variant: string;
|
||||
variant?: InfoCardVariants;
|
||||
}) => {
|
||||
const { entity } = useEntity();
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright 2021 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 { Grid } from '@material-ui/core';
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { UserEntity } from '@backstage/catalog-model';
|
||||
import { EntityContext } from '@backstage/plugin-catalog-react';
|
||||
import { UserProfileCard } from '.';
|
||||
|
||||
export default {
|
||||
title: 'Plugins/Org/User Profile Card',
|
||||
component: UserProfileCard,
|
||||
};
|
||||
|
||||
const dummyGroup = {
|
||||
type: 'memberOf',
|
||||
target: {
|
||||
namespace: 'default',
|
||||
kind: 'group',
|
||||
name: 'team-a',
|
||||
},
|
||||
};
|
||||
|
||||
const defaultEntity: UserEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: {
|
||||
name: 'guest',
|
||||
},
|
||||
spec: {
|
||||
profile: {
|
||||
displayName: 'Guest User',
|
||||
email: 'guest@example.com',
|
||||
picture:
|
||||
'https://avatars.dicebear.com/api/avataaars/guest@example.com.svg?background=%23fff',
|
||||
},
|
||||
memberOf: ['team-a'],
|
||||
},
|
||||
relations: [dummyGroup],
|
||||
};
|
||||
|
||||
export const Default = () => (
|
||||
<MemoryRouter>
|
||||
<EntityContext.Provider value={{ entity: defaultEntity, loading: false }}>
|
||||
<Grid container spacing={4}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<UserProfileCard variant="gridItem" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</EntityContext.Provider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const noImageEntity: UserEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: {
|
||||
name: 'guest',
|
||||
},
|
||||
spec: {
|
||||
profile: {
|
||||
displayName: 'Guest User',
|
||||
email: 'guest@example.com',
|
||||
},
|
||||
memberOf: ['team-a'],
|
||||
},
|
||||
relations: [dummyGroup],
|
||||
};
|
||||
|
||||
export const NoImage = () => (
|
||||
<MemoryRouter>
|
||||
<EntityContext.Provider value={{ entity: noImageEntity, loading: false }}>
|
||||
<Grid container spacing={4}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<UserProfileCard variant="gridItem" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</EntityContext.Provider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
RELATION_MEMBER_OF,
|
||||
UserEntity,
|
||||
} from '@backstage/catalog-model';
|
||||
import { Avatar, InfoCard } from '@backstage/core';
|
||||
import { Avatar, InfoCard, InfoCardVariants } from '@backstage/core';
|
||||
import { entityRouteParams, useEntity } from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
Box,
|
||||
@@ -73,7 +73,7 @@ export const UserProfileCard = ({
|
||||
}: {
|
||||
/** @deprecated The entity is now grabbed from context instead */
|
||||
entity?: UserEntity;
|
||||
variant: string;
|
||||
variant?: InfoCardVariants;
|
||||
}) => {
|
||||
const user = useEntity().entity as UserEntity;
|
||||
const {
|
||||
@@ -95,11 +95,11 @@ export const UserProfileCard = ({
|
||||
return (
|
||||
<InfoCard title={<CardTitle title={displayName} />} variant={variant}>
|
||||
<Grid container spacing={3} alignItems="flex-start">
|
||||
<Grid item md={2} lg={1}>
|
||||
<Grid item xs={12} sm={2} xl={1}>
|
||||
<Avatar displayName={displayName} picture={profile?.picture} />
|
||||
</Grid>
|
||||
|
||||
<Grid item md={10} lg={11}>
|
||||
<Grid item md={10} xl={11}>
|
||||
<List>
|
||||
{profile?.email && (
|
||||
<ListItem>
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"@material-ui/core": "^4.11.0",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
"@types/react": "^16.9",
|
||||
"classnames": "^2.2.6",
|
||||
"luxon": "1.25.0",
|
||||
"react": "^16.13.1",
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemSecondaryAction,
|
||||
Tooltip,
|
||||
ListItemText,
|
||||
@@ -25,13 +24,16 @@ import {
|
||||
IconButton,
|
||||
Link,
|
||||
Typography,
|
||||
Chip,
|
||||
} from '@material-ui/core';
|
||||
import { StatusError, StatusWarning } from '@backstage/core';
|
||||
import Done from '@material-ui/icons/Done';
|
||||
import Warning from '@material-ui/icons/Warning';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import { Incident } from '../types';
|
||||
import OpenInBrowserIcon from '@material-ui/icons/OpenInBrowser';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
const useStyles = makeStyles<BackstageTheme>(theme => ({
|
||||
denseListIcon: {
|
||||
marginRight: 0,
|
||||
display: 'flex',
|
||||
@@ -42,10 +44,21 @@ const useStyles = makeStyles({
|
||||
listItemPrimary: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
listItemIcon: {
|
||||
minWidth: '1em',
|
||||
warning: {
|
||||
borderColor: theme.palette.status.warning,
|
||||
color: theme.palette.status.warning,
|
||||
'& *': {
|
||||
color: theme.palette.status.warning,
|
||||
},
|
||||
},
|
||||
});
|
||||
error: {
|
||||
borderColor: theme.palette.status.error,
|
||||
color: theme.palette.status.error,
|
||||
'& *': {
|
||||
color: theme.palette.status.error,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
type Props = {
|
||||
incident: Incident;
|
||||
@@ -62,19 +75,24 @@ export const IncidentListItem = ({ incident }: Props) => {
|
||||
|
||||
return (
|
||||
<ListItem dense key={incident.id}>
|
||||
<ListItemIcon className={classes.listItemIcon}>
|
||||
<Tooltip title={incident.status} placement="top">
|
||||
<div className={classes.denseListIcon}>
|
||||
{incident.status === 'triggered' ? (
|
||||
<StatusError />
|
||||
) : (
|
||||
<StatusWarning />
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={incident.title}
|
||||
primary={
|
||||
<>
|
||||
<Chip
|
||||
data-testid={`chip-${incident.status}`}
|
||||
label={incident.status}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
icon={incident.status === 'acknowledged' ? <Done /> : <Warning />}
|
||||
className={
|
||||
incident.status === 'triggered'
|
||||
? classes.error
|
||||
: classes.warning
|
||||
}
|
||||
/>
|
||||
{incident.title}
|
||||
</>
|
||||
}
|
||||
primaryTypographyProps={{
|
||||
variant: 'body1',
|
||||
className: classes.listItemPrimary,
|
||||
|
||||
@@ -84,13 +84,7 @@ describe('Incidents', () => {
|
||||
},
|
||||
] as Incident[],
|
||||
);
|
||||
const {
|
||||
getByText,
|
||||
getByTitle,
|
||||
getAllByTitle,
|
||||
getByLabelText,
|
||||
queryByTestId,
|
||||
} = render(
|
||||
const { getByText, getAllByTitle, queryByTestId } = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<Incidents serviceId="abc" refreshIncidents={false} />
|
||||
@@ -102,10 +96,10 @@ describe('Incidents', () => {
|
||||
expect(getByText('title2')).toBeInTheDocument();
|
||||
expect(getByText('person1')).toBeInTheDocument();
|
||||
expect(getByText('person2')).toBeInTheDocument();
|
||||
expect(getByTitle('triggered')).toBeInTheDocument();
|
||||
expect(getByTitle('acknowledged')).toBeInTheDocument();
|
||||
expect(getByLabelText('Status error')).toBeInTheDocument();
|
||||
expect(getByLabelText('Status warning')).toBeInTheDocument();
|
||||
expect(getByText('triggered')).toBeInTheDocument();
|
||||
expect(getByText('acknowledged')).toBeInTheDocument();
|
||||
expect(queryByTestId('chip-triggered')).toBeInTheDocument();
|
||||
expect(queryByTestId('chip-acknowledged')).toBeInTheDocument();
|
||||
|
||||
// assert links, mailto and hrefs, date calculation
|
||||
expect(getAllByTitle('View in PagerDuty').length).toEqual(2);
|
||||
|
||||
@@ -17,68 +17,38 @@ import React, { useState, useCallback } from 'react';
|
||||
import { useApi, Progress, HeaderIconLinkRow } from '@backstage/core';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
Button,
|
||||
makeStyles,
|
||||
Card,
|
||||
CardHeader,
|
||||
Divider,
|
||||
CardContent,
|
||||
} from '@material-ui/core';
|
||||
import { Card, CardHeader, Divider, CardContent } from '@material-ui/core';
|
||||
import { Incidents } from './Incident';
|
||||
import { EscalationPolicy } from './Escalation';
|
||||
import { useAsync } from 'react-use';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import { pagerDutyApiRef, UnauthorizedError } from '../api';
|
||||
import AlarmAddIcon from '@material-ui/icons/AlarmAdd';
|
||||
import { TriggerDialog } from './TriggerDialog';
|
||||
import { MissingTokenError } from './Errors/MissingTokenError';
|
||||
import WebIcon from '@material-ui/icons/Web';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
triggerAlarm: {
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
fontSize: '0.7rem',
|
||||
textTransform: 'uppercase',
|
||||
fontWeight: 600,
|
||||
letterSpacing: 1.2,
|
||||
lineHeight: 1.5,
|
||||
'&:hover, &:focus, &.focus': {
|
||||
backgroundColor: 'transparent',
|
||||
textDecoration: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const PAGERDUTY_INTEGRATION_KEY = 'pagerduty.com/integration-key';
|
||||
import { PAGERDUTY_INTEGRATION_KEY } from './constants';
|
||||
import { TriggerButton, useShowDialog } from './TriggerButton';
|
||||
|
||||
export const isPluginApplicableToEntity = (entity: Entity) =>
|
||||
Boolean(entity.metadata.annotations?.[PAGERDUTY_INTEGRATION_KEY]);
|
||||
|
||||
type Props = {
|
||||
/** @deprecated The entity is now grabbed from context instead */
|
||||
entity?: Entity;
|
||||
};
|
||||
|
||||
export const PagerDutyCard = (_props: Props) => {
|
||||
const classes = useStyles();
|
||||
export const PagerDutyCard = () => {
|
||||
const { entity } = useEntity();
|
||||
const api = useApi(pagerDutyApiRef);
|
||||
const [showDialog, setShowDialog] = useState<boolean>(false);
|
||||
const [refreshIncidents, setRefreshIncidents] = useState<boolean>(false);
|
||||
const integrationKey = entity.metadata.annotations![
|
||||
PAGERDUTY_INTEGRATION_KEY
|
||||
];
|
||||
const setShowDialog = useShowDialog()[1];
|
||||
|
||||
const showDialog = useCallback(() => {
|
||||
setShowDialog(true);
|
||||
}, [setShowDialog]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
setRefreshIncidents(x => !x);
|
||||
}, []);
|
||||
|
||||
const handleDialog = useCallback(() => {
|
||||
setShowDialog(x => !x);
|
||||
}, []);
|
||||
|
||||
const { value: service, loading, error } = useAsync(async () => {
|
||||
const services = await api.getServiceByIntegrationKey(integrationKey);
|
||||
|
||||
@@ -114,17 +84,8 @@ export const PagerDutyCard = (_props: Props) => {
|
||||
|
||||
const triggerLink = {
|
||||
label: 'Create Incident',
|
||||
action: (
|
||||
<Button
|
||||
data-testid="trigger-button"
|
||||
color="secondary"
|
||||
onClick={handleDialog}
|
||||
className={classes.triggerAlarm}
|
||||
>
|
||||
Create Incident
|
||||
</Button>
|
||||
),
|
||||
icon: <AlarmAddIcon onClick={handleDialog} />,
|
||||
action: <TriggerButton design="link" onIncidentCreated={handleRefresh} />,
|
||||
icon: <AlarmAddIcon onClick={showDialog} />,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -140,13 +101,6 @@ export const PagerDutyCard = (_props: Props) => {
|
||||
refreshIncidents={refreshIncidents}
|
||||
/>
|
||||
<EscalationPolicy policyId={service!.policyId} />
|
||||
<TriggerDialog
|
||||
showDialog={showDialog}
|
||||
handleDialog={handleDialog}
|
||||
name={entity.metadata.name}
|
||||
integrationKey={integrationKey}
|
||||
onIncidentCreated={handleRefresh}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright 2021 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, { useCallback, PropsWithChildren } from 'react';
|
||||
import { createGlobalState } from 'react-use';
|
||||
import { makeStyles, Button } from '@material-ui/core';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
|
||||
import { TriggerDialog } from '../TriggerDialog';
|
||||
import { PAGERDUTY_INTEGRATION_KEY } from '../constants';
|
||||
|
||||
export interface TriggerButtonProps {
|
||||
design: 'link' | 'button';
|
||||
onIncidentCreated?: () => void;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles<BackstageTheme>(theme => ({
|
||||
buttonStyle: {
|
||||
backgroundColor: theme.palette.error.main,
|
||||
color: theme.palette.error.contrastText,
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.error.dark,
|
||||
},
|
||||
},
|
||||
triggerAlarm: {
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
fontSize: '0.7rem',
|
||||
textTransform: 'uppercase',
|
||||
fontWeight: 600,
|
||||
letterSpacing: 1.2,
|
||||
lineHeight: 1.5,
|
||||
'&:hover, &:focus, &.focus': {
|
||||
backgroundColor: 'transparent',
|
||||
textDecoration: 'none',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export const useShowDialog = createGlobalState(false);
|
||||
|
||||
export function TriggerButton({
|
||||
design,
|
||||
onIncidentCreated,
|
||||
children,
|
||||
}: PropsWithChildren<TriggerButtonProps>) {
|
||||
const { buttonStyle, triggerAlarm } = useStyles();
|
||||
const { entity } = useEntity();
|
||||
const [dialogShown = false, setDialogShown] = useShowDialog();
|
||||
|
||||
const showDialog = useCallback(() => {
|
||||
setDialogShown(true);
|
||||
}, [setDialogShown]);
|
||||
const hideDialog = useCallback(() => {
|
||||
setDialogShown(false);
|
||||
}, [setDialogShown]);
|
||||
|
||||
const integrationKey = entity.metadata.annotations![
|
||||
PAGERDUTY_INTEGRATION_KEY
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
data-testid="trigger-button"
|
||||
{...(design === 'link' && { color: 'secondary' })}
|
||||
onClick={showDialog}
|
||||
className={design === 'link' ? triggerAlarm : buttonStyle}
|
||||
>
|
||||
{children ?? 'Create Incident'}
|
||||
</Button>
|
||||
<TriggerDialog
|
||||
showDialog={dialogShown}
|
||||
handleDialog={hideDialog}
|
||||
name={entity.metadata.name}
|
||||
integrationKey={integrationKey}
|
||||
onIncidentCreated={onIncidentCreated}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -35,7 +35,7 @@ type Props = {
|
||||
integrationKey: string;
|
||||
showDialog: boolean;
|
||||
handleDialog: () => void;
|
||||
onIncidentCreated: () => void;
|
||||
onIncidentCreated?: () => void;
|
||||
};
|
||||
|
||||
export const TriggerDialog = ({
|
||||
@@ -69,11 +69,17 @@ export const TriggerDialog = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
alertApi.post({
|
||||
message: `Alarm successfully triggered by ${userName}`,
|
||||
});
|
||||
onIncidentCreated();
|
||||
handleDialog();
|
||||
(async () => {
|
||||
alertApi.post({
|
||||
message: `Alarm successfully triggered by ${userName}`,
|
||||
});
|
||||
|
||||
handleDialog();
|
||||
|
||||
// The pager duty API isn't always returning the newly created alarm immediately
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
onIncidentCreated?.();
|
||||
})();
|
||||
}
|
||||
}, [value, alertApi, handleDialog, userName, onIncidentCreated]);
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2021 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 const PAGERDUTY_INTEGRATION_KEY = 'pagerduty.com/integration-key';
|
||||
@@ -23,6 +23,7 @@ export {
|
||||
isPluginApplicableToEntity as isPagerDutyAvailable,
|
||||
PagerDutyCard,
|
||||
} from './components/PagerDutyCard';
|
||||
export { TriggerButton } from './components/TriggerButton';
|
||||
export {
|
||||
PagerDutyClient,
|
||||
pagerDutyApiRef,
|
||||
|
||||
@@ -77,7 +77,7 @@ exports.up = async function up(knex) {
|
||||
exports.down = async function down(knex) {
|
||||
if (knex.client.config.client !== 'sqlite3') {
|
||||
await knex.schema.alterTable('task_events', table => {
|
||||
table.dropIndex([], 'ctask_events_task_id_idx');
|
||||
table.dropIndex([], 'task_events_task_id_idx');
|
||||
});
|
||||
}
|
||||
await knex.schema.dropTable('task_events');
|
||||
|
||||
@@ -18,14 +18,20 @@ import path from 'path';
|
||||
import { Git } from '@backstage/backend-common';
|
||||
import { PreparerBase, PreparerOptions } from './types';
|
||||
import parseGitUrl from 'git-url-parse';
|
||||
import { GitHubIntegrationConfig } from '@backstage/integration';
|
||||
import {
|
||||
GitHubIntegrationConfig,
|
||||
GithubCredentialsProvider,
|
||||
} from '@backstage/integration';
|
||||
|
||||
export class GithubPreparer implements PreparerBase {
|
||||
static fromConfig(config: GitHubIntegrationConfig) {
|
||||
return new GithubPreparer({ token: config.token });
|
||||
const credentialsProvider = GithubCredentialsProvider.create(config);
|
||||
return new GithubPreparer({ credentialsProvider });
|
||||
}
|
||||
|
||||
constructor(private readonly config: { token?: string }) {}
|
||||
constructor(
|
||||
private readonly config: { credentialsProvider: GithubCredentialsProvider },
|
||||
) {}
|
||||
|
||||
async prepare({ url, workspacePath, logger }: PreparerOptions) {
|
||||
const parsedGitUrl = parseGitUrl(url);
|
||||
@@ -36,10 +42,14 @@ export class GithubPreparer implements PreparerBase {
|
||||
parsedGitUrl.filepath ?? '',
|
||||
);
|
||||
|
||||
const git = this.config.token
|
||||
const { token } = await this.config.credentialsProvider.getCredentials({
|
||||
url,
|
||||
});
|
||||
|
||||
const git = token
|
||||
? Git.fromAuth({
|
||||
username: 'x-access-token',
|
||||
password: this.config.token,
|
||||
password: token,
|
||||
logger,
|
||||
})
|
||||
: Git.fromAuth({ logger });
|
||||
|
||||
@@ -16,7 +16,10 @@
|
||||
|
||||
import { PublisherBase, PublisherOptions, PublisherResult } from './types';
|
||||
import { initRepoAndPush } from './helpers';
|
||||
import { GitHubIntegrationConfig } from '@backstage/integration';
|
||||
import {
|
||||
GitHubIntegrationConfig,
|
||||
GithubCredentialsProvider,
|
||||
} from '@backstage/integration';
|
||||
import parseGitUrl from 'git-url-parse';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import path from 'path';
|
||||
@@ -28,27 +31,24 @@ export class GithubPublisher implements PublisherBase {
|
||||
config: GitHubIntegrationConfig,
|
||||
{ repoVisibility }: { repoVisibility: RepoVisibilityOptions },
|
||||
) {
|
||||
if (!config.token) {
|
||||
if (!config.token && !config.apps) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const githubClient = new Octokit({
|
||||
auth: config.token,
|
||||
baseUrl: config.apiBaseUrl,
|
||||
});
|
||||
const credentialsProvider = GithubCredentialsProvider.create(config);
|
||||
|
||||
return new GithubPublisher({
|
||||
token: config.token,
|
||||
client: githubClient,
|
||||
credentialsProvider,
|
||||
repoVisibility,
|
||||
apiBaseUrl: config.apiBaseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly config: {
|
||||
token: string;
|
||||
client: Octokit;
|
||||
credentialsProvider: GithubCredentialsProvider;
|
||||
repoVisibility: RepoVisibilityOptions;
|
||||
apiBaseUrl: string | undefined;
|
||||
},
|
||||
) {}
|
||||
|
||||
@@ -59,9 +59,25 @@ export class GithubPublisher implements PublisherBase {
|
||||
}: PublisherOptions): Promise<PublisherResult> {
|
||||
const { owner, name } = parseGitUrl(values.storePath);
|
||||
|
||||
const { token } = await this.config.credentialsProvider.getCredentials({
|
||||
url: values.storePath,
|
||||
});
|
||||
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
`No token could be acquired for URL: ${values.storePath}`,
|
||||
);
|
||||
}
|
||||
|
||||
const client = new Octokit({
|
||||
auth: token,
|
||||
baseUrl: this.config.apiBaseUrl,
|
||||
});
|
||||
|
||||
const description = values.description as string;
|
||||
const access = values.access as string;
|
||||
const remoteUrl = await this.createRemote({
|
||||
client,
|
||||
description,
|
||||
access,
|
||||
name,
|
||||
@@ -73,7 +89,7 @@ export class GithubPublisher implements PublisherBase {
|
||||
remoteUrl,
|
||||
auth: {
|
||||
username: 'x-access-token',
|
||||
password: this.config.token,
|
||||
password: token,
|
||||
},
|
||||
logger,
|
||||
});
|
||||
@@ -86,27 +102,28 @@ export class GithubPublisher implements PublisherBase {
|
||||
}
|
||||
|
||||
private async createRemote(opts: {
|
||||
client: Octokit;
|
||||
access: string;
|
||||
name: string;
|
||||
owner: string;
|
||||
description: string;
|
||||
}) {
|
||||
const { access, description, owner, name } = opts;
|
||||
const { client, access, description, owner, name } = opts;
|
||||
|
||||
const user = await this.config.client.users.getByUsername({
|
||||
const user = await client.users.getByUsername({
|
||||
username: owner,
|
||||
});
|
||||
|
||||
const repoCreationPromise =
|
||||
user.data.type === 'Organization'
|
||||
? this.config.client.repos.createInOrg({
|
||||
? client.repos.createInOrg({
|
||||
name,
|
||||
org: owner,
|
||||
private: this.config.repoVisibility !== 'public',
|
||||
visibility: this.config.repoVisibility,
|
||||
description,
|
||||
})
|
||||
: this.config.client.repos.createForAuthenticatedUser({
|
||||
: client.repos.createForAuthenticatedUser({
|
||||
name,
|
||||
private: this.config.repoVisibility === 'private',
|
||||
description,
|
||||
@@ -116,7 +133,7 @@ export class GithubPublisher implements PublisherBase {
|
||||
|
||||
if (access?.startsWith(`${owner}/`)) {
|
||||
const [, team] = access.split('/');
|
||||
await this.config.client.teams.addOrUpdateRepoPermissionsInOrg({
|
||||
await client.teams.addOrUpdateRepoPermissionsInOrg({
|
||||
org: owner,
|
||||
team_slug: team,
|
||||
owner,
|
||||
@@ -125,7 +142,7 @@ export class GithubPublisher implements PublisherBase {
|
||||
});
|
||||
// no need to add access if it's the person who own's the personal account
|
||||
} else if (access && access !== owner) {
|
||||
await this.config.client.repos.addCollaborator({
|
||||
await client.repos.addCollaborator({
|
||||
owner,
|
||||
repo: name,
|
||||
username: access,
|
||||
|
||||
@@ -14,24 +14,25 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import {
|
||||
EmptyState,
|
||||
ErrorApi,
|
||||
errorApiRef,
|
||||
InfoCard,
|
||||
InfoCardVariants,
|
||||
MissingAnnotationEmptyState,
|
||||
Progress,
|
||||
useApi,
|
||||
} from '@backstage/core';
|
||||
import SentryIssuesTable from '../SentryIssuesTable/SentryIssuesTable';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
import { sentryApiRef } from '../../api';
|
||||
import SentryIssuesTable from '../SentryIssuesTable/SentryIssuesTable';
|
||||
import {
|
||||
SENTRY_PROJECT_SLUG_ANNOTATION,
|
||||
useProjectSlug,
|
||||
} from '../useProjectSlug';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
export const SentryIssuesWidget = ({
|
||||
entity,
|
||||
@@ -40,7 +41,7 @@ export const SentryIssuesWidget = ({
|
||||
}: {
|
||||
entity: Entity;
|
||||
statsFor?: '24h' | '12h';
|
||||
variant?: string;
|
||||
variant?: InfoCardVariants;
|
||||
}) => {
|
||||
const errorApi = useApi<ErrorApi>(errorApiRef);
|
||||
const sentryApi = useApi(sentryApiRef);
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import { useTheme } from '@material-ui/styles';
|
||||
import { useTheme } from '@material-ui/core';
|
||||
import { Circle } from 'rc-progress';
|
||||
import React from 'react';
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Entity } from '@backstage/catalog-model';
|
||||
import {
|
||||
EmptyState,
|
||||
InfoCard,
|
||||
InfoCardVariants,
|
||||
MissingAnnotationEmptyState,
|
||||
Progress,
|
||||
useApi,
|
||||
@@ -88,7 +89,7 @@ export const SonarQubeCard = ({
|
||||
duplicationRatings = defaultDuplicationRatings,
|
||||
}: {
|
||||
entity?: Entity;
|
||||
variant?: string;
|
||||
variant?: InfoCardVariants;
|
||||
duplicationRatings?: DuplicationRating[];
|
||||
}) => {
|
||||
const { entity } = useEntity();
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint')],
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
# Splunk On-Call
|
||||
|
||||
## Overview
|
||||
|
||||
This plugin displays Splunk On-Call, formerly VictorOps, information about an entity.
|
||||
|
||||
There is a way to trigger an new incident directly to specific users or/and specific teams.
|
||||
|
||||
This plugin requires that entities are annotated with a team name. See more further down in this document.
|
||||
|
||||
This plugin provides:
|
||||
|
||||
- A list of incidents
|
||||
- A way to trigger a new incident to specific users or/and teams
|
||||
- A way to acknowledge/resolve an incident
|
||||
- Information details about the persons on-call
|
||||
|
||||
## Setup instructions
|
||||
|
||||
Install the plugin:
|
||||
|
||||
```bash
|
||||
yarn add @backstage/plugin-splunk-on-call
|
||||
```
|
||||
|
||||
Add it to the app in `plugins.ts`:
|
||||
|
||||
```ts
|
||||
export { plugin as SplunkOnCall } from '@backstage/plugin-splunk-on-call';
|
||||
```
|
||||
|
||||
Add it to the `EntityPage.tsx`:
|
||||
|
||||
```ts
|
||||
import {
|
||||
isPluginApplicableToEntity as isSplunkOnCallAvailable,
|
||||
SplunkOnCallCard,
|
||||
} from '@backstage/plugin-splunk-on-call';
|
||||
// ...
|
||||
{
|
||||
isSplunkOnCallAvailable(entity) && (
|
||||
<Grid item md={6}>
|
||||
<SplunkOnCallCard entity={entity} />
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Client configuration
|
||||
|
||||
In order to be able to perform certain action (create-acknowledge-resolve an action), you need to provide the username of the user making the action.
|
||||
The user supplied must be a valid Splunk On-Call user and a member of your organization.
|
||||
|
||||
In `app-config.yaml`:
|
||||
|
||||
```yaml
|
||||
splunkOnCall:
|
||||
username: <SPLUNK_ON_CALL_USERNAME>
|
||||
```
|
||||
|
||||
The user supplied must be a valid Splunk On-Call user and a member of your organization.
|
||||
|
||||
In order to make the API calls, you need to provide a new proxy config which will redirect to the Splunk On-Call API endpoint and add authentication information in the headers:
|
||||
|
||||
```yaml
|
||||
# app-config.yaml
|
||||
proxy:
|
||||
# ...
|
||||
'/splunk-on-call':
|
||||
target: https://api.victorops.com/api-public
|
||||
headers:
|
||||
X-VO-Api-Id:
|
||||
$env: SPLUNK_ON_CALL_API_ID
|
||||
X-VO-Api-Key:
|
||||
$env: SPLUNK_ON_CALL_API_KEY
|
||||
```
|
||||
|
||||
In addition, to make certain API calls (trigger-resolve-acknowledge an incident) you need to add the `PATCH` method to the backend `cors` methods list: `[GET, POST, PUT, DELETE, PATCH]`.
|
||||
|
||||
### Adding your team name to the entity annotation
|
||||
|
||||
The information displayed for each entity is based on the team name.
|
||||
If you want to use this plugin for an entity, you need to label it with the below annotation:
|
||||
|
||||
```yaml
|
||||
annotations:
|
||||
splunk.com/on-call-team': <SPLUNK_ON_CALL_TEAM_NAME>
|
||||
```
|
||||
|
||||
## Providing the API key and API id
|
||||
|
||||
In order for the client to make requests to the [Splunk On-Call API](https://portal.victorops.com/public/api-docs.html#/) it needs an [API ID and an API Key](https://help.victorops.com/knowledge-base/api/).
|
||||
|
||||
Then start the backend passing the values as an environment variable:
|
||||
|
||||
```bash
|
||||
$ SPLUNK_ON_CALL_API_KEY='' SPLUNK_ON_CALL_API_ID='' yarn start
|
||||
```
|
||||
|
||||
This will proxy the request by adding `X-VO-Api-Id` and `X-VO-Api-Key` headers with the provided values.
|
||||
|
||||
You can also add the values in your helm template:
|
||||
|
||||
```yaml
|
||||
# backend-secret.yaml
|
||||
stringData:
|
||||
# ...
|
||||
SPLUNK_ON_CALL_API_ID: { { .Values.auth.splunkOnCallApiId } }
|
||||
SPLUNK_ON_CALL_API_KEY: { { .Values.auth.splunkOnCallApiKey } }
|
||||
```
|
||||
|
||||
To enable it you need to provide them in the chart's values:
|
||||
|
||||
```yaml
|
||||
# values.yaml
|
||||
auth:
|
||||
# ...
|
||||
splunkOnCallApiId: h
|
||||
splunkOnCallApiKey: h
|
||||
```
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2021 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 { createDevApp } from '@backstage/dev-utils';
|
||||
import { splunkOnCallPlugin, SplunkOnCallPage } from '../src/plugin';
|
||||
|
||||
createDevApp()
|
||||
.registerPlugin(splunkOnCallPlugin)
|
||||
.addPage({
|
||||
title: 'Splunk On-Call',
|
||||
element: <SplunkOnCallPage />,
|
||||
})
|
||||
.render();
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "@backstage/plugin-splunk-on-call",
|
||||
"version": "0.1.1",
|
||||
"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"
|
||||
},
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/backstage/backstage",
|
||||
"directory": "plugins/splunk-on-call"
|
||||
},
|
||||
"keywords": [
|
||||
"backstage",
|
||||
"splunk-on-call"
|
||||
],
|
||||
"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.7.1",
|
||||
"@backstage/core": "^0.6.1",
|
||||
"@backstage/plugin-catalog-react": "^0.0.2",
|
||||
"@backstage/theme": "^0.2.3",
|
||||
"@material-ui/core": "^4.11.0",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
"classnames": "^2.2.6",
|
||||
"luxon": "^1.25.0",
|
||||
"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.6.0",
|
||||
"@backstage/dev-utils": "^0.1.10",
|
||||
"@backstage/test-utils": "^0.1.7",
|
||||
"@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/luxon": "^1.25.0",
|
||||
"@types/node": "^12.0.0",
|
||||
"cross-fetch": "^3.0.6",
|
||||
"msw": "^0.21.2",
|
||||
"node-fetch": "^2.6.1"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createApiRef, DiscoveryApi, ConfigApi } from '@backstage/core';
|
||||
import {
|
||||
Incident,
|
||||
OnCall,
|
||||
User,
|
||||
EscalationPolicyInfo,
|
||||
Team,
|
||||
} from '../components/types';
|
||||
import {
|
||||
SplunkOnCallApi,
|
||||
TriggerAlarmRequest,
|
||||
IncidentsResponse,
|
||||
OnCallsResponse,
|
||||
ClientApiConfig,
|
||||
RequestOptions,
|
||||
ListUserResponse,
|
||||
EscalationPolicyResponse,
|
||||
PatchIncidentRequest,
|
||||
} from './types';
|
||||
|
||||
export class UnauthorizedError extends Error {}
|
||||
|
||||
export const splunkOnCallApiRef = createApiRef<SplunkOnCallApi>({
|
||||
id: 'plugin.splunk-on-call.api',
|
||||
description: 'Used to fetch data from Splunk On-Call API',
|
||||
});
|
||||
|
||||
export class SplunkOnCallClient implements SplunkOnCallApi {
|
||||
static fromConfig(configApi: ConfigApi, discoveryApi: DiscoveryApi) {
|
||||
const usernameFromConfig: string | null =
|
||||
configApi.getOptionalString('splunkOnCall.username') || null;
|
||||
return new SplunkOnCallClient({
|
||||
username: usernameFromConfig,
|
||||
discoveryApi,
|
||||
});
|
||||
}
|
||||
constructor(private readonly config: ClientApiConfig) {}
|
||||
|
||||
async getIncidents(): Promise<Incident[]> {
|
||||
const url = `${await this.config.discoveryApi.getBaseUrl(
|
||||
'proxy',
|
||||
)}/splunk-on-call/v1/incidents`;
|
||||
|
||||
const { incidents } = await this.getByUrl<IncidentsResponse>(url);
|
||||
|
||||
return incidents;
|
||||
}
|
||||
|
||||
async getOnCallUsers(): Promise<OnCall[]> {
|
||||
const url = `${await this.config.discoveryApi.getBaseUrl(
|
||||
'proxy',
|
||||
)}/splunk-on-call/v1/oncall/current`;
|
||||
const { teamsOnCall } = await this.getByUrl<OnCallsResponse>(url);
|
||||
|
||||
return teamsOnCall;
|
||||
}
|
||||
|
||||
async getTeams(): Promise<Team[]> {
|
||||
const url = `${await this.config.discoveryApi.getBaseUrl(
|
||||
'proxy',
|
||||
)}/splunk-on-call/v1/team`;
|
||||
const teams = await this.getByUrl<Team[]>(url);
|
||||
|
||||
return teams;
|
||||
}
|
||||
|
||||
async acknowledgeIncident({
|
||||
incidentNames,
|
||||
}: PatchIncidentRequest): Promise<Response> {
|
||||
const options = {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userName: this.config.username,
|
||||
incidentNames,
|
||||
}),
|
||||
};
|
||||
|
||||
const url = `${await this.config.discoveryApi.getBaseUrl(
|
||||
'proxy',
|
||||
)}/splunk-on-call/v1/incidents/ack`;
|
||||
|
||||
return this.request(url, options);
|
||||
}
|
||||
|
||||
async resolveIncident({
|
||||
incidentNames,
|
||||
}: PatchIncidentRequest): Promise<Response> {
|
||||
const options = {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userName: this.config.username,
|
||||
incidentNames,
|
||||
}),
|
||||
};
|
||||
|
||||
const url = `${await this.config.discoveryApi.getBaseUrl(
|
||||
'proxy',
|
||||
)}/splunk-on-call/v1/incidents/resolve`;
|
||||
|
||||
return this.request(url, options);
|
||||
}
|
||||
|
||||
async getUsers(): Promise<User[]> {
|
||||
const url = `${await this.config.discoveryApi.getBaseUrl(
|
||||
'proxy',
|
||||
)}/splunk-on-call/v2/user`;
|
||||
const { users } = await this.getByUrl<ListUserResponse>(url);
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
async getEscalationPolicies(): Promise<EscalationPolicyInfo[]> {
|
||||
const url = `${await this.config.discoveryApi.getBaseUrl(
|
||||
'proxy',
|
||||
)}/splunk-on-call/v1/policies`;
|
||||
const { policies } = await this.getByUrl<EscalationPolicyResponse>(url);
|
||||
|
||||
return policies;
|
||||
}
|
||||
|
||||
async triggerAlarm({
|
||||
summary,
|
||||
details,
|
||||
userName,
|
||||
targets,
|
||||
isMultiResponder,
|
||||
}: TriggerAlarmRequest): Promise<Response> {
|
||||
const body = JSON.stringify({
|
||||
summary,
|
||||
details,
|
||||
userName: this.config.username || userName,
|
||||
targets,
|
||||
isMultiResponder,
|
||||
});
|
||||
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body,
|
||||
};
|
||||
|
||||
const url = `${await this.config.discoveryApi.getBaseUrl(
|
||||
'proxy',
|
||||
)}/splunk-on-call/v1/incidents`;
|
||||
|
||||
return this.request(url, options);
|
||||
}
|
||||
|
||||
private async getByUrl<T>(url: string): Promise<T> {
|
||||
const options = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
const response = await this.request(url, options);
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
private async request(
|
||||
url: string,
|
||||
options: RequestOptions,
|
||||
): Promise<Response> {
|
||||
const response = await fetch(url, options);
|
||||
if (response.status === 403) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
if (!response.ok) {
|
||||
const payload = await response.json();
|
||||
const errors = payload.errors.map((error: string) => error).join(' ');
|
||||
const message = `Request failed with ${response.status}, ${errors}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* 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 {
|
||||
SplunkOnCallClient,
|
||||
splunkOnCallApiRef,
|
||||
UnauthorizedError,
|
||||
} from './client';
|
||||
export type { SplunkOnCallApi } from './types';
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright 2021 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 {
|
||||
EscalationPolicyInfo,
|
||||
Incident,
|
||||
Team,
|
||||
User,
|
||||
} from '../components/types';
|
||||
|
||||
export const MOCKED_USER: User = {
|
||||
createdAt: '2021-02-01T23:38:38Z',
|
||||
displayName: 'Test User',
|
||||
email: 'test@example.com',
|
||||
firstName: 'FirstNameTest',
|
||||
lastName: 'LastNameTest',
|
||||
passwordLastUpdated: '2021-02-01T23:38:38Z',
|
||||
username: 'test_user',
|
||||
verified: true,
|
||||
_selfUrl: '/api-public/v1/user/test_user',
|
||||
};
|
||||
|
||||
export const MOCKED_ON_CALL = [
|
||||
{
|
||||
team: { name: 'team_example', slug: 'team-zEalMCgwYSA0Lt40' },
|
||||
oncallNow: [
|
||||
{
|
||||
escalationPolicy: { name: 'Example', slug: 'team-zEalMCgwYSA0Lt40' },
|
||||
users: [{ onCalluser: { username: 'test_user' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const MOCK_INCIDENT: Incident = {
|
||||
alertCount: 1,
|
||||
currentPhase: 'ACKED',
|
||||
entityDisplayName: 'test-incident',
|
||||
entityId: 'entityId',
|
||||
entityState: 'CRITICAL',
|
||||
entityType: 'SERVICE',
|
||||
incidentNumber: '1',
|
||||
lastAlertId: 'lastAlertId',
|
||||
lastAlertTime: '2021-02-03T00:13:11Z',
|
||||
routingKey: 'routingdefault',
|
||||
service: 'test',
|
||||
startTime: '2021-02-03T00:13:11Z',
|
||||
pagedTeams: ['team-O9SqT13fsnCstjMi'],
|
||||
pagedUsers: [],
|
||||
pagedPolicies: [
|
||||
{
|
||||
policy: {
|
||||
name: 'Generated Direct User Policy for test_user',
|
||||
slug: 'directUserPolicySlug-test',
|
||||
_selfUrl: '/test',
|
||||
},
|
||||
},
|
||||
],
|
||||
transitions: [{ name: 'ACKED', at: '2021-02-03T01:20:00Z', by: 'test' }],
|
||||
monitorName: 'vouser-user',
|
||||
monitorType: 'Manual',
|
||||
firstAlertUuid: 'firstAlertUuid',
|
||||
incidentLink: 'https://portal.victorops.com/example',
|
||||
};
|
||||
|
||||
export const MOCK_TEAM: Team = {
|
||||
_selfUrl: '/api-public/v1/team/team-O9SqT13fsnCstjMi',
|
||||
_membersUrl: '/api-public/v1/team/team-O9SqT13fsnCstjMi/members',
|
||||
_policiesUrl: '/api-public/v1/team/team-O9SqT13fsnCstjMi/policies',
|
||||
_adminsUrl: '/api-public/v1/team/team-O9SqT13fsnCstjMi/admins',
|
||||
name: 'test',
|
||||
slug: 'team-O9SqT13fsnCstjMi',
|
||||
memberCount: 1,
|
||||
version: 1,
|
||||
isDefaultTeam: false,
|
||||
};
|
||||
|
||||
export const ESCALATION_POLICIES: EscalationPolicyInfo[] = [
|
||||
{
|
||||
policy: {
|
||||
name: 'Example',
|
||||
slug: 'team-zEalMCgwYSA0Lt40',
|
||||
_selfUrl: '/api-public/v1/policies/team-zEalMCgwYSA0Lt40',
|
||||
},
|
||||
team: { name: 'Example', slug: 'team-zEalMCgwYSA0Lt40' },
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* 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 {
|
||||
EscalationPolicyInfo,
|
||||
Incident,
|
||||
OnCall,
|
||||
Team,
|
||||
User,
|
||||
} from '../components/types';
|
||||
import { DiscoveryApi } from '@backstage/core';
|
||||
|
||||
export enum TargetType {
|
||||
UserValue = 'User',
|
||||
EscalationPolicyValue = 'EscalationPolicy',
|
||||
}
|
||||
|
||||
export type IncidentTarget = {
|
||||
type: TargetType;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export type TriggerAlarmRequest = {
|
||||
targets: IncidentTarget[];
|
||||
details: string;
|
||||
summary: string;
|
||||
userName: string;
|
||||
isMultiResponder?: boolean;
|
||||
};
|
||||
|
||||
export interface SplunkOnCallApi {
|
||||
/**
|
||||
* Fetches a list of incidents
|
||||
*/
|
||||
getIncidents(): Promise<Incident[]>;
|
||||
|
||||
/**
|
||||
* Fetches the list of users in an escalation policy.
|
||||
*/
|
||||
getOnCallUsers(): Promise<OnCall[]>;
|
||||
|
||||
/**
|
||||
* Triggers an incident to specific users and/or specific teams.
|
||||
*/
|
||||
triggerAlarm(request: TriggerAlarmRequest): Promise<Response>;
|
||||
|
||||
/**
|
||||
* Resolves an incident.
|
||||
*/
|
||||
resolveIncident(request: PatchIncidentRequest): Promise<Response>;
|
||||
|
||||
/**
|
||||
* Acknowledge an incident.
|
||||
*/
|
||||
acknowledgeIncident(request: PatchIncidentRequest): Promise<Response>;
|
||||
|
||||
/**
|
||||
* Get a list of users for your organization.
|
||||
*/
|
||||
getUsers(): Promise<User[]>;
|
||||
|
||||
/**
|
||||
* Get a list of teams for your organization.
|
||||
*/
|
||||
getTeams(): Promise<Team[]>;
|
||||
|
||||
/**
|
||||
* Get a list of escalation policies for your organization.
|
||||
*/
|
||||
getEscalationPolicies(): Promise<EscalationPolicyInfo[]>;
|
||||
}
|
||||
|
||||
export type PatchIncidentRequest = {
|
||||
incidentNames: string[];
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type EscalationPolicyResponse = {
|
||||
policies: EscalationPolicyInfo[];
|
||||
};
|
||||
|
||||
export type ListUserResponse = {
|
||||
users: User[];
|
||||
_selfUrl?: string;
|
||||
};
|
||||
|
||||
export type IncidentsResponse = {
|
||||
incidents: Incident[];
|
||||
};
|
||||
|
||||
export type OnCallsResponse = {
|
||||
teamsOnCall: OnCall[];
|
||||
};
|
||||
|
||||
export type ClientApiConfig = {
|
||||
username: string | null;
|
||||
discoveryApi: DiscoveryApi;
|
||||
};
|
||||
|
||||
export type RequestOptions = {
|
||||
method: string;
|
||||
headers: HeadersInit;
|
||||
body?: BodyInit;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="282" height="173" fill="none" viewBox="0 0 282 173"><path fill="#000" fill-opacity=".05" fill-rule="evenodd" d="M16.4571 45.1637C11.0514 46.1711 7.48574 51.3699 8.49306 56.7756C9.50039 62.1814 14.6992 65.747 20.105 64.7397L27.5528 63.3518C25.4791 65.5835 24.4525 68.7347 25.0535 71.9596C26.0608 77.3653 31.2596 80.931 36.6654 79.9236L89.691 70.0427C89.7016 70.1067 89.7129 70.1708 89.7249 70.2349C90.3258 73.4598 92.4185 76.0298 95.1569 77.3647L91.9031 77.971C86.4974 78.9784 82.9318 84.1772 83.9391 89.583C84.9464 94.9887 90.1452 98.5543 95.551 97.547L250.098 68.7482C255.504 67.7409 259.069 62.5421 258.062 57.1363C257.461 53.9114 255.368 51.3414 252.63 50.0065L257.835 49.0366C263.241 48.0292 266.807 42.8304 265.799 37.4247C264.792 32.0189 259.593 28.4533 254.187 29.4606L161.492 46.7338C161.481 46.6697 161.47 46.6056 161.458 46.5415C160.857 43.3166 158.764 40.7466 156.026 39.4117L165.025 37.7347C170.431 36.7274 173.997 31.5286 172.989 26.1228C171.982 20.7171 166.783 17.1514 161.378 18.1588L16.4571 45.1637ZM24.3031 122.54C23.2958 117.134 26.8614 111.936 32.2672 110.928L190.856 81.3762C196.262 80.3688 201.461 83.9345 202.468 89.3402C203.476 94.746 199.91 99.9448 194.504 100.952L189.963 101.798C190.493 102.057 190.999 102.362 191.474 102.708L246.43 92.4677C251.835 91.4604 257.034 95.026 258.041 100.432C258.642 103.657 257.616 106.808 255.542 109.04L256.649 108.833C262.055 107.826 267.253 111.392 268.261 116.797C269.268 122.203 265.702 127.402 260.297 128.409L95.5591 159.107C90.1534 160.114 84.9545 156.549 83.9472 151.143C82.9399 145.737 86.5055 140.538 91.9113 139.531L103.94 137.29C103.41 137.031 102.904 136.726 102.429 136.38L29.1002 150.044C23.6944 151.051 18.4956 147.486 17.4882 142.08C16.4809 136.674 20.0465 131.475 25.4523 130.468L29.7352 129.67C26.9967 128.335 24.904 125.765 24.3031 122.54Z" clip-rule="evenodd"/><g filter="url(#filter0_d)"><path fill="#EEE" d="M232.896 31.2403H51.2975C49.1452 31.2403 47.4005 32.983 47.4005 35.1327V46.8101C47.4005 48.9598 49.1452 50.7025 51.2975 50.7025H232.896C235.048 50.7025 236.793 48.9598 236.793 46.8101V35.1327C236.793 32.983 235.048 31.2403 232.896 31.2403Z"/><mask id="mask0" width="190" height="114" x="47" y="31" mask-type="alpha" maskUnits="userSpaceOnUse"><path fill="#404040" d="M232.896 31.2403H51.2975C49.1452 31.2403 47.4005 32.983 47.4005 35.1327V141.007C47.4005 143.157 49.1452 144.9 51.2975 144.9H232.896C235.048 144.9 236.793 143.157 236.793 141.007V35.1327C236.793 32.983 235.048 31.2403 232.896 31.2403Z"/></mask><g mask="url(#mask0)"><path fill="#EEE" d="M239.91 42.1391H47.4005V150.349H239.91V42.1391Z"/></g></g><circle cx="188" cy="55" r="6" fill="#69DDC7"/><circle cx="91" cy="92" r="6" fill="#69DDC7"/><path fill="#69DDC7" d="M121 114L95.5 88L86.5 96L121 130L192.5 59L183.5 51L121 114Z"/><defs><filter id="filter0_d" width="229.392" height="153.66" x="29.401" y="15.24" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dx="2" dy="4"/><feGaussianBlur stdDeviation="10"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" mode="normal" result="effect1_dropShadow"/><feBlend in="SourceGraphic" in2="effect1_dropShadow" mode="normal" result="shape"/></filter></defs></svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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 { EmptyState } from '@backstage/core';
|
||||
import { Button } from '@material-ui/core';
|
||||
|
||||
export const MissingApiKeyOrApiIdError = () => (
|
||||
<EmptyState
|
||||
missing="info"
|
||||
title="Missing or invalid Splunk On-Call API key and/or API id"
|
||||
description="The request to fetch data needs a valid api id and a valid api key. See README for more details."
|
||||
action={
|
||||
<Button
|
||||
color="primary"
|
||||
variant="contained"
|
||||
href="https://github.com/backstage/backstage/blob/master/plugins/splunk-on-call/README.md"
|
||||
>
|
||||
Read More
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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 { MissingApiKeyOrApiIdError } from './MissingApiKeyOrApiIdError';
|
||||
@@ -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 React from 'react';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { EscalationPolicy } from './EscalationPolicy';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core';
|
||||
import { splunkOnCallApiRef } from '../../api';
|
||||
import { MOCKED_ON_CALL, MOCKED_USER } from '../../api/mocks';
|
||||
|
||||
const mockSplunkOnCallApi = {
|
||||
getOnCallUsers: () => [],
|
||||
};
|
||||
const apis = ApiRegistry.from([[splunkOnCallApiRef, mockSplunkOnCallApi]]);
|
||||
|
||||
describe('Escalation', () => {
|
||||
it('Handles an empty response', async () => {
|
||||
mockSplunkOnCallApi.getOnCallUsers = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => []);
|
||||
|
||||
const { getByText, queryByTestId } = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<EscalationPolicy
|
||||
users={{
|
||||
[MOCKED_USER.username!]: MOCKED_USER,
|
||||
}}
|
||||
team="team_example"
|
||||
/>
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
await waitFor(() => !queryByTestId('progress'));
|
||||
|
||||
expect(getByText('Empty escalation policy')).toBeInTheDocument();
|
||||
expect(mockSplunkOnCallApi.getOnCallUsers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Render a list of users', async () => {
|
||||
mockSplunkOnCallApi.getOnCallUsers = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => MOCKED_ON_CALL);
|
||||
|
||||
const { getByText, queryByTestId } = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<EscalationPolicy
|
||||
users={{
|
||||
[MOCKED_USER.username!]: MOCKED_USER,
|
||||
}}
|
||||
team="team_example"
|
||||
/>
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
await waitFor(() => !queryByTestId('progress'));
|
||||
|
||||
expect(getByText('FirstNameTest LastNameTest')).toBeInTheDocument();
|
||||
expect(getByText('test@example.com')).toBeInTheDocument();
|
||||
expect(mockSplunkOnCallApi.getOnCallUsers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Handles errors', async () => {
|
||||
mockSplunkOnCallApi.getOnCallUsers = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('Error message'));
|
||||
|
||||
const { getByText, queryByTestId } = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<EscalationPolicy
|
||||
users={{
|
||||
[MOCKED_USER.username!]: MOCKED_USER,
|
||||
}}
|
||||
team="team_example"
|
||||
/>
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
await waitFor(() => !queryByTestId('progress'));
|
||||
|
||||
expect(
|
||||
getByText('Error encountered while fetching information. Error message'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* 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 { List, ListSubheader } from '@material-ui/core';
|
||||
import { EscalationUsersEmptyState } from './EscalationUsersEmptyState';
|
||||
import { EscalationUser } from './EscalationUser';
|
||||
import { useAsync } from 'react-use';
|
||||
import { splunkOnCallApiRef } from '../../api';
|
||||
import { useApi, Progress } from '@backstage/core';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import { User } from '../types';
|
||||
|
||||
type Props = {
|
||||
users: { [key: string]: User };
|
||||
team: string;
|
||||
};
|
||||
|
||||
export const EscalationPolicy = ({ users, team }: Props) => {
|
||||
const api = useApi(splunkOnCallApiRef);
|
||||
|
||||
const { value: userNames, loading, error } = useAsync(async () => {
|
||||
const oncalls = await api.getOnCallUsers();
|
||||
const teamUsernames = oncalls
|
||||
.filter(oncall => oncall.team?.name === team)
|
||||
.flatMap(oncall => {
|
||||
return oncall.oncallNow?.flatMap(oncallNow => {
|
||||
return oncallNow.users?.flatMap(user => {
|
||||
return user?.onCalluser?.username;
|
||||
});
|
||||
});
|
||||
});
|
||||
return teamUsernames;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="error">
|
||||
Error encountered while fetching information. {error.message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <Progress />;
|
||||
}
|
||||
|
||||
if (!userNames?.length) {
|
||||
return <EscalationUsersEmptyState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<List dense subheader={<ListSubheader>ON CALL</ListSubheader>}>
|
||||
{userNames &&
|
||||
userNames.map(
|
||||
(userName, index) =>
|
||||
userName &&
|
||||
userName in users && (
|
||||
<EscalationUser key={index} user={users[userName]} />
|
||||
),
|
||||
)}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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 {
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemSecondaryAction,
|
||||
Tooltip,
|
||||
ListItemText,
|
||||
makeStyles,
|
||||
IconButton,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import Avatar from '@material-ui/core/Avatar';
|
||||
import EmailIcon from '@material-ui/icons/Email';
|
||||
import { User } from '../types';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
listItemPrimary: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
});
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
export const EscalationUser = ({ user }: Props) => {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<Avatar alt="User" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography className={classes.listItemPrimary}>
|
||||
{user.firstName} {user.lastName}
|
||||
</Typography>
|
||||
}
|
||||
secondary={user.email}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Tooltip title="Send e-mail to user" placement="top">
|
||||
<IconButton href={`mailto:${user.email}`}>
|
||||
<EmailIcon color="primary" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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 {
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
makeStyles,
|
||||
} from '@material-ui/core';
|
||||
import { StatusWarning } from '@backstage/core';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
denseListIcon: {
|
||||
marginRight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export const EscalationUsersEmptyState = () => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<div className={classes.denseListIcon}>
|
||||
<StatusWarning />
|
||||
</div>
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Empty escalation policy" />
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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 { EscalationPolicy } from './EscalationPolicy';
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* 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 { Grid, Typography } from '@material-ui/core';
|
||||
import EmptyStateImage from '../../assets/emptystate.svg';
|
||||
|
||||
export const IncidentsEmptyState = () => {
|
||||
return (
|
||||
<Grid container justify="center" direction="column" alignItems="center">
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h5">Nice! No incidents found!</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<img
|
||||
src={EmptyStateImage}
|
||||
alt="EmptyState"
|
||||
data-testid="emptyStateImg"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
* 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, { useEffect } from 'react';
|
||||
import {
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemSecondaryAction,
|
||||
Tooltip,
|
||||
ListItemText,
|
||||
makeStyles,
|
||||
IconButton,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import DoneIcon from '@material-ui/icons/Done';
|
||||
import DoneAllIcon from '@material-ui/icons/DoneAll';
|
||||
import {
|
||||
StatusError,
|
||||
StatusWarning,
|
||||
StatusOK,
|
||||
useApi,
|
||||
alertApiRef,
|
||||
} from '@backstage/core';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import { Incident, IncidentPhase } from '../types';
|
||||
import OpenInBrowserIcon from '@material-ui/icons/OpenInBrowser';
|
||||
import { splunkOnCallApiRef } from '../../api/client';
|
||||
import { useAsyncFn } from 'react-use';
|
||||
import { PatchIncidentRequest } from '../../api/types';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
denseListIcon: {
|
||||
marginRight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
listItemPrimary: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
listItemIcon: {
|
||||
minWidth: '1em',
|
||||
},
|
||||
secondaryAction: {
|
||||
paddingRight: 48,
|
||||
},
|
||||
});
|
||||
|
||||
type Props = {
|
||||
incident: Incident;
|
||||
onIncidentAction: () => void;
|
||||
};
|
||||
|
||||
const IncidentPhaseStatus = ({
|
||||
currentPhase,
|
||||
}: {
|
||||
currentPhase: IncidentPhase;
|
||||
}) => {
|
||||
switch (currentPhase) {
|
||||
case 'UNACKED':
|
||||
return <StatusError />;
|
||||
case 'ACKED':
|
||||
return <StatusWarning />;
|
||||
default:
|
||||
return <StatusOK />;
|
||||
}
|
||||
};
|
||||
|
||||
const incidentPhaseTooltip = (currentPhase: IncidentPhase) => {
|
||||
switch (currentPhase) {
|
||||
case 'UNACKED':
|
||||
return 'Triggered';
|
||||
case 'ACKED':
|
||||
return 'Acknowledged';
|
||||
default:
|
||||
return 'Resolved';
|
||||
}
|
||||
};
|
||||
|
||||
const IncidentAction = ({
|
||||
currentPhase,
|
||||
incidentNames,
|
||||
resolveAction,
|
||||
acknowledgeAction,
|
||||
}: {
|
||||
currentPhase: string;
|
||||
incidentNames: string[];
|
||||
resolveAction: (args: PatchIncidentRequest) => void;
|
||||
acknowledgeAction: (args: PatchIncidentRequest) => void;
|
||||
}) => {
|
||||
switch (currentPhase) {
|
||||
case 'UNACKED':
|
||||
return (
|
||||
<Tooltip title="Aknowledge" placement="top">
|
||||
<IconButton onClick={() => acknowledgeAction({ incidentNames })}>
|
||||
<DoneIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
case 'ACKED':
|
||||
return (
|
||||
<Tooltip title="Resolve" placement="top">
|
||||
<IconButton onClick={() => resolveAction({ incidentNames })}>
|
||||
<DoneAllIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
};
|
||||
|
||||
export const IncidentListItem = ({ incident, onIncidentAction }: Props) => {
|
||||
const classes = useStyles();
|
||||
const duration =
|
||||
new Date().getTime() - new Date(incident.startTime!).getTime();
|
||||
const createdAt = DateTime.local()
|
||||
.minus(Duration.fromMillis(duration))
|
||||
.toRelative({ locale: 'en' });
|
||||
const alertApi = useApi(alertApiRef);
|
||||
const api = useApi(splunkOnCallApiRef);
|
||||
|
||||
const hasBeenManuallyTriggered = incident.monitorName?.includes('vouser-');
|
||||
|
||||
const user = hasBeenManuallyTriggered
|
||||
? incident.monitorName?.replace('vouser-', '')
|
||||
: incident.monitorName;
|
||||
|
||||
const [
|
||||
{ value: resolveValue, error: resolveError },
|
||||
handleResolveIncident,
|
||||
] = useAsyncFn(
|
||||
async ({ incidentNames }: PatchIncidentRequest) =>
|
||||
await api.resolveIncident({
|
||||
incidentNames,
|
||||
}),
|
||||
);
|
||||
|
||||
const [
|
||||
{ value: acknowledgeValue, error: acknowledgeError },
|
||||
handleAcknowledgeIncident,
|
||||
] = useAsyncFn(
|
||||
async ({ incidentNames }: PatchIncidentRequest) =>
|
||||
await api.acknowledgeIncident({
|
||||
incidentNames,
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (acknowledgeValue) {
|
||||
alertApi.post({
|
||||
message: `Incident successfully acknowledged`,
|
||||
});
|
||||
}
|
||||
|
||||
if (resolveValue) {
|
||||
alertApi.post({
|
||||
message: `Incident successfully resolved`,
|
||||
});
|
||||
}
|
||||
if (resolveValue || acknowledgeValue) {
|
||||
onIncidentAction();
|
||||
}
|
||||
}, [acknowledgeValue, resolveValue, alertApi, onIncidentAction]);
|
||||
|
||||
if (acknowledgeError) {
|
||||
alertApi.post({
|
||||
message: `Failed to acknowledge incident. ${acknowledgeError.message}`,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
if (resolveError) {
|
||||
alertApi.post({
|
||||
message: `Failed to resolve incident. ${resolveError.message}`,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<ListItem dense key={incident.entityId}>
|
||||
<ListItemIcon className={classes.listItemIcon}>
|
||||
<Tooltip
|
||||
title={incidentPhaseTooltip(incident.currentPhase)}
|
||||
placement="top"
|
||||
>
|
||||
<div className={classes.denseListIcon}>
|
||||
<IncidentPhaseStatus currentPhase={incident.currentPhase} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={incident.entityDisplayName}
|
||||
primaryTypographyProps={{
|
||||
variant: 'body1',
|
||||
className: classes.listItemPrimary,
|
||||
}}
|
||||
secondary={
|
||||
<Typography noWrap variant="body2" color="textSecondary">
|
||||
Created {createdAt} {user && `by ${user}`}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
|
||||
{incident.incidentLink && incident.incidentNumber && (
|
||||
<ListItemSecondaryAction>
|
||||
<IncidentAction
|
||||
currentPhase={incident.currentPhase || ''}
|
||||
incidentNames={[incident.incidentNumber]}
|
||||
resolveAction={handleResolveIncident}
|
||||
acknowledgeAction={handleAcknowledgeIncident}
|
||||
/>
|
||||
<Tooltip title="View in Splunk On-Call" placement="top">
|
||||
<IconButton
|
||||
href={incident.incidentLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
color="primary"
|
||||
>
|
||||
<OpenInBrowserIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</ListItemSecondaryAction>
|
||||
)}
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* 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 { render, waitFor } from '@testing-library/react';
|
||||
import { Incidents } from './Incidents';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import {
|
||||
alertApiRef,
|
||||
ApiProvider,
|
||||
ApiRegistry,
|
||||
createApiRef,
|
||||
IdentityApi,
|
||||
identityApiRef,
|
||||
} from '@backstage/core';
|
||||
import { splunkOnCallApiRef } from '../../api';
|
||||
import { MOCK_TEAM, MOCK_INCIDENT } from '../../api/mocks';
|
||||
|
||||
const mockIdentityApi: Partial<IdentityApi> = {
|
||||
getUserId: () => 'test',
|
||||
};
|
||||
|
||||
const mockSplunkOnCallApi = {
|
||||
getIncidents: () => [],
|
||||
getTeams: () => [],
|
||||
};
|
||||
const apis = ApiRegistry.from([
|
||||
[
|
||||
alertApiRef,
|
||||
createApiRef({
|
||||
id: 'core.alert',
|
||||
description: 'Used to report alerts and forward them to the app',
|
||||
}),
|
||||
],
|
||||
[identityApiRef, mockIdentityApi],
|
||||
[splunkOnCallApiRef, mockSplunkOnCallApi],
|
||||
]);
|
||||
|
||||
describe('Incidents', () => {
|
||||
it('Renders an empty state when there are no incidents', async () => {
|
||||
mockSplunkOnCallApi.getTeams = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => [MOCK_TEAM]);
|
||||
|
||||
const { getByText, queryByTestId } = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<Incidents refreshIncidents={false} team="test" />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
await waitFor(() => !queryByTestId('progress'));
|
||||
expect(getByText('Nice! No incidents found!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Renders all incidents', async () => {
|
||||
mockSplunkOnCallApi.getIncidents = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => [MOCK_INCIDENT]);
|
||||
|
||||
mockSplunkOnCallApi.getTeams = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => [MOCK_TEAM]);
|
||||
const {
|
||||
getByText,
|
||||
getByTitle,
|
||||
getAllByTitle,
|
||||
getByLabelText,
|
||||
queryByTestId,
|
||||
} = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<Incidents team="test" refreshIncidents={false} />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
await waitFor(() => !queryByTestId('progress'));
|
||||
expect(
|
||||
getByText('user', {
|
||||
exact: false,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(getByText('test-incident')).toBeInTheDocument();
|
||||
expect(getByTitle('Acknowledged')).toBeInTheDocument();
|
||||
expect(getByLabelText('Status warning')).toBeInTheDocument();
|
||||
|
||||
// assert links, mailto and hrefs, date calculation
|
||||
expect(getAllByTitle('View in Splunk On-Call').length).toEqual(1);
|
||||
});
|
||||
|
||||
it('Handle errors', async () => {
|
||||
mockSplunkOnCallApi.getIncidents = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('Error occurred'));
|
||||
|
||||
const { getByText, queryByTestId } = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<Incidents team="test" refreshIncidents={false} />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
await waitFor(() => !queryByTestId('progress'));
|
||||
expect(
|
||||
getByText('Error encountered while fetching information. Error occurred'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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, { useEffect } from 'react';
|
||||
import { List, ListSubheader } from '@material-ui/core';
|
||||
import { IncidentListItem } from './IncidentListItem';
|
||||
import { IncidentsEmptyState } from './IncidentEmptyState';
|
||||
import { useAsyncFn } from 'react-use';
|
||||
import { splunkOnCallApiRef } from '../../api';
|
||||
import { useApi, Progress } from '@backstage/core';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
|
||||
type Props = {
|
||||
refreshIncidents: boolean;
|
||||
team: string;
|
||||
};
|
||||
|
||||
export const Incidents = ({ refreshIncidents, team }: Props) => {
|
||||
const api = useApi(splunkOnCallApiRef);
|
||||
|
||||
const [{ value: incidents, loading, error }, getIncidents] = useAsyncFn(
|
||||
async () => {
|
||||
const allIncidents = await api.getIncidents();
|
||||
const teams = await api.getTeams();
|
||||
const teamSlug = teams.find(teamValue => teamValue.name === team)?.slug;
|
||||
const filteredIncidents = teamSlug
|
||||
? allIncidents.filter(incident =>
|
||||
incident.pagedTeams?.includes(teamSlug),
|
||||
)
|
||||
: [];
|
||||
return filteredIncidents;
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
getIncidents();
|
||||
}, [refreshIncidents, getIncidents]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="error">
|
||||
Error encountered while fetching information. {error.message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <Progress />;
|
||||
}
|
||||
|
||||
if (!incidents?.length) {
|
||||
return <IncidentsEmptyState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<List dense subheader={<ListSubheader>INCIDENTS</ListSubheader>}>
|
||||
{incidents!.map((incident, index) => (
|
||||
<IncidentListItem
|
||||
onIncidentAction={() => getIncidents()}
|
||||
key={index}
|
||||
incident={incident}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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 { Incidents } from './Incidents';
|
||||
@@ -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 { render, waitFor, fireEvent, act } from '@testing-library/react';
|
||||
import { SplunkOnCallCard } from './SplunkOnCallCard';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import {
|
||||
alertApiRef,
|
||||
ApiProvider,
|
||||
ApiRegistry,
|
||||
ConfigApi,
|
||||
configApiRef,
|
||||
ConfigReader,
|
||||
createApiRef,
|
||||
} from '@backstage/core';
|
||||
import {
|
||||
splunkOnCallApiRef,
|
||||
UnauthorizedError,
|
||||
SplunkOnCallClient,
|
||||
} from '../api';
|
||||
import {
|
||||
ESCALATION_POLICIES,
|
||||
MOCKED_ON_CALL,
|
||||
MOCKED_USER,
|
||||
MOCK_INCIDENT,
|
||||
MOCK_TEAM,
|
||||
} from '../api/mocks';
|
||||
|
||||
const mockSplunkOnCallApi: Partial<SplunkOnCallClient> = {
|
||||
getUsers: async () => [],
|
||||
getIncidents: async () => [MOCK_INCIDENT],
|
||||
getOnCallUsers: async () => MOCKED_ON_CALL,
|
||||
getTeams: async () => [MOCK_TEAM],
|
||||
getEscalationPolicies: async () => ESCALATION_POLICIES,
|
||||
};
|
||||
|
||||
const configApi: ConfigApi = new ConfigReader({
|
||||
splunkOnCall: {
|
||||
username: MOCKED_USER.username,
|
||||
},
|
||||
});
|
||||
|
||||
const apis = ApiRegistry.from([
|
||||
[splunkOnCallApiRef, mockSplunkOnCallApi],
|
||||
[configApiRef, configApi],
|
||||
[
|
||||
alertApiRef,
|
||||
createApiRef({
|
||||
id: 'core.alert',
|
||||
description: 'Used to report alerts and forward them to the app',
|
||||
}),
|
||||
],
|
||||
]);
|
||||
const entity: Entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'splunkoncall-test',
|
||||
annotations: {
|
||||
'splunk.com/on-call-team': 'Example',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('SplunkOnCallCard', () => {
|
||||
it('Render splunkoncall', async () => {
|
||||
mockSplunkOnCallApi.getUsers = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => [MOCKED_USER]);
|
||||
|
||||
const { getByText, queryByTestId } = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<SplunkOnCallCard entity={entity} />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
await waitFor(() => !queryByTestId('progress'));
|
||||
expect(getByText('Create Incident')).toBeInTheDocument();
|
||||
expect(getByText('Nice! No incidents found!')).toBeInTheDocument();
|
||||
expect(getByText('Empty escalation policy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Handles custom error for missing token', async () => {
|
||||
mockSplunkOnCallApi.getUsers = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new UnauthorizedError());
|
||||
|
||||
const { getByText, queryByTestId } = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<SplunkOnCallCard entity={entity} />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
await waitFor(() => !queryByTestId('progress'));
|
||||
expect(
|
||||
getByText('Missing or invalid Splunk On-Call API key and/or API id'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles general error', async () => {
|
||||
mockSplunkOnCallApi.getUsers = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('An error occurred'));
|
||||
const { getByText, queryByTestId } = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<SplunkOnCallCard entity={entity} />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
await waitFor(() => !queryByTestId('progress'));
|
||||
|
||||
expect(
|
||||
getByText(
|
||||
'Error encountered while fetching information. An error occurred',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
it('opens the dialog when trigger button is clicked', async () => {
|
||||
mockSplunkOnCallApi.getUsers = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => [MOCKED_USER]);
|
||||
|
||||
const { getByText, queryByTestId, getByTestId, getByRole } = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<SplunkOnCallCard entity={entity} />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
await waitFor(() => !queryByTestId('progress'));
|
||||
expect(getByText('Create Incident')).toBeInTheDocument();
|
||||
const triggerButton = getByTestId('trigger-button');
|
||||
await act(async () => {
|
||||
fireEvent.click(triggerButton);
|
||||
});
|
||||
expect(getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
* 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, { useState, useCallback } from 'react';
|
||||
import {
|
||||
useApi,
|
||||
Progress,
|
||||
HeaderIconLinkRow,
|
||||
MissingAnnotationEmptyState,
|
||||
configApiRef,
|
||||
EmptyState,
|
||||
} from '@backstage/core';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import {
|
||||
Button,
|
||||
makeStyles,
|
||||
Card,
|
||||
CardHeader,
|
||||
Divider,
|
||||
CardContent,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import { Incidents } from './Incident';
|
||||
import { EscalationPolicy } from './Escalation';
|
||||
import { useAsync } from 'react-use';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import { splunkOnCallApiRef, UnauthorizedError } from '../api';
|
||||
import AlarmAddIcon from '@material-ui/icons/AlarmAdd';
|
||||
import { TriggerDialog } from './TriggerDialog';
|
||||
import { MissingApiKeyOrApiIdError } from './Errors/MissingApiKeyOrApiIdError';
|
||||
import { User } from './types';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
triggerAlarm: {
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
fontSize: '0.7rem',
|
||||
textTransform: 'uppercase',
|
||||
fontWeight: 600,
|
||||
letterSpacing: 1.2,
|
||||
lineHeight: 1.5,
|
||||
'&:hover, &:focus, &.focus': {
|
||||
backgroundColor: 'transparent',
|
||||
textDecoration: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const SPLUNK_ON_CALL_TEAM = 'splunk.com/on-call-team';
|
||||
|
||||
export const MissingTeamAnnotation = () => (
|
||||
<MissingAnnotationEmptyState annotation={SPLUNK_ON_CALL_TEAM} />
|
||||
);
|
||||
|
||||
export const MissingUsername = () => (
|
||||
<CardContent>
|
||||
<EmptyState
|
||||
title="No Splunk On-Call user available."
|
||||
missing="info"
|
||||
description="You need to add a valid username to your 'app-config.yaml' if you want to enable Splunk On-Call. Make sure that the user is a member of your organization."
|
||||
/>
|
||||
</CardContent>
|
||||
);
|
||||
|
||||
export const isPluginApplicableToEntity = (entity: Entity) =>
|
||||
Boolean(entity.metadata.annotations?.[SPLUNK_ON_CALL_TEAM]);
|
||||
|
||||
type Props = {
|
||||
entity: Entity;
|
||||
};
|
||||
|
||||
export const SplunkOnCallCard = ({ entity }: Props) => {
|
||||
const classes = useStyles();
|
||||
const config = useApi(configApiRef);
|
||||
const api = useApi(splunkOnCallApiRef);
|
||||
const [showDialog, setShowDialog] = useState<boolean>(false);
|
||||
const [refreshIncidents, setRefreshIncidents] = useState<boolean>(false);
|
||||
const team = entity.metadata.annotations![SPLUNK_ON_CALL_TEAM];
|
||||
|
||||
const username = config.getOptionalString('splunkOnCall.username');
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
setRefreshIncidents(x => !x);
|
||||
}, []);
|
||||
|
||||
const handleDialog = useCallback(() => {
|
||||
setShowDialog(x => !x);
|
||||
}, []);
|
||||
|
||||
const { value: users, loading, error } = useAsync(async () => {
|
||||
const allUsers = await api.getUsers();
|
||||
const usersHashMap = allUsers.reduce(
|
||||
(map: Record<string, User>, obj: User) => {
|
||||
if (obj.username) {
|
||||
map[obj.username] = obj;
|
||||
}
|
||||
return map;
|
||||
},
|
||||
{},
|
||||
);
|
||||
return { usersHashMap, userList: allUsers };
|
||||
});
|
||||
|
||||
const incidentCreator =
|
||||
username && users?.userList.find(user => user.username === username);
|
||||
|
||||
if (error instanceof UnauthorizedError) {
|
||||
return <MissingApiKeyOrApiIdError />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="error">
|
||||
Error encountered while fetching information. {error.message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <Progress />;
|
||||
}
|
||||
|
||||
const Content = () => {
|
||||
if (!team) {
|
||||
return <MissingTeamAnnotation />;
|
||||
}
|
||||
if (!username || !incidentCreator) {
|
||||
return <MissingUsername />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Incidents team={team} refreshIncidents={refreshIncidents} />
|
||||
{users?.usersHashMap && team && (
|
||||
<EscalationPolicy team={team} users={users.usersHashMap} />
|
||||
)}
|
||||
{users && incidentCreator && (
|
||||
<TriggerDialog
|
||||
users={users.userList}
|
||||
incidentCreator={incidentCreator}
|
||||
showDialog={showDialog}
|
||||
handleDialog={handleDialog}
|
||||
onIncidentCreated={handleRefresh}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const triggerLink = {
|
||||
label: 'Create Incident',
|
||||
action: (
|
||||
<Button
|
||||
data-testid="trigger-button"
|
||||
color="secondary"
|
||||
onClick={handleDialog}
|
||||
className={classes.triggerAlarm}
|
||||
>
|
||||
Create Incident
|
||||
</Button>
|
||||
),
|
||||
icon: <AlarmAddIcon onClick={handleDialog} />,
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Splunk On-Call"
|
||||
subheader={[
|
||||
<Typography key="team_name">Team: {team}</Typography>,
|
||||
username && (
|
||||
<HeaderIconLinkRow key="incident_trigger" links={[triggerLink]} />
|
||||
),
|
||||
]}
|
||||
/>
|
||||
<Divider />
|
||||
<CardContent>
|
||||
<Content />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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 { Grid, makeStyles } from '@material-ui/core';
|
||||
import {
|
||||
Content,
|
||||
ContentHeader,
|
||||
Page,
|
||||
Header,
|
||||
SupportButton,
|
||||
} from '@backstage/core';
|
||||
import { SplunkOnCallCard } from './SplunkOnCallCard';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
overflowXScroll: {
|
||||
overflowX: 'scroll',
|
||||
},
|
||||
}));
|
||||
|
||||
export type SplunkOnCallPageProps = {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
pageTitle?: string;
|
||||
};
|
||||
|
||||
export const SplunkOnCallPage = ({
|
||||
title,
|
||||
subtitle,
|
||||
pageTitle,
|
||||
}: SplunkOnCallPageProps): JSX.Element => {
|
||||
const classes = useStyles();
|
||||
const { entity } = useEntity();
|
||||
|
||||
return (
|
||||
<Page themeId="tool">
|
||||
<Header title={title} subtitle={subtitle} />
|
||||
<Content className={classes.overflowXScroll}>
|
||||
<ContentHeader title={pageTitle}>
|
||||
<SupportButton>
|
||||
This is used to help you automate incident management.
|
||||
</SupportButton>
|
||||
</ContentHeader>
|
||||
<Grid container spacing={3} direction="row">
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<SplunkOnCallCard entity={entity} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
SplunkOnCallPage.defaultProps = {
|
||||
title: 'Splunk On-Call',
|
||||
subtitle: 'Automate incident management',
|
||||
pageTitle: 'Dashboard',
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* 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 { render, fireEvent, act } from '@testing-library/react';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import {
|
||||
ApiRegistry,
|
||||
alertApiRef,
|
||||
createApiRef,
|
||||
ApiProvider,
|
||||
} from '@backstage/core';
|
||||
import { splunkOnCallApiRef } from '../../api';
|
||||
import { TriggerDialog } from './TriggerDialog';
|
||||
import { ESCALATION_POLICIES, MOCKED_USER } from '../../api/mocks';
|
||||
|
||||
describe('TriggerDialog', () => {
|
||||
const mockTriggerAlarmFn = jest.fn();
|
||||
const mockSplunkOnCallApi = {
|
||||
triggerAlarm: mockTriggerAlarmFn,
|
||||
getEscalationPolicies: async () => ESCALATION_POLICIES,
|
||||
};
|
||||
|
||||
const apis = ApiRegistry.from([
|
||||
[
|
||||
alertApiRef,
|
||||
createApiRef({
|
||||
id: 'core.alert',
|
||||
description: 'Used to report alerts and forward them to the app',
|
||||
}),
|
||||
],
|
||||
[splunkOnCallApiRef, mockSplunkOnCallApi],
|
||||
]);
|
||||
|
||||
it('open the dialog and trigger an alarm', async () => {
|
||||
const { getByText, getByRole, getAllByRole, getByTestId } = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<TriggerDialog
|
||||
showDialog
|
||||
incidentCreator={MOCKED_USER}
|
||||
handleDialog={() => {}}
|
||||
users={[MOCKED_USER]}
|
||||
onIncidentCreated={() => {}}
|
||||
/>
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(getByRole('dialog')).toBeInTheDocument();
|
||||
expect(
|
||||
getByText('This action will trigger an incident', {
|
||||
exact: false,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
const summary = getByTestId('trigger-summary-input');
|
||||
const body = getByTestId('trigger-body-input');
|
||||
const behavior = getByTestId('trigger-select-behavior');
|
||||
const description = 'Test Trigger Alarm';
|
||||
await act(async () => {
|
||||
fireEvent.change(summary, { target: { value: description } });
|
||||
fireEvent.change(body, { target: { value: description } });
|
||||
fireEvent.change(behavior, { target: { value: '0' } });
|
||||
fireEvent.mouseDown(getAllByRole('button')[0]);
|
||||
});
|
||||
|
||||
// Trigger user targets select
|
||||
const options = getAllByRole('option');
|
||||
await act(async () => {
|
||||
fireEvent.click(options[0]);
|
||||
fireEvent.keyDown(options[0], {
|
||||
key: 'Escape',
|
||||
code: 'Escape',
|
||||
keyCode: 27,
|
||||
charCode: 27,
|
||||
});
|
||||
});
|
||||
|
||||
// Trigger policy targets select
|
||||
await act(async () => {
|
||||
fireEvent.mouseDown(getAllByRole('button')[1]);
|
||||
});
|
||||
const policiesOptions = getAllByRole('option');
|
||||
await act(async () => {
|
||||
fireEvent.click(policiesOptions[0]);
|
||||
});
|
||||
|
||||
// Trigger incident creation button
|
||||
const triggerButton = getByTestId('trigger-button');
|
||||
await act(async () => {
|
||||
fireEvent.click(triggerButton);
|
||||
});
|
||||
expect(mockTriggerAlarmFn).toHaveBeenCalled();
|
||||
expect(mockTriggerAlarmFn).toHaveBeenCalledWith({
|
||||
summary: description,
|
||||
details: description,
|
||||
userName: 'test_user',
|
||||
targets: [
|
||||
{ slug: 'test_user', type: 'User' },
|
||||
{ slug: 'team-zEalMCgwYSA0Lt40', type: 'EscalationPolicy' },
|
||||
],
|
||||
isMultiResponder: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,365 @@
|
||||
/*
|
||||
* 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, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
TextField,
|
||||
DialogActions,
|
||||
Button,
|
||||
DialogContent,
|
||||
Typography,
|
||||
CircularProgress,
|
||||
Select,
|
||||
MenuItem,
|
||||
Input,
|
||||
Chip,
|
||||
createStyles,
|
||||
makeStyles,
|
||||
Theme,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
} from '@material-ui/core';
|
||||
import { useApi, alertApiRef } from '@backstage/core';
|
||||
import { useAsync, useAsyncFn } from 'react-use';
|
||||
import { splunkOnCallApiRef } from '../../api';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import { User } from '../types';
|
||||
import { IncidentTarget, TargetType } from '../../api/types';
|
||||
|
||||
const MenuProps = {
|
||||
PaperProps: {
|
||||
style: {
|
||||
width: 250,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
type Props = {
|
||||
users: User[];
|
||||
incidentCreator: User;
|
||||
showDialog: boolean;
|
||||
handleDialog: () => void;
|
||||
onIncidentCreated: () => void;
|
||||
};
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
chips: {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
chip: {
|
||||
margin: 2,
|
||||
},
|
||||
formControl: {
|
||||
margin: theme.spacing(1),
|
||||
minWidth: `calc(100% - ${theme.spacing(2)}px)`,
|
||||
},
|
||||
targets: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export const TriggerDialog = ({
|
||||
users,
|
||||
incidentCreator,
|
||||
showDialog,
|
||||
handleDialog,
|
||||
onIncidentCreated: onIncidentCreated,
|
||||
}: Props) => {
|
||||
const alertApi = useApi(alertApiRef);
|
||||
const api = useApi(splunkOnCallApiRef);
|
||||
const classes = useStyles();
|
||||
|
||||
const [userTargets, setUserTargets] = useState<string[]>([]);
|
||||
const [policyTargets, setPolicyTargets] = useState<string[]>([]);
|
||||
const [detailsValue, setDetails] = useState<string>('');
|
||||
const [summaryValue, setSummary] = useState<string>('');
|
||||
const [isMultiResponderValue, setIsMultiResponder] = useState<string>('1');
|
||||
|
||||
const [
|
||||
{ value, loading: triggerLoading, error: triggerError },
|
||||
handleTriggerAlarm,
|
||||
] = useAsyncFn(
|
||||
async (
|
||||
summary: string,
|
||||
details: string,
|
||||
userName: string,
|
||||
targets: IncidentTarget[],
|
||||
isMultiResponder: boolean,
|
||||
) =>
|
||||
await api.triggerAlarm({
|
||||
summary,
|
||||
details,
|
||||
userName,
|
||||
targets,
|
||||
isMultiResponder,
|
||||
}),
|
||||
);
|
||||
|
||||
const {
|
||||
value: policies,
|
||||
loading: policiesLoaading,
|
||||
error: policiesError,
|
||||
} = useAsync(async () => {
|
||||
const allPolicies = await api.getEscalationPolicies();
|
||||
return allPolicies;
|
||||
});
|
||||
|
||||
const handleUserTargets = (event: React.ChangeEvent<{ value: unknown }>) => {
|
||||
setUserTargets(event.target.value as string[]);
|
||||
};
|
||||
|
||||
const handlePolicyTargets = (
|
||||
event: React.ChangeEvent<{ value: unknown }>,
|
||||
) => {
|
||||
setPolicyTargets(event.target.value as string[]);
|
||||
};
|
||||
|
||||
const detailsChanged = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setDetails(event.target.value);
|
||||
};
|
||||
|
||||
const summaryChanged = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setSummary(event.target.value);
|
||||
};
|
||||
|
||||
const isMultiResponderChanged = (
|
||||
event: React.ChangeEvent<{ value: unknown }>,
|
||||
) => {
|
||||
setIsMultiResponder(event.target.value as string);
|
||||
};
|
||||
|
||||
const targets = (): IncidentTarget[] => [
|
||||
...userTargets.map(user => ({ slug: user, type: TargetType.UserValue })),
|
||||
...policyTargets.map(user => ({
|
||||
slug: user,
|
||||
type: TargetType.EscalationPolicyValue,
|
||||
})),
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
alertApi.post({
|
||||
message: `Alarm successfully triggered`,
|
||||
});
|
||||
onIncidentCreated();
|
||||
handleDialog();
|
||||
}
|
||||
}, [value, alertApi, handleDialog, onIncidentCreated]);
|
||||
|
||||
if (triggerError) {
|
||||
alertApi.post({
|
||||
message: `Failed to trigger alarm. ${triggerError.message}`,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog maxWidth="md" open={showDialog} onClose={handleDialog} fullWidth>
|
||||
<DialogTitle>This action will trigger an incident</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="subtitle1" gutterBottom align="justify">
|
||||
Created by:{' '}
|
||||
<b>
|
||||
{incidentCreator?.firstName} {incidentCreator?.lastName}
|
||||
</b>
|
||||
</Typography>
|
||||
<Alert severity="info">
|
||||
<Typography variant="body1" align="justify">
|
||||
If the issue you are seeing does not need urgent attention, please
|
||||
get in touch with the responsible team using their preferred
|
||||
communications channel. You can find information about the owner of
|
||||
this entity in the "About" card. If the issue is urgent, please
|
||||
don't hesitate to trigger the alert.
|
||||
</Typography>
|
||||
</Alert>
|
||||
<Typography
|
||||
variant="body1"
|
||||
style={{ marginTop: '1em' }}
|
||||
gutterBottom
|
||||
align="justify"
|
||||
>
|
||||
Please describe the problem you want to report. Be as descriptive as
|
||||
possible. Your signed in user and a reference to the current page will
|
||||
automatically be amended to the alarm so that the receiver can reach
|
||||
out to you if necessary.
|
||||
</Typography>
|
||||
<div style={{ marginTop: '1em' }}>
|
||||
<Typography color="textSecondary" gutterBottom>
|
||||
Select the targets
|
||||
</Typography>
|
||||
<div className={classes.targets}>
|
||||
<FormControl className={classes.formControl}>
|
||||
<InputLabel>Select Users</InputLabel>
|
||||
<Select
|
||||
id="user-targets"
|
||||
multiple
|
||||
value={userTargets}
|
||||
onChange={handleUserTargets}
|
||||
input={<Input />}
|
||||
renderValue={selected => (
|
||||
<div className={classes.chips}>
|
||||
{(selected as string[]).map(selectedUser => {
|
||||
const element = users.find(
|
||||
user => user.username === selectedUser,
|
||||
);
|
||||
return (
|
||||
<Chip
|
||||
key={selectedUser}
|
||||
label={`${element?.firstName} ${element?.lastName}`}
|
||||
className={classes.chip}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
MenuProps={MenuProps}
|
||||
>
|
||||
{users.map(user => (
|
||||
<MenuItem key={user.email} value={user.username}>
|
||||
{user.firstName} {user.lastName}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl className={classes.formControl}>
|
||||
<InputLabel>Select Teams / Policies</InputLabel>
|
||||
<Select
|
||||
id="policy-targets"
|
||||
multiple
|
||||
value={policyTargets}
|
||||
onChange={handlePolicyTargets}
|
||||
input={<Input />}
|
||||
renderValue={selected => (
|
||||
<div className={classes.chips}>
|
||||
{(selected as string[]).map(selectedPolicy => {
|
||||
const element = policies?.find(
|
||||
policy => policy.policy.slug === selectedPolicy,
|
||||
);
|
||||
return (
|
||||
<Chip
|
||||
key={selectedPolicy}
|
||||
label={element?.policy.name}
|
||||
className={classes.chip}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
MenuProps={MenuProps}
|
||||
>
|
||||
{!policiesError &&
|
||||
!policiesLoaading &&
|
||||
policies &&
|
||||
policies.map(policy => (
|
||||
<MenuItem
|
||||
key={policy.policy.slug}
|
||||
value={policy.policy.slug}
|
||||
>
|
||||
{policy.policy.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
<Typography
|
||||
style={{ marginTop: '1em' }}
|
||||
color="textSecondary"
|
||||
gutterBottom
|
||||
>
|
||||
Acknowledge Behavior
|
||||
</Typography>
|
||||
<FormControl className={classes.formControl}>
|
||||
<Select
|
||||
id="multi-responder"
|
||||
value={isMultiResponderValue}
|
||||
onChange={isMultiResponderChanged}
|
||||
inputProps={{ 'data-testid': 'trigger-select-behavior' }}
|
||||
>
|
||||
<MenuItem value="1">
|
||||
Stop paging after a single escalation policy or user has
|
||||
acknowledged
|
||||
</MenuItem>
|
||||
<MenuItem value="0">
|
||||
Continue paging until each escalation policy or user above has
|
||||
acknowledged
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
required
|
||||
inputProps={{ 'data-testid': 'trigger-summary-input' }}
|
||||
id="summary"
|
||||
multiline
|
||||
fullWidth
|
||||
rows="4"
|
||||
margin="normal"
|
||||
label="Incident summary"
|
||||
variant="outlined"
|
||||
onChange={summaryChanged}
|
||||
/>
|
||||
<TextField
|
||||
required
|
||||
inputProps={{ 'data-testid': 'trigger-body-input' }}
|
||||
id="details"
|
||||
multiline
|
||||
fullWidth
|
||||
rows="2"
|
||||
margin="normal"
|
||||
label="Incident body"
|
||||
variant="outlined"
|
||||
onChange={detailsChanged}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
data-testid="trigger-button"
|
||||
id="trigger"
|
||||
color="secondary"
|
||||
disabled={
|
||||
!detailsValue ||
|
||||
!summaryValue ||
|
||||
(!userTargets.length && !policyTargets.length) ||
|
||||
triggerLoading
|
||||
}
|
||||
variant="contained"
|
||||
onClick={() =>
|
||||
handleTriggerAlarm(
|
||||
summaryValue,
|
||||
detailsValue,
|
||||
incidentCreator.username!,
|
||||
targets(),
|
||||
!!Number(isMultiResponderValue),
|
||||
)
|
||||
}
|
||||
endIcon={triggerLoading && <CircularProgress size={16} />}
|
||||
>
|
||||
Trigger Incident
|
||||
</Button>
|
||||
<Button id="close" color="primary" onClick={handleDialog}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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 { TriggerDialog } from './TriggerDialog';
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* 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 { IncidentTarget } from '../api/types';
|
||||
|
||||
export type Team = {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
memberCount?: number;
|
||||
version?: number;
|
||||
isDefaultTeam?: boolean;
|
||||
_selfUrl?: string;
|
||||
_policiesUrl?: string;
|
||||
_membersUrl?: string;
|
||||
_adminsUrl?: string;
|
||||
};
|
||||
|
||||
export type OnCall = {
|
||||
team?: OnCallTeamResource;
|
||||
oncallNow?: OnCallNowResource[];
|
||||
};
|
||||
|
||||
export type OnCallTeamResource = {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
};
|
||||
|
||||
export type OnCallNowResource = {
|
||||
escalationPolicy?: OnCallEscalationPolicyResource;
|
||||
users?: OnCallUsersResource[];
|
||||
};
|
||||
|
||||
export type OnCallEscalationPolicyResource = {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
};
|
||||
|
||||
export type OnCallUsersResource = {
|
||||
onCalluser?: OnCallUser;
|
||||
};
|
||||
|
||||
export type OnCallUser = {
|
||||
username?: string;
|
||||
};
|
||||
|
||||
export type User = {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
displayName?: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
createdAt?: string;
|
||||
passwordLastUpdated?: string;
|
||||
verified?: boolean;
|
||||
_selfUrl?: string;
|
||||
};
|
||||
|
||||
export type CreateIncidentRequest = {
|
||||
summary: string;
|
||||
details: string;
|
||||
userName: string;
|
||||
targets: IncidentTarget;
|
||||
isMultiResponder: boolean;
|
||||
};
|
||||
|
||||
export type IncidentPhase = 'UNACKED' | 'ACKED' | 'RESOLVED';
|
||||
|
||||
export type Incident = {
|
||||
incidentNumber?: string;
|
||||
startTime?: string;
|
||||
currentPhase: IncidentPhase;
|
||||
entityState?: string;
|
||||
entityType?: string;
|
||||
routingKey?: string;
|
||||
alertCount?: number;
|
||||
lastAlertTime?: string;
|
||||
lastAlertId?: string;
|
||||
entityId?: string;
|
||||
host?: string;
|
||||
service?: string;
|
||||
pagedUsers?: string[];
|
||||
pagedTeams?: string[];
|
||||
entityDisplayName?: string;
|
||||
pagedPolicies?: EscalationPolicyInfo[];
|
||||
transitions?: IncidentTransition[];
|
||||
firstAlertUuid?: string;
|
||||
monitorName?: string;
|
||||
monitorType?: string;
|
||||
incidentLink?: string;
|
||||
};
|
||||
|
||||
export type EscalationPolicyInfo = {
|
||||
policy: EscalationPolicySummary;
|
||||
team?: EscalationPolicyTeam;
|
||||
};
|
||||
|
||||
export type IncidentTransition = {
|
||||
name?: string;
|
||||
at?: string;
|
||||
by?: string;
|
||||
message?: string;
|
||||
manually?: boolean;
|
||||
alertId?: string;
|
||||
alertUrl?: string;
|
||||
};
|
||||
|
||||
export type EscalationPolicySummary = {
|
||||
name: string;
|
||||
slug: string;
|
||||
_selfUrl: string;
|
||||
};
|
||||
|
||||
export type EscalationPolicyTeam = {
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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 {
|
||||
splunkOnCallPlugin,
|
||||
splunkOnCallPlugin as plugin,
|
||||
SplunkOnCallPage,
|
||||
} from './plugin';
|
||||
export {
|
||||
isPluginApplicableToEntity,
|
||||
SplunkOnCallCard,
|
||||
} from './components/SplunkOnCallCard';
|
||||
export {
|
||||
SplunkOnCallClient,
|
||||
splunkOnCallApiRef,
|
||||
UnauthorizedError,
|
||||
} from './api/client';
|
||||
+4
-10
@@ -13,16 +13,10 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { splunkOnCallPlugin } from './plugin';
|
||||
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import React from 'react';
|
||||
import { MissingProvidesApisEmptyState } from './MissingProvidesApisEmptyState';
|
||||
|
||||
describe('<MissingProvidesApisEmptyState />', () => {
|
||||
it('renders without exploding', async () => {
|
||||
const { getByText } = await renderInTestApp(
|
||||
<MissingProvidesApisEmptyState />,
|
||||
);
|
||||
expect(getByText(/providesApis:/i)).toBeInTheDocument();
|
||||
describe('splunk-on-call', () => {
|
||||
it('should export plugin', () => {
|
||||
expect(splunkOnCallPlugin).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
createApiFactory,
|
||||
createPlugin,
|
||||
createRouteRef,
|
||||
discoveryApiRef,
|
||||
configApiRef,
|
||||
createRoutableExtension,
|
||||
} from '@backstage/core';
|
||||
import { splunkOnCallApiRef, SplunkOnCallClient } from './api';
|
||||
|
||||
export const rootRouteRef = createRouteRef({
|
||||
title: 'splunk-on-call',
|
||||
});
|
||||
|
||||
export const splunkOnCallPlugin = createPlugin({
|
||||
id: 'splunk-on-call',
|
||||
apis: [
|
||||
createApiFactory({
|
||||
api: splunkOnCallApiRef,
|
||||
deps: { discoveryApi: discoveryApiRef, configApi: configApiRef },
|
||||
factory: ({ configApi, discoveryApi }) =>
|
||||
SplunkOnCallClient.fromConfig(configApi, discoveryApi),
|
||||
}),
|
||||
],
|
||||
routes: {
|
||||
root: rootRouteRef,
|
||||
},
|
||||
});
|
||||
|
||||
export const SplunkOnCallPage = splunkOnCallPlugin.provide(
|
||||
createRoutableExtension({
|
||||
component: () =>
|
||||
import('./components/SplunkOnCallPage').then(m => m.SplunkOnCallPage),
|
||||
mountPoint: rootRouteRef,
|
||||
}),
|
||||
);
|
||||
@@ -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.
|
||||
*/
|
||||
import '@testing-library/jest-dom';
|
||||
Reference in New Issue
Block a user