merge trunk

Signed-off-by: Christopher Diaz <cdiaz@rvohealth.com>
This commit is contained in:
Christopher Diaz
2023-05-23 11:23:20 -04:00
74 changed files with 3245 additions and 1008 deletions
@@ -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 (
<>
+1
View File
@@ -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",
@@ -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" />
)}
&nbsp;
{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;
}
+1 -1
View File
@@ -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": [
+1 -1
View File
@@ -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"
+1
View File
@@ -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',
+3
View File
@@ -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];
+1
View File
@@ -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';
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
);
});
};
+4 -3
View File
@@ -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,