@@ -65,7 +65,7 @@
|
||||
"@backstage/backend-test-utils": "workspace:^",
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@types/lodash": "^4.14.151",
|
||||
"msw": "^0.49.0"
|
||||
"msw": "^1.0.0"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -24,7 +24,7 @@ describe('<EntityKindIcon />', () => {
|
||||
<EntityKindIcon kind="Component" />,
|
||||
);
|
||||
|
||||
expect(baseElement.querySelector('.MuiSvgIcon-root')).toBeInTheDocument();
|
||||
expect(baseElement.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders without exploding for unknown kind', async () => {
|
||||
@@ -32,6 +32,6 @@ describe('<EntityKindIcon />', () => {
|
||||
<EntityKindIcon kind="unknown" />,
|
||||
);
|
||||
|
||||
expect(baseElement.querySelector('.MuiSvgIcon-root')).toBeInTheDocument();
|
||||
expect(baseElement.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ import { BackButton, NextButton } from '../Buttons';
|
||||
import { EntityListComponent } from '../EntityListComponent';
|
||||
import { PrepareResult, ReviewResult } from '../useImportState';
|
||||
|
||||
import { configApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import { configApiRef, useAnalytics, useApi } from '@backstage/core-plugin-api';
|
||||
import { Link } from '@backstage/core-components';
|
||||
import { stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import { assertError } from '@backstage/errors';
|
||||
@@ -40,6 +40,7 @@ export const StepReviewLocation = ({
|
||||
}: Props) => {
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const configApi = useApi(configApiRef);
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const appTitle = configApi.getOptional('app.title') || 'Backstage';
|
||||
|
||||
@@ -52,6 +53,7 @@ export const StepReviewLocation = ({
|
||||
: false;
|
||||
const handleClick = useCallback(async () => {
|
||||
setSubmitted(true);
|
||||
analytics.captureEvent('click', 'import entity');
|
||||
try {
|
||||
let refreshed = new Array<{ target: string }>();
|
||||
if (prepareResult.type === 'locations') {
|
||||
@@ -108,7 +110,7 @@ export const StepReviewLocation = ({
|
||||
setSubmitted(false);
|
||||
}
|
||||
}
|
||||
}, [prepareResult, onReview, catalogApi]);
|
||||
}, [prepareResult, onReview, catalogApi, analytics]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.61",
|
||||
"@react-hookz/web": "^23.0.0",
|
||||
"@types/react": "^16.13.1 || ^17.0.0",
|
||||
"classnames": "^2.2.6",
|
||||
"jwt-decode": "^3.1.0",
|
||||
|
||||
+151
-92
@@ -14,8 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MockEntityListContextProvider } from '../../testUtils/providers';
|
||||
import { EntityOwnerFilter } from '../../filters';
|
||||
@@ -28,8 +28,9 @@ import {
|
||||
} from '@backstage/test-utils';
|
||||
import { catalogApiRef, CatalogApi } from '../..';
|
||||
import { errorApiRef } from '@backstage/core-plugin-api';
|
||||
import { QueryEntitiesCursorRequest } from '@backstage/catalog-client';
|
||||
|
||||
const ownerEntities: Entity[] = [
|
||||
const ownerEntitiesBatch1: Entity[] = [
|
||||
{
|
||||
apiVersion: '1',
|
||||
kind: 'Group',
|
||||
@@ -68,64 +69,56 @@ const ownerEntities: Entity[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const sampleEntities: Entity[] = [
|
||||
const ownerEntitiesBatch2: Entity[] = [
|
||||
{
|
||||
apiVersion: '1',
|
||||
kind: 'Component',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'component-1',
|
||||
name: 'some-owner-batch-2',
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: 'ownedBy',
|
||||
targetRef: 'group:default/some-owner',
|
||||
},
|
||||
{
|
||||
type: 'ownedBy',
|
||||
targetRef: 'group:default/some-owner-2',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
apiVersion: '1',
|
||||
kind: 'Component',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'component-2',
|
||||
name: 'some-owner-2-batch-2',
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: 'ownedBy',
|
||||
targetRef: 'group:default/another-owner',
|
||||
spec: {
|
||||
profile: {
|
||||
displayName: 'Some Owner Batch 2',
|
||||
},
|
||||
{
|
||||
type: 'ownedBy',
|
||||
targetRef: 'group:test-namespace/another-owner-2',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
apiVersion: '1',
|
||||
kind: 'Component',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'component-3',
|
||||
name: 'another-owner-batch-2',
|
||||
title: 'Another Owner Batch 2',
|
||||
},
|
||||
},
|
||||
{
|
||||
apiVersion: '1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
namespace: 'test-namespace',
|
||||
name: 'another-owner-2-batch-2',
|
||||
title: 'Another Owner in Another Namespace Batch 2',
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: 'ownedBy',
|
||||
targetRef: 'group:default/some-owner',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const getEntitiesByRefs = jest.fn(async ({ entityRefs }) => ({
|
||||
items: entityRefs.map((e: string) =>
|
||||
ownerEntities.find(f => stringifyEntityRef(f) === e),
|
||||
),
|
||||
}));
|
||||
const mockedQueryEntities: jest.MockedFn<CatalogApi['queryEntities']> =
|
||||
jest.fn();
|
||||
|
||||
const mockedGetEntitiesByRef: jest.MockedFn<CatalogApi['getEntitiesByRefs']> =
|
||||
jest.fn();
|
||||
|
||||
const mockCatalogApi: Partial<CatalogApi> = {
|
||||
getEntitiesByRefs,
|
||||
queryEntities: mockedQueryEntities,
|
||||
getEntitiesByRefs: mockedGetEntitiesByRef,
|
||||
};
|
||||
|
||||
const mockErrorApi = new MockErrorApi();
|
||||
|
||||
describe('<EntityOwnerPicker/>', () => {
|
||||
@@ -134,12 +127,33 @@ describe('<EntityOwnerPicker/>', () => {
|
||||
[errorApiRef, mockErrorApi],
|
||||
);
|
||||
|
||||
it('renders all owners', async () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
mockedQueryEntities.mockImplementation(async request => {
|
||||
const totalItems =
|
||||
ownerEntitiesBatch1.length + ownerEntitiesBatch2.length;
|
||||
if ((request as QueryEntitiesCursorRequest).cursor) {
|
||||
return {
|
||||
items: ownerEntitiesBatch2,
|
||||
pageInfo: {},
|
||||
totalItems,
|
||||
};
|
||||
}
|
||||
return {
|
||||
items: ownerEntitiesBatch1,
|
||||
pageInfo: {
|
||||
nextCursor: 'nextCursor',
|
||||
},
|
||||
totalItems,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
it('renders all users and groups', async () => {
|
||||
await renderWithEffects(
|
||||
<ApiProvider apis={mockApis}>
|
||||
<MockEntityListContextProvider
|
||||
value={{ entities: sampleEntities, backendEntities: sampleEntities }}
|
||||
>
|
||||
<MockEntityListContextProvider value={{}}>
|
||||
<EntityOwnerPicker />
|
||||
</MockEntityListContextProvider>
|
||||
</ApiProvider>,
|
||||
@@ -147,36 +161,37 @@ describe('<EntityOwnerPicker/>', () => {
|
||||
expect(screen.getByText('Owner')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId('owner-picker-expand'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Another Owner')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
[
|
||||
'Another Owner',
|
||||
'some-owner',
|
||||
'Some Owner 2',
|
||||
'Another Owner in Another Namespace',
|
||||
].forEach(owner => {
|
||||
expect(screen.getByText(owner)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders unique owners in alphabetical order', async () => {
|
||||
await renderWithEffects(
|
||||
<ApiProvider apis={mockApis}>
|
||||
<MockEntityListContextProvider
|
||||
value={{ entities: sampleEntities, backendEntities: sampleEntities }}
|
||||
>
|
||||
<EntityOwnerPicker />
|
||||
</MockEntityListContextProvider>
|
||||
</ApiProvider>,
|
||||
expect(mockedQueryEntities).toHaveBeenCalledTimes(1);
|
||||
expect(mockedGetEntitiesByRef).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.scroll(screen.getByTestId('owner-picker-listbox'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('some-owner-batch-2')).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByText('Owner')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId('owner-picker-expand'));
|
||||
[
|
||||
'some-owner-batch-2',
|
||||
'Some Owner Batch 2',
|
||||
'Another Owner in Another Namespace Batch 2',
|
||||
].forEach(owner => {
|
||||
expect(screen.getByText(owner)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getAllByRole('option').map(o => o.textContent)).toEqual([
|
||||
'Another Owner',
|
||||
'Another Owner in Another Namespace',
|
||||
'some-owner',
|
||||
'Some Owner 2',
|
||||
]);
|
||||
expect(mockedQueryEntities).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('respects the query parameter filter value', async () => {
|
||||
@@ -186,8 +201,6 @@ describe('<EntityOwnerPicker/>', () => {
|
||||
<ApiProvider apis={mockApis}>
|
||||
<MockEntityListContextProvider
|
||||
value={{
|
||||
entities: sampleEntities,
|
||||
backendEntities: sampleEntities,
|
||||
updateFilters,
|
||||
queryParameters,
|
||||
}}
|
||||
@@ -197,19 +210,77 @@ describe('<EntityOwnerPicker/>', () => {
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
expect(mockedGetEntitiesByRef).toHaveBeenCalledWith({
|
||||
entityRefs: ['another-owner'],
|
||||
});
|
||||
expect(updateFilters).toHaveBeenLastCalledWith({
|
||||
owners: new EntityOwnerFilter(['group:default/another-owner']),
|
||||
});
|
||||
});
|
||||
|
||||
it('should display the selected owners as humanized entities', async () => {
|
||||
const updateFilters = jest.fn();
|
||||
const queryParameters = { owners: ['another-owner'] };
|
||||
|
||||
mockedGetEntitiesByRef.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
metadata: {
|
||||
name: 'another-owner',
|
||||
title: 'Beautiful display name',
|
||||
namespace: 'default',
|
||||
},
|
||||
apiVersion: '1',
|
||||
kind: 'group',
|
||||
},
|
||||
],
|
||||
});
|
||||
await renderWithEffects(
|
||||
<ApiProvider apis={mockApis}>
|
||||
<MockEntityListContextProvider
|
||||
value={{
|
||||
updateFilters,
|
||||
queryParameters,
|
||||
}}
|
||||
>
|
||||
<EntityOwnerPicker />
|
||||
</MockEntityListContextProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'Beautiful display name',
|
||||
}),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(mockedGetEntitiesByRef).toHaveBeenCalledWith({
|
||||
entityRefs: ['another-owner'],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId('owner-picker-expand'));
|
||||
await waitFor(() => screen.getByText('Some Owner 2'));
|
||||
fireEvent.click(screen.getByText('Some Owner 2'));
|
||||
|
||||
expect(mockedGetEntitiesByRef).toHaveBeenCalledTimes(1);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'Some Owner 2',
|
||||
}),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('adds owners to filters', async () => {
|
||||
const updateFilters = jest.fn();
|
||||
await renderWithEffects(
|
||||
<ApiProvider apis={mockApis}>
|
||||
<MockEntityListContextProvider
|
||||
value={{
|
||||
entities: sampleEntities,
|
||||
backendEntities: sampleEntities,
|
||||
updateFilters,
|
||||
}}
|
||||
>
|
||||
@@ -217,11 +288,14 @@ describe('<EntityOwnerPicker/>', () => {
|
||||
</MockEntityListContextProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
expect(mockedGetEntitiesByRef).not.toHaveBeenCalled();
|
||||
expect(updateFilters).toHaveBeenLastCalledWith({
|
||||
owners: undefined,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId('owner-picker-expand'));
|
||||
await waitFor(() => screen.getByText('some-owner'));
|
||||
|
||||
fireEvent.click(screen.getByText('some-owner'));
|
||||
expect(updateFilters).toHaveBeenLastCalledWith({
|
||||
owners: new EntityOwnerFilter(['group:default/some-owner']),
|
||||
@@ -234,8 +308,6 @@ describe('<EntityOwnerPicker/>', () => {
|
||||
<ApiProvider apis={mockApis}>
|
||||
<MockEntityListContextProvider
|
||||
value={{
|
||||
entities: sampleEntities,
|
||||
backendEntities: sampleEntities,
|
||||
updateFilters,
|
||||
filters: { owners: new EntityOwnerFilter(['some-owner']) },
|
||||
}}
|
||||
@@ -244,12 +316,17 @@ describe('<EntityOwnerPicker/>', () => {
|
||||
</MockEntityListContextProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
expect(mockedGetEntitiesByRef).toHaveBeenCalledWith({
|
||||
entityRefs: ['group:default/some-owner'],
|
||||
});
|
||||
expect(updateFilters).toHaveBeenLastCalledWith({
|
||||
owners: new EntityOwnerFilter(['group:default/some-owner']),
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('owner-picker-expand'));
|
||||
|
||||
expect(screen.getByLabelText('some-owner')).toBeChecked();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText('some-owner')).toBeChecked(),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('some-owner'));
|
||||
expect(updateFilters).toHaveBeenLastCalledWith({
|
||||
@@ -265,13 +342,15 @@ describe('<EntityOwnerPicker/>', () => {
|
||||
value={{
|
||||
updateFilters,
|
||||
queryParameters: { owners: ['team-a'] },
|
||||
backendEntities: sampleEntities,
|
||||
}}
|
||||
>
|
||||
<EntityOwnerPicker />
|
||||
</MockEntityListContextProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
expect(mockedGetEntitiesByRef).toHaveBeenCalledWith({
|
||||
entityRefs: ['team-a'],
|
||||
});
|
||||
expect(updateFilters).toHaveBeenLastCalledWith({
|
||||
owners: new EntityOwnerFilter(['group:default/team-a']),
|
||||
});
|
||||
@@ -281,7 +360,6 @@ describe('<EntityOwnerPicker/>', () => {
|
||||
value={{
|
||||
updateFilters,
|
||||
queryParameters: { owners: ['team-b'] },
|
||||
backendEntities: sampleEntities,
|
||||
}}
|
||||
>
|
||||
<EntityOwnerPicker />
|
||||
@@ -292,23 +370,4 @@ describe('<EntityOwnerPicker/>', () => {
|
||||
owners: new EntityOwnerFilter(['group:default/team-b']),
|
||||
});
|
||||
});
|
||||
it('removes owners from filters if there are none available', async () => {
|
||||
const updateFilters = jest.fn();
|
||||
await renderWithEffects(
|
||||
<ApiProvider apis={mockApis}>
|
||||
<MockEntityListContextProvider
|
||||
value={{
|
||||
updateFilters,
|
||||
queryParameters: { owners: ['team-a'] },
|
||||
backendEntities: [],
|
||||
}}
|
||||
>
|
||||
<EntityOwnerPicker />
|
||||
</MockEntityListContextProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
expect(updateFilters).toHaveBeenLastCalledWith({
|
||||
owners: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,32 +14,30 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
parseEntityRef,
|
||||
RELATION_OWNED_BY,
|
||||
stringifyEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import {
|
||||
Box,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
makeStyles,
|
||||
TextField,
|
||||
Typography,
|
||||
makeStyles,
|
||||
} from '@material-ui/core';
|
||||
import CheckBoxIcon from '@material-ui/icons/CheckBox';
|
||||
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank';
|
||||
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
|
||||
import { Autocomplete } from '@material-ui/lab';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEntityList } from '../../hooks/useEntityListProvider';
|
||||
import { EntityOwnerFilter } from '../../filters';
|
||||
import { getEntityRelations } from '../../utils';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
import { errorApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { catalogApiRef } from '../../api';
|
||||
import { humanizeEntity, humanizeEntityRef } from '../EntityRefLink/humanize';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
import useAsyncFn from 'react-use/lib/useAsyncFn';
|
||||
import { useDebouncedEffect } from '@react-hookz/web';
|
||||
import PersonIcon from '@material-ui/icons/Person';
|
||||
import GroupIcon from '@material-ui/icons/Group';
|
||||
import { humanizeEntity } from '../EntityRefLink/humanize';
|
||||
|
||||
/** @public */
|
||||
export type CatalogReactEntityOwnerPickerClassKey = 'input';
|
||||
@@ -61,12 +59,51 @@ export const EntityOwnerPicker = () => {
|
||||
const classes = useStyles();
|
||||
const {
|
||||
updateFilters,
|
||||
backendEntities,
|
||||
filters,
|
||||
queryParameters: { owners: ownersParameter },
|
||||
} = useEntityList();
|
||||
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const errorApi = useApi(errorApiRef);
|
||||
const [text, setText] = useState('');
|
||||
|
||||
const [{ value, loading }, handleFetch] = useAsyncFn(
|
||||
async (request: { text: string } | { cursor: string; prev: Entity[] }) => {
|
||||
const initialRequest = request as { text: string };
|
||||
const cursorRequest = request as { cursor: string; prev: Entity[] };
|
||||
const limit = 20;
|
||||
|
||||
if (cursorRequest.cursor) {
|
||||
const response = await catalogApi.queryEntities({
|
||||
cursor: cursorRequest.cursor,
|
||||
limit,
|
||||
});
|
||||
return {
|
||||
...response,
|
||||
items: [...cursorRequest.prev, ...response.items],
|
||||
};
|
||||
}
|
||||
|
||||
return catalogApi.queryEntities({
|
||||
fullTextFilter: {
|
||||
term: initialRequest.text || '',
|
||||
fields: [
|
||||
'metadata.name',
|
||||
'kind',
|
||||
'spec.profile.displayname',
|
||||
'metadata.title',
|
||||
],
|
||||
},
|
||||
filter: { kind: ['User', 'Group'] },
|
||||
orderFields: [{ field: 'metadata.name', order: 'asc' }],
|
||||
limit,
|
||||
});
|
||||
},
|
||||
[text],
|
||||
);
|
||||
|
||||
useDebouncedEffect(() => handleFetch({ text }), [text], 250);
|
||||
|
||||
const availableOwners = value?.items || [];
|
||||
|
||||
const queryParamOwners = useMemo(
|
||||
() => [ownersParameter].flat().filter(Boolean) as string[],
|
||||
@@ -74,71 +111,10 @@ export const EntityOwnerPicker = () => {
|
||||
);
|
||||
|
||||
const [selectedOwners, setSelectedOwners] = useState(
|
||||
queryParamOwners.length
|
||||
? new EntityOwnerFilter(queryParamOwners).values
|
||||
: filters.owners?.values ?? [],
|
||||
queryParamOwners.length ? queryParamOwners : filters.owners?.values ?? [],
|
||||
);
|
||||
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
value: ownerEntities,
|
||||
} = useAsync(async () => {
|
||||
const ownerEntityRefs = [
|
||||
...new Set(
|
||||
backendEntities
|
||||
.flatMap((e: Entity) =>
|
||||
getEntityRelations(e, RELATION_OWNED_BY).map(o =>
|
||||
stringifyEntityRef(o),
|
||||
),
|
||||
)
|
||||
.filter(Boolean) as string[],
|
||||
),
|
||||
];
|
||||
const { items: ownerEntitiesOrNull } = await catalogApi.getEntitiesByRefs({
|
||||
entityRefs: ownerEntityRefs,
|
||||
fields: [
|
||||
'kind',
|
||||
'metadata.name',
|
||||
'metadata.title',
|
||||
'metadata.namespace',
|
||||
'spec.profile.displayName',
|
||||
],
|
||||
});
|
||||
const owners = ownerEntitiesOrNull.map((entity, index) => {
|
||||
if (entity) {
|
||||
return {
|
||||
label: humanizeEntity(entity, { defaultKind: 'Group' }),
|
||||
entityRef: stringifyEntityRef(entity),
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: humanizeEntityRef(parseEntityRef(ownerEntityRefs[index]), {
|
||||
defaultKind: 'group',
|
||||
}),
|
||||
entityRef: ownerEntityRefs[index],
|
||||
};
|
||||
});
|
||||
|
||||
return owners.sort((a, b) =>
|
||||
a.label.localeCompare(b.label, 'en-US', {
|
||||
ignorePunctuation: true,
|
||||
caseFirst: 'upper',
|
||||
}),
|
||||
);
|
||||
}, [backendEntities]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
errorApi.post(
|
||||
{
|
||||
...error,
|
||||
message: `EntityOwnerPicker failed to initialize: ${error.message}`,
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
}, [error, errorApi]);
|
||||
const { getEntity, setEntity } = useSelectedOwners(selectedOwners);
|
||||
|
||||
// Set selected owners on query parameter updates; this happens at initial page load and from
|
||||
// external updates to the page location.
|
||||
@@ -150,17 +126,20 @@ export const EntityOwnerPicker = () => {
|
||||
}, [queryParamOwners]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && ownerEntities) {
|
||||
updateFilters({
|
||||
owners:
|
||||
selectedOwners.length && ownerEntities.length
|
||||
? new EntityOwnerFilter(selectedOwners)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}, [selectedOwners, updateFilters, ownerEntities, loading]);
|
||||
updateFilters({
|
||||
owners: selectedOwners.length
|
||||
? new EntityOwnerFilter(selectedOwners)
|
||||
: undefined,
|
||||
});
|
||||
}, [selectedOwners, updateFilters]);
|
||||
|
||||
if (!loading && !ownerEntities?.length) return null;
|
||||
if (
|
||||
['user', 'group'].includes(
|
||||
filters.kind?.value.toLocaleLowerCase('en-US') || '',
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box pb={1} pt={1}>
|
||||
@@ -170,40 +149,130 @@ export const EntityOwnerPicker = () => {
|
||||
multiple
|
||||
disableCloseOnSelect
|
||||
loading={loading}
|
||||
options={ownerEntities || []}
|
||||
value={
|
||||
ownerEntities?.filter(e =>
|
||||
selectedOwners.some((f: string) => f === e.entityRef),
|
||||
) ?? []
|
||||
}
|
||||
onChange={(_: object, value: { entityRef: string }[]) =>
|
||||
setSelectedOwners(value.map(e => e.entityRef))
|
||||
}
|
||||
getOptionLabel={option => option.label}
|
||||
renderOption={(option, { selected }) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
icon={icon}
|
||||
checkedIcon={checkedIcon}
|
||||
checked={selected}
|
||||
/>
|
||||
}
|
||||
onClick={event => event.preventDefault()}
|
||||
label={option.label}
|
||||
/>
|
||||
)}
|
||||
options={availableOwners}
|
||||
value={selectedOwners as unknown as Entity[]}
|
||||
getOptionSelected={(o, v) => {
|
||||
if (typeof v === 'string') {
|
||||
return stringifyEntityRef(o) === v;
|
||||
}
|
||||
return o === v;
|
||||
}}
|
||||
getOptionLabel={o => {
|
||||
const entity = typeof o === 'string' ? getEntity(o) || o : o;
|
||||
|
||||
return typeof entity === 'string'
|
||||
? entity
|
||||
: humanizeEntity(entity, entity.metadata.name);
|
||||
}}
|
||||
onChange={(_: object, owners) => {
|
||||
setText('');
|
||||
setSelectedOwners(
|
||||
owners.map(e => {
|
||||
const entityRef =
|
||||
typeof e === 'string' ? e : stringifyEntityRef(e);
|
||||
|
||||
if (typeof e !== 'string') {
|
||||
setEntity(e);
|
||||
}
|
||||
|
||||
return entityRef;
|
||||
}),
|
||||
);
|
||||
}}
|
||||
filterOptions={x => x}
|
||||
renderOption={(entity, { selected }) => {
|
||||
const isGroup = entity.kind === 'Group';
|
||||
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
icon={icon}
|
||||
checkedIcon={checkedIcon}
|
||||
checked={selected}
|
||||
/>
|
||||
}
|
||||
onClick={event => event.preventDefault()}
|
||||
label={
|
||||
<Box display="flex" flexWrap="wrap" alignItems="center">
|
||||
{isGroup ? (
|
||||
<GroupIcon fontSize="small" />
|
||||
) : (
|
||||
<PersonIcon fontSize="small" />
|
||||
)}
|
||||
|
||||
{humanizeEntity(entity, entity.metadata.name)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
size="small"
|
||||
popupIcon={<ExpandMoreIcon data-testid="owner-picker-expand" />}
|
||||
renderInput={params => (
|
||||
<TextField
|
||||
{...params}
|
||||
className={classes.input}
|
||||
onChange={e => {
|
||||
setText(e.currentTarget.value);
|
||||
}}
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
ListboxProps={{
|
||||
onScroll: (e: React.MouseEvent) => {
|
||||
const element = e.currentTarget;
|
||||
const hasReachedEnd =
|
||||
Math.abs(
|
||||
element.scrollHeight -
|
||||
element.clientHeight -
|
||||
element.scrollTop,
|
||||
) < 1;
|
||||
|
||||
if (hasReachedEnd && value?.pageInfo.nextCursor) {
|
||||
handleFetch({
|
||||
cursor: value.pageInfo.nextCursor,
|
||||
prev: value.items,
|
||||
});
|
||||
}
|
||||
},
|
||||
'data-testid': 'owner-picker-listbox',
|
||||
}}
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook used for storing the full entity of the specified owners
|
||||
* in order to display users and group using the information contained on each entity.
|
||||
* When a component is rendered for the first time, it loads the content of the entities
|
||||
* specified by `initialSelectedOwnersRefs` and export the `getEntity` and `setEntity`
|
||||
* utilities, used to retrieve and modify the owners.
|
||||
*/
|
||||
function useSelectedOwners(initialSelectedOwnersRefs: string[]) {
|
||||
const allEntities = useRef<Record<string, Entity>>({});
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
|
||||
useAsync(async () => {
|
||||
if (initialSelectedOwnersRefs.length === 0) {
|
||||
return;
|
||||
}
|
||||
const initialSelectedEntities = await catalogApi.getEntitiesByRefs({
|
||||
entityRefs: initialSelectedOwnersRefs,
|
||||
});
|
||||
initialSelectedEntities.items.forEach(e => {
|
||||
if (e) {
|
||||
allEntities.current[stringifyEntityRef(e)] = e;
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
getEntity: (entityRef: string) => allEntities.current[entityRef],
|
||||
setEntity: (entity: Entity) => {
|
||||
allEntities.current[stringifyEntityRef(entity)] = entity;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -215,9 +215,12 @@ describe('humanizeEntityRef', () => {
|
||||
describe('humanizeEntity', () => {
|
||||
it('gives a readable name when one is provided at metadata.title', () => {
|
||||
expect(
|
||||
humanizeEntity({
|
||||
metadata: { name: 'my-entity', title: 'My Title' },
|
||||
} as Entity),
|
||||
humanizeEntity(
|
||||
{
|
||||
metadata: { name: 'my-entity', title: 'My Title' },
|
||||
} as Entity,
|
||||
'default',
|
||||
),
|
||||
).toBe('My Title');
|
||||
});
|
||||
|
||||
@@ -257,13 +260,16 @@ describe('humanizeEntity', () => {
|
||||
])(
|
||||
'gives a readable name for kind %s when one is provided at spec.profile.displayName',
|
||||
(_, entity: Entity, expected) => {
|
||||
expect(humanizeEntity(entity)).toBe(expected);
|
||||
expect(humanizeEntity(entity, 'default')).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it('should pass through to humanizeEntityRef when nothing matches', () => {
|
||||
expect(
|
||||
humanizeEntity({ kind: 'Group', metadata: { name: 'test' } } as Entity),
|
||||
).toBe('group:test');
|
||||
humanizeEntity(
|
||||
{ kind: 'Group', metadata: { name: 'test' } } as Entity,
|
||||
'default',
|
||||
),
|
||||
).toBe('default');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,8 @@ import get from 'lodash/get';
|
||||
* @param defaultNamespace - if set to false then namespace is never omitted,
|
||||
* if set to string which matches namespace of entity then omitted
|
||||
*
|
||||
* @public */
|
||||
* @public
|
||||
**/
|
||||
export function humanizeEntityRef(
|
||||
entityRef: Entity | CompoundEntityRef,
|
||||
opts?: {
|
||||
@@ -73,27 +74,19 @@ export function humanizeEntityRef(
|
||||
* If an entity is either User or Group, this will be its `spec.profile.displayName`.
|
||||
* Otherwise, this is `metadata.title`.
|
||||
*
|
||||
* If neither of those are found or populated, fallback to `humanizeEntityRef`.
|
||||
* If neither of those are found or populated, fallback to `defaultName`.
|
||||
*
|
||||
* @param entity - Entity to convert.
|
||||
* @param opts - If entity readable name is not available, opts will be used to specify humanizeEntityRef options.
|
||||
* @returns Readable name, defaults to unique identifier.
|
||||
* @param defaultName - If entity readable name is not available, `defaultName` will be returned.
|
||||
* @returns Readable name, defaults to `defaultName`.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function humanizeEntity(
|
||||
entity: Entity,
|
||||
opts?: {
|
||||
defaultKind?: string;
|
||||
defaultNamespace?: string | false;
|
||||
},
|
||||
) {
|
||||
export function humanizeEntity(entity: Entity, defaultName: string) {
|
||||
for (const path of ['spec.profile.displayName', 'metadata.title']) {
|
||||
const value = get(entity, path);
|
||||
if (value && typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return humanizeEntityRef(entity, opts);
|
||||
return defaultName;
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"@types/ping": "^0.4.1",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"@types/yarnpkg__lockfile": "^1.1.4",
|
||||
"msw": "^0.47.0",
|
||||
"msw": "^1.0.0",
|
||||
"supertest": "^6.2.4"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"@testing-library/user-event": "^14.0.0",
|
||||
"@types/node": "*",
|
||||
"cross-fetch": "^3.1.5",
|
||||
"msw": "^0.47.0"
|
||||
"msw": "^1.0.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"@backstage/plugin-auth-node": "workspace:^",
|
||||
"@backstage/plugin-jenkins-common": "workspace:^",
|
||||
"@backstage/plugin-permission-common": "workspace:^",
|
||||
"@backstage/plugin-permission-node": "workspace:^",
|
||||
"@types/express": "^4.17.6",
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^4.1.0",
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node';
|
||||
import { stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import { stringifyError } from '@backstage/errors';
|
||||
import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node';
|
||||
import { jenkinsPermissions } from '@backstage/plugin-jenkins-common';
|
||||
|
||||
/** @public */
|
||||
export interface RouterOptions {
|
||||
@@ -58,6 +60,11 @@ export async function createRouter(
|
||||
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
router.use(
|
||||
createPermissionIntegrationRouter({
|
||||
permissions: jenkinsPermissions,
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/v1/entity/:namespace/:kind/:name/projects',
|
||||
|
||||
@@ -8,5 +8,8 @@ import { ResourcePermission } from '@backstage/plugin-permission-common';
|
||||
// @public
|
||||
export const jenkinsExecutePermission: ResourcePermission<'catalog-entity'>;
|
||||
|
||||
// @public
|
||||
export const jenkinsPermissions: ResourcePermission<'catalog-entity'>[];
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
|
||||
@@ -28,3 +28,10 @@ export const jenkinsExecutePermission = createPermission({
|
||||
},
|
||||
resourceType: RESOURCE_TYPE_CATALOG_ENTITY,
|
||||
});
|
||||
|
||||
/**
|
||||
* List of all Jenkins permissions
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const jenkinsPermissions = [jenkinsExecutePermission];
|
||||
|
||||
@@ -22,6 +22,7 @@ import { OAuthApi } from '@backstage/core-plugin-api';
|
||||
import { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common';
|
||||
import { OpenIdConnectApi } from '@backstage/core-plugin-api';
|
||||
import { Pod } from 'kubernetes-models/v1';
|
||||
import { Pod as Pod_2 } from 'kubernetes-models/v1/Pod';
|
||||
import { default as React_2 } from 'react';
|
||||
import { RouteRef } from '@backstage/core-plugin-api';
|
||||
import { V1ConfigMap } from '@kubernetes/client-node';
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"@backstage/plugin-kubernetes-common": "workspace:^",
|
||||
"@backstage/theme": "workspace:^",
|
||||
"@kubernetes-models/apimachinery": "^1.1.0",
|
||||
"@kubernetes-models/base": "^4.0.1",
|
||||
"@kubernetes/client-node": "0.18.1",
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
|
||||
@@ -24,6 +24,7 @@ import { Cluster } from './Cluster';
|
||||
import EmptyStateImage from '../assets/emptystate.svg';
|
||||
import { useKubernetesObjects } from '../hooks';
|
||||
import { Content, Page, Progress } from '@backstage/core-components';
|
||||
import { DetectedErrorsContext } from '../hooks/useMatchingErrors';
|
||||
|
||||
type KubernetesContentProps = {
|
||||
entity: Entity;
|
||||
@@ -49,88 +50,92 @@ export const KubernetesContent = ({
|
||||
: new Map<string, DetectedError[]>();
|
||||
|
||||
return (
|
||||
<Page themeId="tool">
|
||||
<Content>
|
||||
{kubernetesObjects === undefined && error === undefined && <Progress />}
|
||||
<DetectedErrorsContext.Provider value={[...detectedErrors.values()].flat()}>
|
||||
<Page themeId="tool">
|
||||
<Content>
|
||||
{kubernetesObjects === undefined && error === undefined && (
|
||||
<Progress />
|
||||
)}
|
||||
|
||||
{/* errors retrieved from the kubernetes clusters */}
|
||||
{clustersWithErrors.length > 0 && (
|
||||
<Grid container spacing={3} direction="column">
|
||||
<Grid item>
|
||||
<ErrorPanel
|
||||
entityName={entity.metadata.name}
|
||||
clustersWithErrors={clustersWithErrors}
|
||||
/>
|
||||
{/* errors retrieved from the kubernetes clusters */}
|
||||
{clustersWithErrors.length > 0 && (
|
||||
<Grid container spacing={3} direction="column">
|
||||
<Grid item>
|
||||
<ErrorPanel
|
||||
entityName={entity.metadata.name}
|
||||
clustersWithErrors={clustersWithErrors}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* other errors */}
|
||||
{error !== undefined && (
|
||||
<Grid container spacing={3} direction="column">
|
||||
<Grid item>
|
||||
<ErrorPanel
|
||||
entityName={entity.metadata.name}
|
||||
errorMessage={error}
|
||||
/>
|
||||
{/* other errors */}
|
||||
{error !== undefined && (
|
||||
<Grid container spacing={3} direction="column">
|
||||
<Grid item>
|
||||
<ErrorPanel
|
||||
entityName={entity.metadata.name}
|
||||
errorMessage={error}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
)}
|
||||
|
||||
{kubernetesObjects && (
|
||||
<Grid container spacing={3} direction="column">
|
||||
<Grid item>
|
||||
<ErrorReporting detectedErrors={detectedErrors} />
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Typography variant="h3">Your Clusters</Typography>
|
||||
</Grid>
|
||||
<Grid item container>
|
||||
{kubernetesObjects?.items.length <= 0 && (
|
||||
<Grid
|
||||
container
|
||||
justifyContent="space-around"
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={2}
|
||||
>
|
||||
<Grid item xs={4}>
|
||||
<Typography variant="h5">
|
||||
No resources on any known clusters for{' '}
|
||||
{entity.metadata.name}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<img
|
||||
src={EmptyStateImage}
|
||||
alt="EmptyState"
|
||||
data-testid="emptyStateImg"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
{kubernetesObjects?.items.length > 0 &&
|
||||
kubernetesObjects?.items.map((item, i) => {
|
||||
const podsWithErrors = new Set<string>(
|
||||
detectedErrors
|
||||
.get(item.cluster.name)
|
||||
?.filter(de => de.sourceRef.kind === 'Pod')
|
||||
.map(de => de.sourceRef.name),
|
||||
);
|
||||
|
||||
return (
|
||||
<Grid item key={i} xs={12}>
|
||||
<Cluster
|
||||
clusterObjects={item}
|
||||
podsWithErrors={podsWithErrors}
|
||||
{kubernetesObjects && (
|
||||
<Grid container spacing={3} direction="column">
|
||||
<Grid item>
|
||||
<ErrorReporting detectedErrors={detectedErrors} />
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Typography variant="h3">Your Clusters</Typography>
|
||||
</Grid>
|
||||
<Grid item container>
|
||||
{kubernetesObjects?.items.length <= 0 && (
|
||||
<Grid
|
||||
container
|
||||
justifyContent="space-around"
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={2}
|
||||
>
|
||||
<Grid item xs={4}>
|
||||
<Typography variant="h5">
|
||||
No resources on any known clusters for{' '}
|
||||
{entity.metadata.name}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<img
|
||||
src={EmptyStateImage}
|
||||
alt="EmptyState"
|
||||
data-testid="emptyStateImg"
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
)}
|
||||
{kubernetesObjects?.items.length > 0 &&
|
||||
kubernetesObjects?.items.map((item, i) => {
|
||||
const podsWithErrors = new Set<string>(
|
||||
detectedErrors
|
||||
.get(item.cluster.name)
|
||||
?.filter(de => de.sourceRef.kind === 'Pod')
|
||||
.map(de => de.sourceRef.name),
|
||||
);
|
||||
|
||||
return (
|
||||
<Grid item key={i} xs={12}>
|
||||
<Cluster
|
||||
clusterObjects={item}
|
||||
podsWithErrors={podsWithErrors}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</Content>
|
||||
</Page>
|
||||
)}
|
||||
</Content>
|
||||
</Page>
|
||||
</DetectedErrorsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import { ErrorList } from './ErrorList';
|
||||
import { Pod } from 'kubernetes-models/v1/Pod';
|
||||
|
||||
describe('ErrorList', () => {
|
||||
it('error highlight should render', () => {
|
||||
const { getByText } = render(
|
||||
<ErrorList
|
||||
podAndErrors={[
|
||||
{
|
||||
clusterName: 'some-cluster',
|
||||
pod: {
|
||||
metadata: {
|
||||
name: 'some-pod',
|
||||
namespace: 'some-namespace',
|
||||
},
|
||||
} as Pod,
|
||||
errors: [
|
||||
{
|
||||
type: 'some-error',
|
||||
severity: 10,
|
||||
message: 'some error message',
|
||||
occuranceCount: 1,
|
||||
sourceRef: {
|
||||
name: 'some-pod',
|
||||
namespace: 'some-namespace',
|
||||
kind: 'Pod',
|
||||
apiGroup: 'v1',
|
||||
},
|
||||
proposedFix: [
|
||||
{
|
||||
type: 'logs',
|
||||
container: 'some-container',
|
||||
errorType: 'some error type',
|
||||
rootCauseExplanation: 'some root cause',
|
||||
possibleFixes: ['fix1', 'fix2'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(getByText('some-pod')).toBeInTheDocument();
|
||||
expect(getByText('some error message')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Divider,
|
||||
createStyles,
|
||||
makeStyles,
|
||||
Theme,
|
||||
Paper,
|
||||
} from '@material-ui/core';
|
||||
import { PodAndErrors } from '../types';
|
||||
|
||||
const useStyles = makeStyles((_theme: Theme) =>
|
||||
createStyles({
|
||||
root: {
|
||||
overflow: 'auto',
|
||||
},
|
||||
list: {
|
||||
width: '100%',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
interface ErrorListProps {
|
||||
podAndErrors: PodAndErrors[];
|
||||
}
|
||||
|
||||
export const ErrorList = ({ podAndErrors }: ErrorListProps) => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<Paper className={classes.root}>
|
||||
<List className={classes.list}>
|
||||
{podAndErrors
|
||||
.filter(pae => pae.errors.length > 0)
|
||||
.flatMap(onlyPodWithErrors => {
|
||||
return onlyPodWithErrors.errors.map((error, i) => {
|
||||
return (
|
||||
<React.Fragment
|
||||
key={`${
|
||||
onlyPodWithErrors.pod.metadata?.name ?? 'unknown'
|
||||
}-eli-${i}`}
|
||||
>
|
||||
{i > 0 && <Divider key={`error-divider${i}`} />}
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={error.message}
|
||||
secondary={onlyPodWithErrors.pod.metadata?.name}
|
||||
/>
|
||||
</ListItem>
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
})}
|
||||
</List>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export * from './ErrorList';
|
||||
@@ -33,7 +33,7 @@ describe('PodDrawer', () => {
|
||||
clusterName: 'some-cluster-1',
|
||||
pod: {
|
||||
metadata: {
|
||||
name: 'ok-pod',
|
||||
name: 'some-pod',
|
||||
},
|
||||
spec: {
|
||||
containers: [
|
||||
@@ -51,17 +51,40 @@ describe('PodDrawer', () => {
|
||||
],
|
||||
},
|
||||
},
|
||||
errors: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'some-error',
|
||||
severity: 10,
|
||||
message: 'some error message',
|
||||
occuranceCount: 1,
|
||||
sourceRef: {
|
||||
name: 'some-pod',
|
||||
namespace: 'some-namespace',
|
||||
kind: 'Pod',
|
||||
apiGroup: 'v1',
|
||||
},
|
||||
proposedFix: [
|
||||
{
|
||||
type: 'logs',
|
||||
container: 'some-container',
|
||||
errorType: 'some error type',
|
||||
rootCauseExplanation: 'some root cause',
|
||||
possibleFixes: ['fix1', 'fix2'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(getAllByText('ok-pod')).toHaveLength(2);
|
||||
expect(getAllByText('some-pod')).toHaveLength(3);
|
||||
expect(getByText('Pod (127.0.0.1)')).toBeInTheDocument();
|
||||
expect(getByText('YAML')).toBeInTheDocument();
|
||||
expect(getByText('Containers')).toBeInTheDocument();
|
||||
expect(getByText('some-container')).toBeInTheDocument();
|
||||
expect(getByText('some error message')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ import { ContainerCard } from './ContainerCard';
|
||||
import { PodAndErrors } from '../types';
|
||||
import { KubernetesDrawer } from '../../KubernetesDrawer';
|
||||
import { PendingPodContent } from './PendingPodContent';
|
||||
import { ErrorList } from '../ErrorList';
|
||||
|
||||
const useDrawerContentStyles = makeStyles((_theme: Theme) =>
|
||||
createStyles({
|
||||
@@ -116,6 +117,16 @@ export const PodDrawer = ({ podAndErrors, open }: PodDrawerProps) => {
|
||||
)}
|
||||
</ItemCardGrid>
|
||||
</Grid>
|
||||
{podAndErrors.errors.length > 0 && (
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h5">Errors:</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
{podAndErrors.errors.length > 0 && (
|
||||
<Grid item xs={12}>
|
||||
<ErrorList podAndErrors={[podAndErrors]} />
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -13,11 +13,11 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { kubernetesProxyApiRef } from '@backstage/plugin-kubernetes';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
|
||||
import { ContainerScope } from './types';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { kubernetesProxyApiRef } from '../../../api';
|
||||
|
||||
interface PodLogsOptions {
|
||||
podScope: ContainerScope;
|
||||
|
||||
+33
-22
@@ -15,7 +15,6 @@
|
||||
*/
|
||||
|
||||
import React, { useContext } from 'react';
|
||||
import { V1Pod } from '@kubernetes/client-node';
|
||||
import { PodDrawer } from './PodDrawer';
|
||||
import {
|
||||
containersReady,
|
||||
@@ -27,18 +26,21 @@ import {
|
||||
import { Table, TableColumn } from '@backstage/core-components';
|
||||
import { PodNamesWithMetricsContext } from '../../hooks/PodNamesWithMetrics';
|
||||
import { ClusterContext } from '../../hooks/Cluster';
|
||||
import { useMatchingErrors } from '../../hooks/useMatchingErrors';
|
||||
import { Pod } from 'kubernetes-models/v1/Pod';
|
||||
import { V1Pod } from '@kubernetes/client-node';
|
||||
|
||||
export const READY_COLUMNS: PodColumns = 'READY';
|
||||
export const RESOURCE_COLUMNS: PodColumns = 'RESOURCE';
|
||||
export type PodColumns = 'READY' | 'RESOURCE';
|
||||
|
||||
type PodsTablesProps = {
|
||||
pods: V1Pod[];
|
||||
pods: Pod | V1Pod[];
|
||||
extraColumns?: PodColumns[];
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const READY: TableColumn<V1Pod>[] = [
|
||||
const READY: TableColumn<Pod>[] = [
|
||||
{
|
||||
title: 'containers ready',
|
||||
align: 'center',
|
||||
@@ -54,26 +56,37 @@ const READY: TableColumn<V1Pod>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const PodDrawerTrigger = ({ pod }: { pod: Pod }) => {
|
||||
const cluster = useContext(ClusterContext);
|
||||
const errors = useMatchingErrors({
|
||||
kind: 'Pod',
|
||||
apiVersion: 'v1',
|
||||
metadata: pod.metadata,
|
||||
});
|
||||
return (
|
||||
<PodDrawer
|
||||
podAndErrors={{
|
||||
pod: pod as any,
|
||||
clusterName: cluster.name,
|
||||
errors: errors,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const PodsTable = ({ pods, extraColumns = [] }: PodsTablesProps) => {
|
||||
const podNamesWithMetrics = useContext(PodNamesWithMetricsContext);
|
||||
const cluster = useContext(ClusterContext);
|
||||
const defaultColumns: TableColumn<V1Pod>[] = [
|
||||
const defaultColumns: TableColumn<Pod>[] = [
|
||||
{
|
||||
title: 'name',
|
||||
highlight: true,
|
||||
render: (pod: V1Pod) => (
|
||||
<PodDrawer
|
||||
podAndErrors={{
|
||||
pod: pod as any,
|
||||
clusterName: cluster.name,
|
||||
errors: [],
|
||||
}}
|
||||
/>
|
||||
),
|
||||
render: (pod: Pod) => {
|
||||
return <PodDrawerTrigger pod={pod} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'phase',
|
||||
render: (pod: V1Pod) => pod.status?.phase ?? 'unknown',
|
||||
render: (pod: Pod) => pod.status?.phase ?? 'unknown',
|
||||
width: 'auto',
|
||||
},
|
||||
{
|
||||
@@ -81,16 +94,16 @@ export const PodsTable = ({ pods, extraColumns = [] }: PodsTablesProps) => {
|
||||
render: containerStatuses,
|
||||
},
|
||||
];
|
||||
const columns: TableColumn<V1Pod>[] = [...defaultColumns];
|
||||
const columns: TableColumn<Pod>[] = [...defaultColumns];
|
||||
|
||||
if (extraColumns.includes(READY_COLUMNS)) {
|
||||
columns.push(...READY);
|
||||
}
|
||||
if (extraColumns.includes(RESOURCE_COLUMNS)) {
|
||||
const resourceColumns: TableColumn<V1Pod>[] = [
|
||||
const resourceColumns: TableColumn<Pod>[] = [
|
||||
{
|
||||
title: 'CPU usage %',
|
||||
render: (pod: V1Pod) => {
|
||||
render: (pod: Pod) => {
|
||||
const metrics = podNamesWithMetrics.get(pod.metadata?.name ?? '');
|
||||
|
||||
if (!metrics) {
|
||||
@@ -103,7 +116,7 @@ export const PodsTable = ({ pods, extraColumns = [] }: PodsTablesProps) => {
|
||||
},
|
||||
{
|
||||
title: 'Memory usage %',
|
||||
render: (pod: V1Pod) => {
|
||||
render: (pod: Pod) => {
|
||||
const metrics = podNamesWithMetrics.get(pod.metadata?.name ?? '');
|
||||
|
||||
if (!metrics) {
|
||||
@@ -123,13 +136,11 @@ export const PodsTable = ({ pods, extraColumns = [] }: PodsTablesProps) => {
|
||||
width: '100%',
|
||||
};
|
||||
|
||||
const usePods = pods.map(p => ({ ...p, id: p.metadata?.uid }));
|
||||
|
||||
return (
|
||||
<div style={tableStyle}>
|
||||
<Table
|
||||
options={{ paging: true, search: false, emptyRowsWhenPaging: false }}
|
||||
data={usePods}
|
||||
data={pods as Pod[]}
|
||||
columns={columns}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+2
-1
@@ -14,9 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { Pod } from 'kubernetes-models/v1';
|
||||
import { DetectedError } from '../../error-detection';
|
||||
|
||||
export interface PodAndErrors {
|
||||
clusterName: string;
|
||||
pod: Pod;
|
||||
errors: any[];
|
||||
errors: DetectedError[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { DetectedErrorsContext, useMatchingErrors } from './useMatchingErrors';
|
||||
import { DetectedError } from '../error-detection';
|
||||
import { ResourceRef } from '../error-detection/types';
|
||||
|
||||
const genericErrorWithRef = (resourceRef: ResourceRef): DetectedError => {
|
||||
return {
|
||||
type: 'some-error',
|
||||
severity: 10,
|
||||
message: 'some error message',
|
||||
occuranceCount: 1,
|
||||
sourceRef: resourceRef,
|
||||
proposedFix: [
|
||||
{
|
||||
type: 'logs',
|
||||
container: 'some-container',
|
||||
errorType: 'some error type',
|
||||
rootCauseExplanation: 'some root cause',
|
||||
possibleFixes: ['fix1', 'fix2'],
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
describe('useMatchingErrors', () => {
|
||||
it('should filter non-matching resources', () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<DetectedErrorsContext.Provider
|
||||
value={[
|
||||
genericErrorWithRef({
|
||||
name: 'some-other-pod',
|
||||
namespace: 'some-namespace',
|
||||
kind: 'some-kind',
|
||||
apiGroup: 'some-apiGroup',
|
||||
}),
|
||||
genericErrorWithRef({
|
||||
name: 'some-name',
|
||||
namespace: 'some-namespace',
|
||||
kind: 'some-kind',
|
||||
apiGroup: 'some-apiGroup',
|
||||
}),
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</DetectedErrorsContext.Provider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useMatchingErrors({
|
||||
metadata: {
|
||||
name: 'some-name',
|
||||
namespace: 'some-namespace',
|
||||
},
|
||||
kind: 'some-kind',
|
||||
apiVersion: 'some-apiGroup',
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
expect(result.current).toStrictEqual([
|
||||
genericErrorWithRef({
|
||||
name: 'some-name',
|
||||
namespace: 'some-namespace',
|
||||
kind: 'some-kind',
|
||||
apiGroup: 'some-apiGroup',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React, { useContext } from 'react';
|
||||
import { DetectedError, ResourceRef } from '../error-detection/types';
|
||||
import { TypeMeta } from '@kubernetes-models/base';
|
||||
import { IIoK8sApimachineryPkgApisMetaV1ObjectMeta as V1ObjectMeta } from '@kubernetes-models/apimachinery/apis/meta/v1/ObjectMeta';
|
||||
|
||||
export const DetectedErrorsContext = React.createContext<DetectedError[]>([]);
|
||||
|
||||
type Matcher = {
|
||||
metadata?: V1ObjectMeta;
|
||||
} & TypeMeta;
|
||||
|
||||
export const useMatchingErrors = (matcher: Matcher): DetectedError[] => {
|
||||
const targetRef: ResourceRef = {
|
||||
name: matcher.metadata?.name ?? '',
|
||||
namespace: matcher.metadata?.namespace ?? '',
|
||||
kind: matcher.kind,
|
||||
apiGroup: matcher.apiVersion,
|
||||
};
|
||||
|
||||
const errors = useContext(DetectedErrorsContext);
|
||||
|
||||
return errors.filter(e => {
|
||||
const r = e.sourceRef;
|
||||
return (
|
||||
targetRef.apiGroup === r.apiGroup &&
|
||||
targetRef.kind === r.kind &&
|
||||
targetRef.name === r.name &&
|
||||
targetRef.namespace === r.namespace
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
SubvalueCell,
|
||||
} from '@backstage/core-components';
|
||||
import { ClientPodStatus } from '@backstage/plugin-kubernetes-common';
|
||||
import { Pod } from 'kubernetes-models/v1/Pod';
|
||||
|
||||
export const imageChips = (pod: V1Pod): ReactNode => {
|
||||
const containerStatuses = pod.status?.containerStatuses ?? [];
|
||||
@@ -38,19 +39,19 @@ export const imageChips = (pod: V1Pod): ReactNode => {
|
||||
return <div>{images}</div>;
|
||||
};
|
||||
|
||||
export const containersReady = (pod: V1Pod): string => {
|
||||
export const containersReady = (pod: Pod): string => {
|
||||
const containerStatuses = pod.status?.containerStatuses ?? [];
|
||||
const containersReadyItem = containerStatuses.filter(cs => cs.ready).length;
|
||||
|
||||
return `${containersReadyItem}/${containerStatuses.length}`;
|
||||
};
|
||||
|
||||
export const totalRestarts = (pod: V1Pod): number => {
|
||||
export const totalRestarts = (pod: Pod): number => {
|
||||
const containerStatuses = pod.status?.containerStatuses ?? [];
|
||||
return containerStatuses?.reduce((a, b) => a + b.restartCount, 0);
|
||||
};
|
||||
|
||||
export const containerStatuses = (pod: V1Pod): ReactNode => {
|
||||
export const containerStatuses = (pod: Pod): ReactNode => {
|
||||
const containerStatusesItem = pod.status?.containerStatuses ?? [];
|
||||
const errors = containerStatusesItem.reduce((accum, next) => {
|
||||
if (next.state === undefined) {
|
||||
|
||||
@@ -278,7 +278,7 @@ export class TechDocsAddonTester {
|
||||
render(this.build());
|
||||
});
|
||||
|
||||
const shadowHost = screen.getByTestId('techdocs-native-shadowroot');
|
||||
const shadowHost = await screen.findByTestId('techdocs-native-shadowroot');
|
||||
|
||||
return {
|
||||
...screen,
|
||||
|
||||
Reference in New Issue
Block a user