Merge branch 'master' into techdocs/addon-integration
Signed-off-by: Eric Peterson <ericpeterson@spotify.com>
This commit is contained in:
@@ -211,8 +211,10 @@ export class AwsAlbAuthProvider implements AuthProviderRouteHandlers {
|
||||
if (optionalCacheKey) {
|
||||
return crypto.createPublicKey(optionalCacheKey);
|
||||
}
|
||||
const keyText: string = await fetch(
|
||||
`https://public-keys.auth.elb.${this.region}.amazonaws.com/${keyId}`,
|
||||
const keyText = await fetch(
|
||||
`https://public-keys.auth.elb.${encodeURIComponent(
|
||||
this.region,
|
||||
)}.amazonaws.com/${encodeURIComponent(keyId)}`,
|
||||
).then(response => response.text());
|
||||
const keyValue = crypto.createPublicKey(keyText);
|
||||
this.keyCache.set(keyId, keyValue.export({ format: 'pem', type: 'spki' }));
|
||||
|
||||
@@ -23,11 +23,7 @@ import {
|
||||
CatalogClient,
|
||||
GetEntitiesRequest,
|
||||
} from '@backstage/catalog-client';
|
||||
import {
|
||||
Entity,
|
||||
stringifyEntityRef,
|
||||
UserEntity,
|
||||
} from '@backstage/catalog-model';
|
||||
import { stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import { DocumentCollatorFactory } from '@backstage/plugin-search-common';
|
||||
import {
|
||||
@@ -36,6 +32,7 @@ import {
|
||||
} from '@backstage/plugin-catalog-common';
|
||||
import { Permission } from '@backstage/plugin-permission-common';
|
||||
import { Readable } from 'stream';
|
||||
import { getDocumentText } from './util';
|
||||
|
||||
/** @public */
|
||||
export type DefaultCatalogCollatorFactoryOptions = {
|
||||
@@ -100,24 +97,6 @@ export class DefaultCatalogCollatorFactory implements DocumentCollatorFactory {
|
||||
return formatted.toLowerCase();
|
||||
}
|
||||
|
||||
private isUserEntity(entity: Entity): entity is UserEntity {
|
||||
return entity.kind.toLocaleUpperCase('en-US') === 'USER';
|
||||
}
|
||||
|
||||
private getDocumentText(entity: Entity): string {
|
||||
let documentText = entity.metadata.description || '';
|
||||
if (this.isUserEntity(entity)) {
|
||||
if (entity.spec?.profile?.displayName && documentText) {
|
||||
// combine displayName and description
|
||||
const displayName = entity.spec?.profile?.displayName;
|
||||
documentText = displayName.concat(' : ', documentText);
|
||||
} else {
|
||||
documentText = entity.spec?.profile?.displayName || documentText;
|
||||
}
|
||||
}
|
||||
return documentText;
|
||||
}
|
||||
|
||||
private async *execute(): AsyncGenerator<CatalogEntityDocument> {
|
||||
const { token } = await this.tokenManager.getToken();
|
||||
let entitiesRetrieved = 0;
|
||||
@@ -150,7 +129,7 @@ export class DefaultCatalogCollatorFactory implements DocumentCollatorFactory {
|
||||
kind: entity.kind,
|
||||
name: entity.metadata.name,
|
||||
}),
|
||||
text: this.getDocumentText(entity),
|
||||
text: getDocumentText(entity),
|
||||
componentType: entity.spec?.type?.toString() || 'other',
|
||||
type: entity.spec?.type?.toString() || 'other',
|
||||
namespace: entity.metadata.namespace || 'default',
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* Copyright 2022 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 {
|
||||
ComponentEntity,
|
||||
GroupEntity,
|
||||
UserEntity,
|
||||
} from '@backstage/catalog-model';
|
||||
import { getDocumentText } from './util';
|
||||
|
||||
describe('getDocumentText', () => {
|
||||
describe('kind is not User or Group', () => {
|
||||
test('contains description if set', () => {
|
||||
const entity = createComponent();
|
||||
entity.metadata.description = 'The expected description';
|
||||
const actual = getDocumentText(entity);
|
||||
expect(actual).toContain(entity.metadata.description);
|
||||
});
|
||||
|
||||
test('is empty if description is not set', () => {
|
||||
const entity = createComponent();
|
||||
const actual = getDocumentText(entity);
|
||||
expect(actual).toEqual('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('kind is User', () => {
|
||||
test('contains display name if set', () => {
|
||||
const entity = createUser();
|
||||
const actual = getDocumentText(entity);
|
||||
expect(actual).toContain(entity.spec.profile?.displayName);
|
||||
});
|
||||
|
||||
test('contains description if set', () => {
|
||||
const entity = createUser();
|
||||
const actual = getDocumentText(entity);
|
||||
expect(actual).toContain(entity.metadata.description);
|
||||
});
|
||||
|
||||
test('contains both description and display name if both are set', () => {
|
||||
const entity = createUser();
|
||||
const actual = getDocumentText(entity);
|
||||
expect(actual).toContain(entity.spec.profile?.displayName);
|
||||
expect(actual).toContain(entity.metadata.description);
|
||||
});
|
||||
|
||||
test('is empty if description and display name are not set', () => {
|
||||
const entity = createUser();
|
||||
delete entity.metadata.description;
|
||||
delete entity.spec.profile?.displayName;
|
||||
const actual = getDocumentText(entity);
|
||||
expect(actual).toEqual('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('kind is Group', () => {
|
||||
test('contains display name if set', () => {
|
||||
const entity = createGroup();
|
||||
const actual = getDocumentText(entity);
|
||||
expect(actual).toContain(entity.spec.profile?.displayName);
|
||||
});
|
||||
|
||||
test('contains description if set', () => {
|
||||
const entity = createGroup();
|
||||
const actual = getDocumentText(entity);
|
||||
expect(actual).toContain(entity.metadata.description);
|
||||
});
|
||||
|
||||
test('contains both description and display name if both are set', () => {
|
||||
const entity = createGroup();
|
||||
const actual = getDocumentText(entity);
|
||||
expect(actual).toContain(entity.spec.profile?.displayName);
|
||||
expect(actual).toContain(entity.metadata.description);
|
||||
});
|
||||
|
||||
test('is empty if description and display name are not set', () => {
|
||||
const entity = createGroup();
|
||||
delete entity.metadata.description;
|
||||
delete entity.spec.profile?.displayName;
|
||||
const actual = getDocumentText(entity);
|
||||
expect(actual).toEqual('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createGroup(): GroupEntity {
|
||||
return {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'group-1',
|
||||
description: 'The expected description',
|
||||
},
|
||||
spec: {
|
||||
type: 'team',
|
||||
profile: {
|
||||
displayName: 'Group 1',
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createUser(): UserEntity {
|
||||
return {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: {
|
||||
name: 'user-1',
|
||||
description: 'The expected description',
|
||||
},
|
||||
spec: {
|
||||
profile: {
|
||||
displayName: 'User 1',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createComponent(): ComponentEntity {
|
||||
return {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'component-1',
|
||||
},
|
||||
spec: {
|
||||
lifecycle: 'experimental',
|
||||
owner: 'someone',
|
||||
type: 'service',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright 2022 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 { Entity, UserEntity } from '@backstage/catalog-model';
|
||||
|
||||
function isUserEntity(entity: Entity): entity is UserEntity {
|
||||
return entity.kind.toLocaleUpperCase('en-US') === 'USER';
|
||||
}
|
||||
|
||||
function isGroupEntity(entity: Entity): entity is UserEntity {
|
||||
return entity.kind.toLocaleUpperCase('en-US') === 'GROUP';
|
||||
}
|
||||
|
||||
export function getDocumentText(entity: Entity): string {
|
||||
const documentTexts: string[] = [];
|
||||
documentTexts.push(entity.metadata.description || '');
|
||||
|
||||
if (isUserEntity(entity) || isGroupEntity(entity)) {
|
||||
if (entity.spec?.profile?.displayName) {
|
||||
documentTexts.push(entity.spec.profile.displayName);
|
||||
}
|
||||
}
|
||||
return documentTexts.join(' : ');
|
||||
}
|
||||
@@ -93,4 +93,4 @@ Additionally, the API is at a very early state, so contributing with additional
|
||||
|
||||
### Homepage Templates
|
||||
|
||||
We are hoping that we together can build up a collection of Homepage templates. We therefore put together a place where we can collect all the templates for the Home Plugin in the [storybook](https://backstage.io/storybook/?path=/story/plugins-home-templates). If you would like to contribute with a template, start by taking a look at the [DefaultTemplate storybook example to create your own](/plugins/home/src/templates/DefaultTemplate.stories.tsx), and then open a PR with your suggestion.
|
||||
We are hoping that we together can build up a collection of Homepage templates. We therefore put together a place where we can collect all the templates for the Home Plugin in the [storybook](https://backstage.io/storybook/?path=/story/plugins-home-templates). If you would like to contribute with a template, start by taking a look at the [DefaultTemplate storybook example to create your own](/packages/app/src/components/home/templates/DefaultTemplate.stories.tsx), and then open a PR with your suggestion.
|
||||
|
||||
@@ -119,6 +119,19 @@ export const SettingsModal: (props: {
|
||||
children: JSX.Element;
|
||||
}) => JSX.Element;
|
||||
|
||||
// Warning: (ae-forgotten-export) The symbol "TemplateBackstageLogoProps" needs to be exported by the entry point index.d.ts
|
||||
// Warning: (ae-missing-release-tag) "TemplateBackstageLogo" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export const TemplateBackstageLogo: (
|
||||
props: TemplateBackstageLogoProps,
|
||||
) => JSX.Element;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "TemplateBackstageLogoIcon" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export const TemplateBackstageLogoIcon: () => JSX.Element;
|
||||
|
||||
// @public
|
||||
export const WelcomeTitle: () => JSX.Element;
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@
|
||||
"@backstage/core-components": "^0.9.3-next.1",
|
||||
"@backstage/core-plugin-api": "^1.0.0",
|
||||
"@backstage/plugin-catalog-react": "^1.0.1-next.2",
|
||||
"@backstage/plugin-search": "^0.7.5-next.0",
|
||||
"@backstage/plugin-stack-overflow": "^0.1.0-next.0",
|
||||
"@backstage/theme": "^0.2.15",
|
||||
"@backstage/config": "^1.0.0",
|
||||
|
||||
+6
-2
@@ -19,9 +19,13 @@ import React from 'react';
|
||||
type Classes = {
|
||||
svg: string;
|
||||
path: string;
|
||||
}
|
||||
};
|
||||
|
||||
export const TemplateBackstageLogo = (props: { classes: Classes }) => {
|
||||
type TemplateBackstageLogoProps = {
|
||||
classes: Classes;
|
||||
};
|
||||
|
||||
export const TemplateBackstageLogo = (props: TemplateBackstageLogoProps) => {
|
||||
return (
|
||||
<svg
|
||||
className={props.classes.svg}
|
||||
-1
@@ -43,4 +43,3 @@ export const TemplateBackstageLogoIcon = () => {
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2020 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 './TemplateBackstageLogo';
|
||||
export * from './TemplateBackstageLogoIcon';
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { TemplateBackstageLogo } from '../../templates';
|
||||
import { TemplateBackstageLogo } from '../../assets';
|
||||
import { HomePageCompanyLogo } from '../../plugin';
|
||||
import { rootRouteRef } from '../../routes';
|
||||
import { wrapInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
|
||||
@@ -20,7 +20,7 @@ import { Grid } from '@material-ui/core';
|
||||
import React, { ComponentType } from 'react';
|
||||
import { ComponentAccordion } from '../../componentRenderers';
|
||||
import { HomePageToolkit } from '../../plugin';
|
||||
import { TemplateBackstageLogoIcon } from '../../templates';
|
||||
import { TemplateBackstageLogoIcon } from '../../assets';
|
||||
|
||||
export default {
|
||||
title: 'Plugins/Home/Components/Toolkit',
|
||||
|
||||
@@ -33,6 +33,7 @@ export {
|
||||
WelcomeTitle,
|
||||
} from './plugin';
|
||||
export { SettingsModal, HeaderWorldClock } from './components';
|
||||
export * from './assets';
|
||||
export type { ClockConfig } from './components';
|
||||
export { createCardExtension } from './extensions';
|
||||
export type { ComponentRenderer } from './extensions';
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 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 { TemplateBackstageLogo } from './TemplateBackstageLogo';
|
||||
import { TemplateBackstageLogoIcon } from './TemplateBackstageLogoIcon';
|
||||
import {
|
||||
HomePageToolkit,
|
||||
HomePageCompanyLogo,
|
||||
HomePageStarredEntities,
|
||||
} from '../plugin';
|
||||
import { wrapInTestApp, TestApiProvider} from '@backstage/test-utils';
|
||||
import { Content, Page, InfoCard } from '@backstage/core-components';
|
||||
import {
|
||||
starredEntitiesApiRef,
|
||||
MockStarredEntitiesApi,
|
||||
entityRouteRef,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { configApiRef } from '@backstage/core-plugin-api';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import {
|
||||
HomePageSearchBar,
|
||||
SearchContextProvider,
|
||||
searchApiRef,
|
||||
searchPlugin,
|
||||
} from '@backstage/plugin-search';
|
||||
import { HomePageStackOverflowQuestions } from '@backstage/plugin-stack-overflow';
|
||||
import { Grid, makeStyles } from '@material-ui/core';
|
||||
import React, { ComponentType } from 'react';
|
||||
|
||||
const starredEntitiesApi = new MockStarredEntitiesApi();
|
||||
starredEntitiesApi.toggleStarred('component:default/example-starred-entity');
|
||||
starredEntitiesApi.toggleStarred('component:default/example-starred-entity-2');
|
||||
starredEntitiesApi.toggleStarred('component:default/example-starred-entity-3');
|
||||
starredEntitiesApi.toggleStarred('component:default/example-starred-entity-4');
|
||||
|
||||
export default {
|
||||
title: 'Plugins/Home/Templates',
|
||||
decorators: [
|
||||
(Story: ComponentType<{}>) =>
|
||||
wrapInTestApp(
|
||||
<>
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[
|
||||
starredEntitiesApiRef,
|
||||
starredEntitiesApi,
|
||||
],
|
||||
[searchApiRef, { query: () => Promise.resolve({ results: [] }) }],
|
||||
[
|
||||
configApiRef,
|
||||
new ConfigReader({
|
||||
stackoverflow: {
|
||||
baseUrl: 'https://api.stackexchange.com/2.2',
|
||||
},
|
||||
}),
|
||||
],
|
||||
]}
|
||||
>
|
||||
<Story />
|
||||
</TestApiProvider>
|
||||
</>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/hello-company': searchPlugin.routes.root,
|
||||
'/catalog/:namespace/:kind/:name': entityRouteRef,
|
||||
},
|
||||
},
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
searchBar: {
|
||||
display: 'flex',
|
||||
maxWidth: '60vw',
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
boxShadow: theme.shadows[1],
|
||||
padding: '8px 0',
|
||||
borderRadius: '50px',
|
||||
margin: 'auto',
|
||||
},
|
||||
}));
|
||||
|
||||
const useLogoStyles = makeStyles(theme => ({
|
||||
container: {
|
||||
margin: theme.spacing(5, 0),
|
||||
},
|
||||
svg: {
|
||||
width: 'auto',
|
||||
height: 100,
|
||||
},
|
||||
path: {
|
||||
fill: '#7df3e1',
|
||||
},
|
||||
}));
|
||||
|
||||
export const DefaultTemplate = () => {
|
||||
const classes = useStyles();
|
||||
const { svg, path, container } = useLogoStyles();
|
||||
|
||||
return (
|
||||
<SearchContextProvider>
|
||||
<Page themeId="home">
|
||||
<Content>
|
||||
<Grid container justifyContent="center" spacing={6}>
|
||||
<HomePageCompanyLogo
|
||||
className={container}
|
||||
logo={<TemplateBackstageLogo classes={{ svg, path }} />}
|
||||
/>
|
||||
<Grid container item xs={12} alignItems="center" direction="row">
|
||||
<HomePageSearchBar
|
||||
classes={{ root: classes.searchBar }}
|
||||
placeholder="Search"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid container item xs={12}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<HomePageStarredEntities />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<HomePageToolkit
|
||||
tools={Array(8).fill({
|
||||
url: '#',
|
||||
label: 'link',
|
||||
icon: <TemplateBackstageLogoIcon />,
|
||||
})}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<InfoCard title="Composable Section">
|
||||
{/* placeholder for content */}
|
||||
<div style={{ height: 370 }} />
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<HomePageStackOverflowQuestions
|
||||
requestParams={{
|
||||
tagged: 'backstage',
|
||||
site: 'stackoverflow',
|
||||
pagesize: 5,
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Content>
|
||||
</Page>
|
||||
</SearchContextProvider>
|
||||
);
|
||||
};
|
||||
@@ -48,7 +48,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.17.0-next.1",
|
||||
"@types/jest-when": "^2.7.2",
|
||||
"@types/jest-when": "^3.5.0",
|
||||
"@types/lodash": "^4.14.151",
|
||||
"jest-when": "^3.1.0",
|
||||
"supertest": "^6.1.3"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
```ts
|
||||
import { Config } from '@backstage/config';
|
||||
import { Duration } from 'luxon';
|
||||
import express from 'express';
|
||||
import type { FetchResponse } from '@backstage/plugin-kubernetes-common';
|
||||
import type { JsonObject } from '@backstage/types';
|
||||
@@ -85,18 +86,20 @@ export class KubernetesBuilder {
|
||||
// (undocumented)
|
||||
build(): KubernetesBuilderReturn;
|
||||
// (undocumented)
|
||||
protected buildClusterSupplier(): KubernetesClustersSupplier;
|
||||
protected buildClusterSupplier(
|
||||
refreshInterval: Duration,
|
||||
): KubernetesClustersSupplier;
|
||||
// (undocumented)
|
||||
protected buildCustomResources(): CustomResource[];
|
||||
// (undocumented)
|
||||
protected buildFetcher(): KubernetesFetcher;
|
||||
// (undocumented)
|
||||
protected buildHttpServiceLocator(
|
||||
_clusterDetails: ClusterDetails[],
|
||||
_clusterSupplier: KubernetesClustersSupplier,
|
||||
): KubernetesServiceLocator;
|
||||
// (undocumented)
|
||||
protected buildMultiTenantServiceLocator(
|
||||
clusterDetails: ClusterDetails[],
|
||||
clusterSupplier: KubernetesClustersSupplier,
|
||||
): KubernetesServiceLocator;
|
||||
// (undocumented)
|
||||
protected buildObjectsProvider(
|
||||
@@ -105,12 +108,12 @@ export class KubernetesBuilder {
|
||||
// (undocumented)
|
||||
protected buildRouter(
|
||||
objectsProvider: KubernetesObjectsProvider,
|
||||
clusterDetails: ClusterDetails[],
|
||||
clusterSupplier: KubernetesClustersSupplier,
|
||||
): express.Router;
|
||||
// (undocumented)
|
||||
protected buildServiceLocator(
|
||||
method: ServiceLocatorMethod,
|
||||
clusterDetails: ClusterDetails[],
|
||||
clusterSupplier: KubernetesClustersSupplier,
|
||||
): KubernetesServiceLocator;
|
||||
// (undocumented)
|
||||
static createBuilder(env: KubernetesEnvironment): KubernetesBuilder;
|
||||
@@ -127,6 +130,8 @@ export class KubernetesBuilder {
|
||||
// (undocumented)
|
||||
setClusterSupplier(clusterSupplier?: KubernetesClustersSupplier): this;
|
||||
// (undocumented)
|
||||
setDefaultClusterRefreshInterval(refreshInterval: Duration): this;
|
||||
// (undocumented)
|
||||
setFetcher(fetcher?: KubernetesFetcher): this;
|
||||
// (undocumented)
|
||||
setObjectsProvider(objectsProvider?: KubernetesObjectsProvider): this;
|
||||
@@ -137,7 +142,6 @@ export class KubernetesBuilder {
|
||||
// @public
|
||||
export type KubernetesBuilderReturn = Promise<{
|
||||
router: express.Router;
|
||||
clusterDetails: ClusterDetails[];
|
||||
clusterSupplier: KubernetesClustersSupplier;
|
||||
customResources: CustomResource[];
|
||||
fetcher: KubernetesFetcher;
|
||||
@@ -149,7 +153,6 @@ export type KubernetesBuilderReturn = Promise<{
|
||||
//
|
||||
// @public (undocumented)
|
||||
export interface KubernetesClustersSupplier {
|
||||
// (undocumented)
|
||||
getClusters(): Promise<ClusterDetails[]>;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"@google-cloud/container": "^3.0.0",
|
||||
"@kubernetes/client-node": "^0.16.0",
|
||||
"@types/express": "^4.17.6",
|
||||
"@types/luxon": "^2.0.4",
|
||||
"aws-sdk": "^2.840.0",
|
||||
"aws4": "^1.11.0",
|
||||
"compression": "^1.7.4",
|
||||
@@ -52,6 +53,7 @@
|
||||
"fs-extra": "10.0.1",
|
||||
"helmet": "^5.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^2.0.2",
|
||||
"morgan": "^1.10.0",
|
||||
"stream-buffers": "^3.0.2",
|
||||
"winston": "^3.2.1",
|
||||
@@ -60,8 +62,8 @@
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.17.0-next.1",
|
||||
"@types/aws4": "^1.5.1",
|
||||
"supertest": "^6.1.3",
|
||||
"aws-sdk-mock": "^5.2.1"
|
||||
"aws-sdk-mock": "^5.2.1",
|
||||
"supertest": "^6.1.3"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -17,7 +17,13 @@
|
||||
import { Config } from '@backstage/config';
|
||||
import { ForwardedError } from '@backstage/errors';
|
||||
import * as container from '@google-cloud/container';
|
||||
import { GKEClusterDetails, KubernetesClustersSupplier } from '../types/types';
|
||||
import { Duration } from 'luxon';
|
||||
import { runPeriodically } from '../service/runPeriodically';
|
||||
import {
|
||||
ClusterDetails,
|
||||
GKEClusterDetails,
|
||||
KubernetesClustersSupplier,
|
||||
} from '../types/types';
|
||||
|
||||
type GkeClusterLocatorOptions = {
|
||||
projectId: string;
|
||||
@@ -31,11 +37,14 @@ export class GkeClusterLocator implements KubernetesClustersSupplier {
|
||||
constructor(
|
||||
private readonly options: GkeClusterLocatorOptions,
|
||||
private readonly client: container.v1.ClusterManagerClient,
|
||||
private clusterDetails: GKEClusterDetails[] | undefined = undefined,
|
||||
private hasClusterDetails: boolean = false,
|
||||
) {}
|
||||
|
||||
static fromConfigWithClient(
|
||||
config: Config,
|
||||
client: container.v1.ClusterManagerClient,
|
||||
refreshInterval: Duration | undefined = undefined,
|
||||
): GkeClusterLocator {
|
||||
const options = {
|
||||
projectId: config.getString('projectId'),
|
||||
@@ -45,18 +54,37 @@ export class GkeClusterLocator implements KubernetesClustersSupplier {
|
||||
config.getOptionalBoolean('skipMetricsLookup') ?? false,
|
||||
exposeDashboard: config.getOptionalBoolean('exposeDashboard') ?? false,
|
||||
};
|
||||
return new GkeClusterLocator(options, client);
|
||||
const gkeClusterLocator = new GkeClusterLocator(options, client);
|
||||
if (refreshInterval) {
|
||||
runPeriodically(
|
||||
() => gkeClusterLocator.refreshClusters(),
|
||||
refreshInterval.toMillis(),
|
||||
);
|
||||
}
|
||||
return gkeClusterLocator;
|
||||
}
|
||||
|
||||
static fromConfig(config: Config): GkeClusterLocator {
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
refreshInterval: Duration | undefined = undefined,
|
||||
): GkeClusterLocator {
|
||||
return GkeClusterLocator.fromConfigWithClient(
|
||||
config,
|
||||
new container.v1.ClusterManagerClient(),
|
||||
refreshInterval,
|
||||
);
|
||||
}
|
||||
|
||||
async getClusters(): Promise<ClusterDetails[]> {
|
||||
if (!this.hasClusterDetails) {
|
||||
// refresh at least once when first called, when retries are disabled and in tests
|
||||
await this.refreshClusters();
|
||||
}
|
||||
return this.clusterDetails ?? [];
|
||||
}
|
||||
|
||||
// TODO pass caData into the object
|
||||
async getClusters(): Promise<GKEClusterDetails[]> {
|
||||
async refreshClusters(): Promise<void> {
|
||||
const {
|
||||
projectId,
|
||||
region,
|
||||
@@ -70,7 +98,7 @@ export class GkeClusterLocator implements KubernetesClustersSupplier {
|
||||
|
||||
try {
|
||||
const [response] = await this.client.listClusters(request);
|
||||
return (response.clusters ?? []).map(r => ({
|
||||
this.clusterDetails = (response.clusters ?? []).map(r => ({
|
||||
// TODO filter out clusters which don't have name or endpoint
|
||||
name: r.name ?? 'unknown',
|
||||
url: `https://${r.endpoint ?? ''}`,
|
||||
@@ -88,6 +116,7 @@ export class GkeClusterLocator implements KubernetesClustersSupplier {
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
this.hasClusterDetails = true;
|
||||
} catch (e) {
|
||||
throw new ForwardedError(
|
||||
`There was an error retrieving clusters from GKE for projectId=${projectId} region=${region}`,
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
*/
|
||||
|
||||
import { Config, ConfigReader } from '@backstage/config';
|
||||
import { getCombinedClusterDetails } from './index';
|
||||
import { getCombinedClusterSupplier } from './index';
|
||||
|
||||
describe('getCombinedClusterDetails', () => {
|
||||
describe('getCombinedClusterSupplier', () => {
|
||||
it('should retrieve cluster details from config', async () => {
|
||||
const config: Config = new ConfigReader(
|
||||
{
|
||||
@@ -45,7 +45,8 @@ describe('getCombinedClusterDetails', () => {
|
||||
'ctx',
|
||||
);
|
||||
|
||||
const result = await getCombinedClusterDetails(config);
|
||||
const clusterSupplier = getCombinedClusterSupplier(config);
|
||||
const result = await clusterSupplier.getClusters();
|
||||
|
||||
expect(result).toStrictEqual([
|
||||
{
|
||||
@@ -99,7 +100,7 @@ describe('getCombinedClusterDetails', () => {
|
||||
'ctx',
|
||||
);
|
||||
|
||||
await expect(getCombinedClusterDetails(config)).rejects.toStrictEqual(
|
||||
expect(() => getCombinedClusterSupplier(config)).toThrowError(
|
||||
new Error('Unsupported kubernetes.clusterLocatorMethods: "magic"'),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -15,38 +15,49 @@
|
||||
*/
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import { ClusterDetails } from '../types/types';
|
||||
import { Duration } from 'luxon';
|
||||
import { ClusterDetails, KubernetesClustersSupplier } from '../types/types';
|
||||
import { ConfigClusterLocator } from './ConfigClusterLocator';
|
||||
import { GkeClusterLocator } from './GkeClusterLocator';
|
||||
|
||||
export const getCombinedClusterDetails = async (
|
||||
class CombinedClustersSupplier implements KubernetesClustersSupplier {
|
||||
constructor(readonly clusterSuppliers: KubernetesClustersSupplier[]) {}
|
||||
|
||||
async getClusters(): Promise<ClusterDetails[]> {
|
||||
return await Promise.all(
|
||||
this.clusterSuppliers.map(supplier => supplier.getClusters()),
|
||||
)
|
||||
.then(res => {
|
||||
return res.flat();
|
||||
})
|
||||
.catch(e => {
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const getCombinedClusterSupplier = (
|
||||
rootConfig: Config,
|
||||
): Promise<ClusterDetails[]> => {
|
||||
return Promise.all(
|
||||
rootConfig
|
||||
.getConfigArray('kubernetes.clusterLocatorMethods')
|
||||
.map(clusterLocatorMethod => {
|
||||
const type = clusterLocatorMethod.getString('type');
|
||||
switch (type) {
|
||||
case 'config':
|
||||
return ConfigClusterLocator.fromConfig(
|
||||
clusterLocatorMethod,
|
||||
).getClusters();
|
||||
case 'gke':
|
||||
return GkeClusterLocator.fromConfig(
|
||||
clusterLocatorMethod,
|
||||
).getClusters();
|
||||
default:
|
||||
throw new Error(
|
||||
`Unsupported kubernetes.clusterLocatorMethods: "${type}"`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.then(res => {
|
||||
return res.flat();
|
||||
})
|
||||
.catch(e => {
|
||||
throw e;
|
||||
refreshInterval: Duration | undefined = undefined,
|
||||
): KubernetesClustersSupplier => {
|
||||
const clusterSuppliers = rootConfig
|
||||
.getConfigArray('kubernetes.clusterLocatorMethods')
|
||||
.map(clusterLocatorMethod => {
|
||||
const type = clusterLocatorMethod.getString('type');
|
||||
switch (type) {
|
||||
case 'config':
|
||||
return ConfigClusterLocator.fromConfig(clusterLocatorMethod);
|
||||
case 'gke':
|
||||
return GkeClusterLocator.fromConfig(
|
||||
clusterLocatorMethod,
|
||||
refreshInterval,
|
||||
);
|
||||
default:
|
||||
throw new Error(
|
||||
`Unsupported kubernetes.clusterLocatorMethods: "${type}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return new CombinedClustersSupplier(clusterSuppliers);
|
||||
};
|
||||
|
||||
@@ -19,7 +19,9 @@ import { MultiTenantServiceLocator } from './MultiTenantServiceLocator';
|
||||
|
||||
describe('MultiTenantConfigClusterLocator', () => {
|
||||
it('empty clusters returns empty cluster details', async () => {
|
||||
const sut = new MultiTenantServiceLocator([]);
|
||||
const sut = new MultiTenantServiceLocator({
|
||||
getClusters: async () => [],
|
||||
});
|
||||
|
||||
const result = await sut.getClustersByServiceId('ignored');
|
||||
|
||||
@@ -27,14 +29,18 @@ describe('MultiTenantConfigClusterLocator', () => {
|
||||
});
|
||||
|
||||
it('one clusters returns one cluster details', async () => {
|
||||
const sut = new MultiTenantServiceLocator([
|
||||
{
|
||||
name: 'cluster1',
|
||||
url: 'http://localhost:8080',
|
||||
authProvider: 'serviceAccount',
|
||||
serviceAccountToken: '12345',
|
||||
const sut = new MultiTenantServiceLocator({
|
||||
getClusters: async () => {
|
||||
return [
|
||||
{
|
||||
name: 'cluster1',
|
||||
url: 'http://localhost:8080',
|
||||
authProvider: 'serviceAccount',
|
||||
serviceAccountToken: '12345',
|
||||
},
|
||||
];
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
const result = await sut.getClustersByServiceId('ignored');
|
||||
|
||||
@@ -49,19 +55,23 @@ describe('MultiTenantConfigClusterLocator', () => {
|
||||
});
|
||||
|
||||
it('two clusters returns two cluster details', async () => {
|
||||
const sut = new MultiTenantServiceLocator([
|
||||
{
|
||||
name: 'cluster1',
|
||||
serviceAccountToken: 'token',
|
||||
url: 'http://localhost:8080',
|
||||
authProvider: 'serviceAccount',
|
||||
const sut = new MultiTenantServiceLocator({
|
||||
getClusters: async () => {
|
||||
return [
|
||||
{
|
||||
name: 'cluster1',
|
||||
serviceAccountToken: 'token',
|
||||
url: 'http://localhost:8080',
|
||||
authProvider: 'serviceAccount',
|
||||
},
|
||||
{
|
||||
name: 'cluster2',
|
||||
url: 'http://localhost:8081',
|
||||
authProvider: 'google',
|
||||
},
|
||||
];
|
||||
},
|
||||
{
|
||||
name: 'cluster2',
|
||||
url: 'http://localhost:8081',
|
||||
authProvider: 'google',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
const result = await sut.getClustersByServiceId('ignored');
|
||||
|
||||
|
||||
@@ -14,20 +14,24 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ClusterDetails, KubernetesServiceLocator } from '../types/types';
|
||||
import {
|
||||
ClusterDetails,
|
||||
KubernetesClustersSupplier,
|
||||
KubernetesServiceLocator,
|
||||
} from '../types/types';
|
||||
|
||||
// This locator assumes that every service is located on every cluster
|
||||
// Therefore it will always return all clusters provided
|
||||
export class MultiTenantServiceLocator implements KubernetesServiceLocator {
|
||||
private readonly clusterDetails: ClusterDetails[];
|
||||
private readonly clusterSupplier: KubernetesClustersSupplier;
|
||||
|
||||
constructor(clusterDetails: ClusterDetails[]) {
|
||||
this.clusterDetails = clusterDetails;
|
||||
constructor(clusterSupplier: KubernetesClustersSupplier) {
|
||||
this.clusterSupplier = clusterSupplier;
|
||||
}
|
||||
|
||||
// As this implementation always returns all clusters serviceId is ignored here
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async getClustersByServiceId(_serviceId: string): Promise<ClusterDetails[]> {
|
||||
return this.clusterDetails;
|
||||
return this.clusterSupplier.getClusters();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,10 +17,10 @@ import { Config } from '@backstage/config';
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { Logger } from 'winston';
|
||||
import { getCombinedClusterDetails } from '../cluster-locator';
|
||||
import { Duration } from 'luxon';
|
||||
import { getCombinedClusterSupplier } from '../cluster-locator';
|
||||
import { MultiTenantServiceLocator } from '../service-locator/MultiTenantServiceLocator';
|
||||
import {
|
||||
ClusterDetails,
|
||||
KubernetesObjectTypes,
|
||||
ServiceLocatorMethod,
|
||||
CustomResource,
|
||||
@@ -50,7 +50,6 @@ export interface KubernetesEnvironment {
|
||||
*/
|
||||
export type KubernetesBuilderReturn = Promise<{
|
||||
router: express.Router;
|
||||
clusterDetails: ClusterDetails[];
|
||||
clusterSupplier: KubernetesClustersSupplier;
|
||||
customResources: CustomResource[];
|
||||
fetcher: KubernetesFetcher;
|
||||
@@ -60,6 +59,9 @@ export type KubernetesBuilderReturn = Promise<{
|
||||
|
||||
export class KubernetesBuilder {
|
||||
private clusterSupplier?: KubernetesClustersSupplier;
|
||||
private defaultClusterRefreshInterval: Duration = Duration.fromObject({
|
||||
minutes: 60,
|
||||
});
|
||||
private objectsProvider?: KubernetesObjectsProvider;
|
||||
private fetcher?: KubernetesFetcher;
|
||||
private serviceLocator?: KubernetesServiceLocator;
|
||||
@@ -91,13 +93,13 @@ export class KubernetesBuilder {
|
||||
|
||||
const fetcher = this.fetcher ?? this.buildFetcher();
|
||||
|
||||
const clusterSupplier = this.clusterSupplier ?? this.buildClusterSupplier();
|
||||
|
||||
const clusterDetails = await this.fetchClusterDetails(clusterSupplier);
|
||||
const clusterSupplier =
|
||||
this.clusterSupplier ??
|
||||
this.buildClusterSupplier(this.defaultClusterRefreshInterval);
|
||||
|
||||
const serviceLocator =
|
||||
this.serviceLocator ??
|
||||
this.buildServiceLocator(this.getServiceLocatorMethod(), clusterDetails);
|
||||
this.buildServiceLocator(this.getServiceLocatorMethod(), clusterSupplier);
|
||||
|
||||
const objectsProvider =
|
||||
this.objectsProvider ??
|
||||
@@ -109,10 +111,9 @@ export class KubernetesBuilder {
|
||||
objectTypesToFetch: this.getObjectTypesToFetch(),
|
||||
});
|
||||
|
||||
const router = this.buildRouter(objectsProvider, clusterDetails);
|
||||
const router = this.buildRouter(objectsProvider, clusterSupplier);
|
||||
|
||||
return {
|
||||
clusterDetails,
|
||||
clusterSupplier,
|
||||
customResources,
|
||||
fetcher,
|
||||
@@ -127,6 +128,11 @@ export class KubernetesBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
public setDefaultClusterRefreshInterval(refreshInterval: Duration) {
|
||||
this.defaultClusterRefreshInterval = refreshInterval;
|
||||
return this;
|
||||
}
|
||||
|
||||
public setObjectsProvider(objectsProvider?: KubernetesObjectsProvider) {
|
||||
this.objectsProvider = objectsProvider;
|
||||
return this;
|
||||
@@ -161,13 +167,11 @@ export class KubernetesBuilder {
|
||||
return customResources;
|
||||
}
|
||||
|
||||
protected buildClusterSupplier(): KubernetesClustersSupplier {
|
||||
protected buildClusterSupplier(
|
||||
refreshInterval: Duration,
|
||||
): KubernetesClustersSupplier {
|
||||
const config = this.env.config;
|
||||
return {
|
||||
getClusters() {
|
||||
return getCombinedClusterDetails(config);
|
||||
},
|
||||
};
|
||||
return getCombinedClusterSupplier(config, refreshInterval);
|
||||
}
|
||||
|
||||
protected buildObjectsProvider(
|
||||
@@ -185,13 +189,13 @@ export class KubernetesBuilder {
|
||||
|
||||
protected buildServiceLocator(
|
||||
method: ServiceLocatorMethod,
|
||||
clusterDetails: ClusterDetails[],
|
||||
clusterSupplier: KubernetesClustersSupplier,
|
||||
): KubernetesServiceLocator {
|
||||
switch (method) {
|
||||
case 'multiTenant':
|
||||
return this.buildMultiTenantServiceLocator(clusterDetails);
|
||||
return this.buildMultiTenantServiceLocator(clusterSupplier);
|
||||
case 'http':
|
||||
return this.buildHttpServiceLocator(clusterDetails);
|
||||
return this.buildHttpServiceLocator(clusterSupplier);
|
||||
default:
|
||||
throw new Error(
|
||||
`Unsupported kubernetes.clusterLocatorMethod "${method}"`,
|
||||
@@ -200,20 +204,20 @@ export class KubernetesBuilder {
|
||||
}
|
||||
|
||||
protected buildMultiTenantServiceLocator(
|
||||
clusterDetails: ClusterDetails[],
|
||||
clusterSupplier: KubernetesClustersSupplier,
|
||||
): KubernetesServiceLocator {
|
||||
return new MultiTenantServiceLocator(clusterDetails);
|
||||
return new MultiTenantServiceLocator(clusterSupplier);
|
||||
}
|
||||
|
||||
protected buildHttpServiceLocator(
|
||||
_clusterDetails: ClusterDetails[],
|
||||
_clusterSupplier: KubernetesClustersSupplier,
|
||||
): KubernetesServiceLocator {
|
||||
throw new Error('not implemented');
|
||||
}
|
||||
|
||||
protected buildRouter(
|
||||
objectsProvider: KubernetesObjectsProvider,
|
||||
clusterDetails: ClusterDetails[],
|
||||
clusterSupplier: KubernetesClustersSupplier,
|
||||
): express.Router {
|
||||
const logger = this.env.logger;
|
||||
const router = Router();
|
||||
@@ -236,6 +240,7 @@ export class KubernetesBuilder {
|
||||
});
|
||||
|
||||
router.get('/clusters', async (_, res) => {
|
||||
const clusterDetails = await this.fetchClusterDetails(clusterSupplier);
|
||||
res.json({
|
||||
items: clusterDetails.map(cd => ({
|
||||
name: cd.name,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2020 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Runs a function repeatedly, with a fixed wait between invocations.
|
||||
*
|
||||
* Supports async functions, and silently ignores exceptions and rejections.
|
||||
*
|
||||
* @param fn - The function to run. May return a Promise.
|
||||
* @param delayMs - The delay between a completed function invocation and the
|
||||
* next.
|
||||
* @returns A function that, when called, stops the invocation loop.
|
||||
*/
|
||||
export function runPeriodically(fn: () => any, delayMs: number): () => void {
|
||||
let cancel: () => void;
|
||||
let cancelled = false;
|
||||
const cancellationPromise = new Promise<void>(resolve => {
|
||||
cancel = () => {
|
||||
resolve();
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
const startRefresh = async () => {
|
||||
while (!cancelled) {
|
||||
try {
|
||||
await fn();
|
||||
} catch {
|
||||
// ignore intentionally
|
||||
}
|
||||
|
||||
await Promise.race([
|
||||
new Promise(resolve => setTimeout(resolve, delayMs)),
|
||||
cancellationPromise,
|
||||
]);
|
||||
}
|
||||
};
|
||||
startRefresh();
|
||||
|
||||
return cancel!;
|
||||
}
|
||||
@@ -80,6 +80,12 @@ export type KubernetesObjectTypes =
|
||||
|
||||
// Used to load cluster details from different sources
|
||||
export interface KubernetesClustersSupplier {
|
||||
/**
|
||||
* Returns the cached list of clusters.
|
||||
*
|
||||
* Implementations _should_ cache the clusters and refresh them periodically,
|
||||
* as getClusters is called whenever the list of clusters is needed.
|
||||
*/
|
||||
getClusters(): Promise<ClusterDetails[]>;
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,8 @@ export const GitlabRepoPicker = (props: {
|
||||
</>
|
||||
)}
|
||||
<FormHelperText>
|
||||
The organization, user or project that this repo will belong to
|
||||
The organization, groups, subgroups, user, project (also known as
|
||||
namespaces in gitlab), that this repo will belong to
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
<FormControl
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -0,0 +1,3 @@
|
||||
# Backstage Search
|
||||
|
||||
A search plugin library which holds functionality the [search plugin](/plugins/search/README.md) itself and other frontend plugins (e.g. [techdocs](/plugins/techdocs/README.md), [home](/plugins/home/README.md)) depend on.
|
||||
@@ -0,0 +1,61 @@
|
||||
## API Report File for "@backstage/plugin-search-react"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { ApiRef } from '@backstage/core-plugin-api';
|
||||
import { AsyncState } from 'react-use/lib/useAsync';
|
||||
import { ComponentProps } from 'react';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { default as React_2 } from 'react';
|
||||
import { SearchQuery } from '@backstage/plugin-search-common';
|
||||
import { SearchResultSet } from '@backstage/plugin-search-common';
|
||||
|
||||
// @public (undocumented)
|
||||
export interface SearchApi {
|
||||
// (undocumented)
|
||||
query(query: SearchQuery): Promise<SearchResultSet>;
|
||||
}
|
||||
|
||||
// Warning: (ae-forgotten-export) The symbol "QueryResultProps" needs to be exported by the entry point index.d.ts
|
||||
// Warning: (ae-missing-release-tag) "SearchApiProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public
|
||||
export function SearchApiProviderForStorybook(
|
||||
props: PropsWithChildren<QueryResultProps>,
|
||||
): JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export const searchApiRef: ApiRef<SearchApi>;
|
||||
|
||||
// @public (undocumented)
|
||||
export const SearchContextProvider: ({
|
||||
initialState,
|
||||
children,
|
||||
}: React_2.PropsWithChildren<{
|
||||
initialState?: SearchContextState | undefined;
|
||||
}>) => JSX.Element;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "SearchContextProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public
|
||||
export const SearchContextProviderForStorybook: (
|
||||
props: ComponentProps<typeof SearchContextProvider> & QueryResultProps,
|
||||
) => JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export type SearchContextState = {
|
||||
term: string;
|
||||
types: string[];
|
||||
filters: JsonObject;
|
||||
pageCursor?: string;
|
||||
};
|
||||
|
||||
// Warning: (ae-forgotten-export) The symbol "SearchContextValue" needs to be exported by the entry point index.d.ts
|
||||
//
|
||||
// @public (undocumented)
|
||||
export const useSearch: () => SearchContextValue;
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@backstage/plugin-search-react",
|
||||
"version": "0.0.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"backstage": {
|
||||
"role": "web-library"
|
||||
},
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/backstage/backstage",
|
||||
"directory": "plugins/search-react"
|
||||
},
|
||||
"keywords": [
|
||||
"backstage"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "backstage-cli package build",
|
||||
"lint": "backstage-cli package lint",
|
||||
"test": "backstage-cli package test",
|
||||
"prepack": "backstage-cli package prepack",
|
||||
"postpack": "backstage-cli package postpack",
|
||||
"clean": "backstage-cli package clean",
|
||||
"start": "backstage-cli package start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/plugin-search-common": "^0.3.3-next.1",
|
||||
"@backstage/core-plugin-api": "^1.0.0",
|
||||
"@backstage/core-app-api": "^1.0.1-next.0",
|
||||
"react-use": "^17.3.2",
|
||||
"@backstage/types": "^1.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.13.1 || ^17.0.0",
|
||||
"react": "^16.13.1 || ^17.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/test-utils": "^1.0.1-next.1",
|
||||
"@testing-library/react": "^12.1.3",
|
||||
"@testing-library/react-hooks": "^7.0.2",
|
||||
"@testing-library/jest-dom": "^5.10.1"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright 2022 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 { SearchQuery, SearchResultSet } from '@backstage/plugin-search-common';
|
||||
import { createApiRef } from '@backstage/core-plugin-api';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const searchApiRef = createApiRef<SearchApi>({
|
||||
id: 'plugin.search.queryservice',
|
||||
});
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface SearchApi {
|
||||
query(query: SearchQuery): Promise<SearchResultSet>;
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
* Copyright 2022 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.
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Copyright 2022 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 { JsonObject } from '@backstage/types';
|
||||
import { useApi, AnalyticsContext } from '@backstage/core-plugin-api';
|
||||
import { SearchResultSet } from '@backstage/plugin-search-common';
|
||||
import React, {
|
||||
createContext,
|
||||
PropsWithChildren,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import useAsync, { AsyncState } from 'react-use/lib/useAsync';
|
||||
import usePrevious from 'react-use/lib/usePrevious';
|
||||
import { searchApiRef } from '../api';
|
||||
|
||||
type SearchContextValue = {
|
||||
result: AsyncState<SearchResultSet>;
|
||||
setTerm: React.Dispatch<React.SetStateAction<string>>;
|
||||
setTypes: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
setFilters: React.Dispatch<React.SetStateAction<JsonObject>>;
|
||||
setPageCursor: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
fetchNextPage?: React.DispatchWithoutAction;
|
||||
fetchPreviousPage?: React.DispatchWithoutAction;
|
||||
} & SearchContextState;
|
||||
|
||||
/**
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type SearchContextState = {
|
||||
term: string;
|
||||
types: string[];
|
||||
filters: JsonObject;
|
||||
pageCursor?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const SearchContext = createContext<SearchContextValue | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
/**
|
||||
* The initial state of `SearchContextProvider`.
|
||||
*
|
||||
*/
|
||||
const searchInitialState: SearchContextState = {
|
||||
term: '',
|
||||
pageCursor: undefined,
|
||||
filters: {},
|
||||
types: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const SearchContextProvider = ({
|
||||
initialState = searchInitialState,
|
||||
children,
|
||||
}: PropsWithChildren<{ initialState?: SearchContextState }>) => {
|
||||
const searchApi = useApi(searchApiRef);
|
||||
const [pageCursor, setPageCursor] = useState<string | undefined>(
|
||||
initialState.pageCursor,
|
||||
);
|
||||
const [filters, setFilters] = useState<JsonObject>(initialState.filters);
|
||||
const [term, setTerm] = useState<string>(initialState.term);
|
||||
const [types, setTypes] = useState<string[]>(initialState.types);
|
||||
|
||||
const prevTerm = usePrevious(term);
|
||||
|
||||
const result = useAsync(
|
||||
() =>
|
||||
searchApi.query({
|
||||
term,
|
||||
filters,
|
||||
pageCursor,
|
||||
types,
|
||||
}),
|
||||
[term, filters, types, pageCursor],
|
||||
);
|
||||
|
||||
const hasNextPage =
|
||||
!result.loading && !result.error && result.value?.nextPageCursor;
|
||||
const hasPreviousPage =
|
||||
!result.loading && !result.error && result.value?.previousPageCursor;
|
||||
const fetchNextPage = useCallback(() => {
|
||||
setPageCursor(result.value?.nextPageCursor);
|
||||
}, [result.value?.nextPageCursor]);
|
||||
const fetchPreviousPage = useCallback(() => {
|
||||
setPageCursor(result.value?.previousPageCursor);
|
||||
}, [result.value?.previousPageCursor]);
|
||||
|
||||
useEffect(() => {
|
||||
// Any time a term is reset, we want to start from page 0.
|
||||
if (term && prevTerm && term !== prevTerm) {
|
||||
setPageCursor(undefined);
|
||||
}
|
||||
}, [term, prevTerm, initialState.pageCursor]);
|
||||
|
||||
const value: SearchContextValue = {
|
||||
result,
|
||||
filters,
|
||||
setFilters,
|
||||
term,
|
||||
setTerm,
|
||||
types,
|
||||
setTypes,
|
||||
pageCursor,
|
||||
setPageCursor,
|
||||
fetchNextPage: hasNextPage ? fetchNextPage : undefined,
|
||||
fetchPreviousPage: hasPreviousPage ? fetchPreviousPage : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<AnalyticsContext attributes={{ searchTypes: types.sort().join(',') }}>
|
||||
<SearchContext.Provider value={value} children={children} />
|
||||
</AnalyticsContext>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const useSearch = () => {
|
||||
const context = useContext(SearchContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useSearch must be used within a SearchContextProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
+7
-3
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
* Copyright 2022 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.
|
||||
@@ -17,7 +17,7 @@ import { ApiProvider } from '@backstage/core-app-api';
|
||||
import { SearchResultSet } from '@backstage/plugin-search-common';
|
||||
import { TestApiRegistry } from '@backstage/test-utils';
|
||||
import React, { ComponentProps, PropsWithChildren } from 'react';
|
||||
import { searchApiRef } from '../../apis';
|
||||
import { searchApiRef } from '../api';
|
||||
import { SearchContextProvider as RealSearchContextProvider } from './SearchContext';
|
||||
|
||||
type QueryResultProps = {
|
||||
@@ -26,7 +26,7 @@ type QueryResultProps = {
|
||||
|
||||
/**
|
||||
* Utility context provider only for use in Storybook stories. You should use
|
||||
* the real `<SearchContextProvider>` exported by `@backstage/plugin-search` in
|
||||
* the real `<SearchContextProvider>` exported by `@backstage/plugin-search-react` in
|
||||
* your app instead of this! In some cases (like the search page) it may
|
||||
* already be provided on your behalf.
|
||||
*/
|
||||
@@ -40,6 +40,10 @@ export const SearchContextProvider = (
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility api provider only for use in Storybook stories.
|
||||
*
|
||||
*/
|
||||
export function SearchApiProvider(props: PropsWithChildren<QueryResultProps>) {
|
||||
const { mockedResults, children } = props;
|
||||
const query: any = () => Promise.resolve(mockedResults || {});
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2022 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 {
|
||||
SearchContextProvider,
|
||||
SearchContext,
|
||||
useSearch,
|
||||
} from './SearchContext';
|
||||
export type { SearchContextState } from './SearchContext';
|
||||
export {
|
||||
SearchContextProvider as SearchContextProviderForStorybook,
|
||||
SearchApiProvider as SearchApiProviderForStorybook,
|
||||
} from './SearchContextForStorybook.stories';
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2021 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 { searchApiRef } from './api';
|
||||
export type { SearchApi } from './api';
|
||||
export {
|
||||
SearchContextProvider,
|
||||
useSearch,
|
||||
SearchContextProviderForStorybook,
|
||||
SearchApiProviderForStorybook,
|
||||
} from './context';
|
||||
export type { SearchContextState } from './context';
|
||||
@@ -14,5 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { TemplateBackstageLogoIcon } from './TemplateBackstageLogoIcon';
|
||||
export { TemplateBackstageLogo } from './TemplateBackstageLogo'
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -83,7 +83,7 @@ export const Router: () => JSX.Element;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "SearchApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export interface SearchApi {
|
||||
// (undocumented)
|
||||
query(query: SearchQuery): Promise<SearchResultSet>;
|
||||
@@ -91,7 +91,7 @@ export interface SearchApi {
|
||||
|
||||
// Warning: (ae-missing-release-tag) "searchApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export const searchApiRef: ApiRef<SearchApi>;
|
||||
|
||||
// @public (undocumented)
|
||||
@@ -140,7 +140,7 @@ export type SearchBarProps = Partial<SearchBarBaseProps>;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "SearchContextProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export const SearchContextProvider: ({
|
||||
initialState,
|
||||
children,
|
||||
@@ -322,10 +322,11 @@ export type SidebarSearchProps = {
|
||||
icon?: IconComponent;
|
||||
};
|
||||
|
||||
// Warning: (tsdoc-at-sign-in-word) The "@" character looks like part of a TSDoc tag; use a backslash to escape it
|
||||
// Warning: (ae-forgotten-export) The symbol "SearchContextValue" needs to be exported by the entry point index.d.ts
|
||||
// Warning: (ae-missing-release-tag) "useSearch" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export const useSearch: () => SearchContextValue;
|
||||
|
||||
// @public
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"@backstage/core-plugin-api": "^1.0.0",
|
||||
"@backstage/errors": "^1.0.0",
|
||||
"@backstage/plugin-catalog-react": "^1.0.1-next.1",
|
||||
"@backstage/plugin-search-react": "^0.0.0",
|
||||
"@backstage/plugin-search-common": "^0.3.3-next.1",
|
||||
"@backstage/theme": "^0.2.15",
|
||||
"@backstage/types": "^1.0.0",
|
||||
|
||||
@@ -21,12 +21,19 @@ import {
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { ResponseError } from '@backstage/errors';
|
||||
import { SearchQuery, SearchResultSet } from '@backstage/plugin-search-common';
|
||||
|
||||
import qs from 'qs';
|
||||
|
||||
/**
|
||||
* @deprecated import from `@backstage/plugin-search-react` instead
|
||||
*/
|
||||
export const searchApiRef = createApiRef<SearchApi>({
|
||||
id: 'plugin.search.queryservice',
|
||||
});
|
||||
|
||||
/**
|
||||
* @deprecated import from `@backstage/plugin-search-react` instead
|
||||
*/
|
||||
export interface SearchApi {
|
||||
query(query: SearchQuery): Promise<SearchResultSet>;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { Grid, makeStyles, Paper } from '@material-ui/core';
|
||||
import React, { ComponentType } from 'react';
|
||||
import { SearchContextProvider } from '../SearchContext/SearchContextForStorybook.stories';
|
||||
import { SearchContextProviderForStorybook } from '@backstage/plugin-search-react';
|
||||
import { SearchBar } from './SearchBar';
|
||||
|
||||
export default {
|
||||
@@ -24,13 +24,13 @@ export default {
|
||||
component: SearchBar,
|
||||
decorators: [
|
||||
(Story: ComponentType<{}>) => (
|
||||
<SearchContextProvider>
|
||||
<SearchContextProviderForStorybook>
|
||||
<Grid container direction="row">
|
||||
<Grid item xs={12}>
|
||||
<Story />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SearchContextProvider>
|
||||
</SearchContextProviderForStorybook>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -51,6 +51,9 @@ export type SearchContextState = {
|
||||
pageCursor?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated import from `@backstage/plugin-search-react` instead
|
||||
*/
|
||||
export const SearchContext = createContext<SearchContextValue | undefined>(
|
||||
undefined,
|
||||
);
|
||||
@@ -62,6 +65,9 @@ const searchInitialState: SearchContextState = {
|
||||
types: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated import from `@backstage/plugin-search-react` instead
|
||||
*/
|
||||
export const SearchContextProvider = ({
|
||||
initialState = searchInitialState,
|
||||
children,
|
||||
@@ -126,6 +132,9 @@ export const SearchContextProvider = ({
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated import from "@backstage/plugin-search-react" instead
|
||||
*/
|
||||
export const useSearch = () => {
|
||||
const context = useContext(SearchContext);
|
||||
if (context === undefined) {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { Grid, Paper } from '@material-ui/core';
|
||||
import React, { ComponentType } from 'react';
|
||||
import { SearchContextProvider } from '../SearchContext/SearchContextForStorybook.stories';
|
||||
import { SearchContextProviderForStorybook } from '@backstage/plugin-search-react';
|
||||
import { SearchFilter } from './SearchFilter';
|
||||
|
||||
export default {
|
||||
@@ -24,13 +24,13 @@ export default {
|
||||
component: SearchFilter,
|
||||
decorators: [
|
||||
(Story: ComponentType<{}>) => (
|
||||
<SearchContextProvider>
|
||||
<SearchContextProviderForStorybook>
|
||||
<Grid container direction="row">
|
||||
<Grid item xs={4}>
|
||||
<Story />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SearchContextProvider>
|
||||
</SearchContextProviderForStorybook>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { Button } from '@material-ui/core';
|
||||
import React, { ComponentType } from 'react';
|
||||
import { rootRouteRef } from '../../plugin';
|
||||
import { SearchApiProvider } from '../SearchContext/SearchContextForStorybook.stories';
|
||||
import { SearchApiProviderForStorybook } from '@backstage/plugin-search-react';
|
||||
import { SearchModal } from './SearchModal';
|
||||
import { useSearchModal } from './useSearchModal';
|
||||
|
||||
@@ -57,9 +57,9 @@ export default {
|
||||
decorators: [
|
||||
(Story: ComponentType<{}>) =>
|
||||
wrapInTestApp(
|
||||
<SearchApiProvider mockedResults={mockResults}>
|
||||
<SearchApiProviderForStorybook mockedResults={mockResults}>
|
||||
<Story />
|
||||
</SearchApiProvider>,
|
||||
</SearchApiProviderForStorybook>,
|
||||
{ mountedRoutes: { '/search': rootRouteRef } },
|
||||
),
|
||||
],
|
||||
|
||||
@@ -19,7 +19,8 @@ import { List, ListItem } from '@material-ui/core';
|
||||
import React, { ComponentType } from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { DefaultResultListItem } from '../DefaultResultListItem';
|
||||
import { SearchContextProvider } from '../SearchContext/SearchContextForStorybook.stories';
|
||||
|
||||
import { SearchContextProviderForStorybook } from '@backstage/plugin-search-react';
|
||||
import { SearchResult } from './SearchResult';
|
||||
|
||||
const mockResults = {
|
||||
@@ -57,9 +58,9 @@ export default {
|
||||
decorators: [
|
||||
(Story: ComponentType<{}>) => (
|
||||
<MemoryRouter>
|
||||
<SearchContextProvider mockedResults={mockResults}>
|
||||
<SearchContextProviderForStorybook mockedResults={mockResults}>
|
||||
<Story />
|
||||
</SearchContextProvider>
|
||||
</SearchContextProviderForStorybook>
|
||||
</MemoryRouter>
|
||||
),
|
||||
],
|
||||
|
||||
@@ -19,7 +19,7 @@ import CatalogIcon from '@material-ui/icons/MenuBook';
|
||||
import DocsIcon from '@material-ui/icons/Description';
|
||||
import UsersGroupsIcon from '@material-ui/icons/Person';
|
||||
import React, { ComponentType } from 'react';
|
||||
import { SearchContextProvider } from '../SearchContext/SearchContextForStorybook.stories';
|
||||
import { SearchContextProviderForStorybook } from '@backstage/plugin-search-react';
|
||||
import { SearchType } from './SearchType';
|
||||
|
||||
export default {
|
||||
@@ -27,13 +27,13 @@ export default {
|
||||
component: SearchType,
|
||||
decorators: [
|
||||
(Story: ComponentType<{}>) => (
|
||||
<SearchContextProvider>
|
||||
<SearchContextProviderForStorybook>
|
||||
<Grid container direction="row">
|
||||
<Grid item xs={4}>
|
||||
<Story />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SearchContextProvider>
|
||||
</SearchContextProviderForStorybook>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -38,6 +38,10 @@ const serviceEntityPage = (
|
||||
title="Customized title for the scorecard"
|
||||
description="Small description about scorecards"
|
||||
/>
|
||||
<EntityTechInsightsScorecardContent
|
||||
title="Show only simpleTestCheck in this card"
|
||||
checksId={['simpleTestCheck']}
|
||||
/>
|
||||
</EntityLayout.Route>
|
||||
...
|
||||
</EntityLayoutWrapper>
|
||||
@@ -46,6 +50,8 @@ const serviceEntityPage = (
|
||||
|
||||
It is not obligatory to pass title and description props to `EntityTechInsightsScorecardContent`. If those are left out, default values from `defaultCheckResultRenderers` in `CheckResultRenderer` will be taken, hence `Boolean scorecard` and `This card represents an overview of default boolean Backstage checks`.
|
||||
|
||||
You can pass an array `checksId` as a prop with the [Fact Retrievers ids](../tech-insights-backend#creating-fact-retrievers) to limit which checks you want to show in this card, If you don't pass, the default value is show all checks.
|
||||
|
||||
## Boolean Scorecard Example
|
||||
|
||||
If you follow the [Backend Example](https://github.com/backstage/backstage/tree/master/plugins/tech-insights-backend#backend-example), once the needed facts have been generated the boolean scorecard will look like this:
|
||||
|
||||
@@ -34,9 +34,11 @@ export type CheckResultRenderer = {
|
||||
export const EntityTechInsightsScorecardContent: ({
|
||||
title,
|
||||
description,
|
||||
checksId,
|
||||
}: {
|
||||
title?: string | undefined;
|
||||
description?: string | undefined;
|
||||
checksId?: string[] | undefined;
|
||||
}) => JSX.Element;
|
||||
|
||||
// @public
|
||||
@@ -58,7 +60,7 @@ export interface TechInsightsApi {
|
||||
// (undocumented)
|
||||
runChecks(
|
||||
entityParams: CompoundEntityRef,
|
||||
checks?: Check[],
|
||||
checks?: string[],
|
||||
): Promise<CheckResult[]>;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface TechInsightsApi {
|
||||
getAllChecks(): Promise<Check[]>;
|
||||
runChecks(
|
||||
entityParams: CompoundEntityRef,
|
||||
checks?: Check[],
|
||||
checks?: string[],
|
||||
): Promise<CheckResult[]>;
|
||||
runBulkChecks(
|
||||
entities: CompoundEntityRef[],
|
||||
|
||||
@@ -75,13 +75,12 @@ export class TechInsightsClient implements TechInsightsApi {
|
||||
|
||||
async runChecks(
|
||||
entityParams: CompoundEntityRef,
|
||||
checks?: Check[],
|
||||
checks?: string[],
|
||||
): Promise<CheckResult[]> {
|
||||
const url = await this.discoveryApi.getBaseUrl('tech-insights');
|
||||
const { token } = await this.identityApi.getCredentials();
|
||||
const { namespace, kind, name } = entityParams;
|
||||
const checkIds = checks ? checks.map(check => check.id) : [];
|
||||
const requestBody = { checks: checkIds.length > 0 ? checkIds : undefined };
|
||||
const requestBody = { checks };
|
||||
const response = await fetch(
|
||||
`${url}/checks/run/${encodeURIComponent(namespace)}/${encodeURIComponent(
|
||||
kind,
|
||||
|
||||
@@ -26,14 +26,16 @@ import { techInsightsApiRef } from '../../api/TechInsightsApi';
|
||||
export const ScorecardsOverview = ({
|
||||
title,
|
||||
description,
|
||||
checksId,
|
||||
}: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
checksId?: string[];
|
||||
}) => {
|
||||
const api = useApi(techInsightsApiRef);
|
||||
const { namespace, kind, name } = useParams();
|
||||
const { value, loading, error } = useAsync(
|
||||
async () => await api.runChecks({ namespace, kind, name }),
|
||||
async () => await api.runChecks({ namespace, kind, name }, checksId),
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
|
||||
@@ -44,8 +44,7 @@
|
||||
"@backstage/integration": "^1.1.0-next.1",
|
||||
"@backstage/integration-react": "^1.0.1-next.1",
|
||||
"@backstage/plugin-catalog-react": "^1.0.1-next.2",
|
||||
"@backstage/plugin-catalog": "^1.1.0-next.2",
|
||||
"@backstage/plugin-search": "^0.7.5-next.0",
|
||||
"@backstage/plugin-search-react": "^0.0.0",
|
||||
"@backstage/plugin-techdocs-react": "^0.0.0",
|
||||
"@backstage/theme": "^0.2.15",
|
||||
"@backstage/version-bridge": "^1.0.0",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { ApiProvider } from '@backstage/core-app-api';
|
||||
import { searchApiRef } from '@backstage/plugin-search';
|
||||
import { searchApiRef } from '@backstage/plugin-search-react';
|
||||
import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils';
|
||||
import {
|
||||
act,
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
*/
|
||||
|
||||
import { CompoundEntityRef } from '@backstage/catalog-model';
|
||||
import { SearchContextProvider, useSearch } from '@backstage/plugin-search';
|
||||
import {
|
||||
SearchContextProvider,
|
||||
useSearch,
|
||||
} from '@backstage/plugin-search-react';
|
||||
import {
|
||||
makeStyles,
|
||||
CircularProgress,
|
||||
|
||||
@@ -52,8 +52,9 @@ async function main() {
|
||||
|
||||
## Scanned Files
|
||||
|
||||
The included `TodoReaderService` and `TodoScmReader` works by reading source code of to the entity that is being viewed. The location source code is determined by the value of the [`backstage.io/source-location`
|
||||
](https://backstage.io/docs/features/software-catalog/well-known-annotations#backstageiosource-location) annotation of the entity, and if that is missing it falls back to the [`backstage.io/managed-by-location `](https://backstage.io/docs/features/software-catalog/well-known-annotations#backstageiomanaged-by-location) annotation. Only `url` locations are currently supported, meaning locally configured `file` locations won't work. Also note that dot-files and folders are ignored.
|
||||
The included `TodoReaderService` and `TodoScmReader` works by getting the entity source location from the catalog.
|
||||
|
||||
The location source code is determined automatically. In case of the source code of the component is not in the same place of the entity YAML file, you can explicitly set the value of the [`backstage.io/source-location`](https://backstage.io/docs/features/software-catalog/well-known-annotations#backstageiosource-location) annotation of the entity, and if that is missing it falls back to the [`backstage.io/managed-by-location `](https://backstage.io/docs/features/software-catalog/well-known-annotations#backstageiomanaged-by-location) annotation. Only `url` locations are currently supported, meaning locally configured `file` locations won't work. Also note that dot-files and folders are ignored.
|
||||
|
||||
## Parser Configuration
|
||||
|
||||
|
||||
@@ -310,7 +310,7 @@ describe('TodoReaderService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw if entity does not have a location', async () => {
|
||||
it('should not throw if entity does not have a location', async () => {
|
||||
const todoReader = mockTodoReader([]);
|
||||
const catalogClient = mockCatalogClient({
|
||||
...mockEntity,
|
||||
@@ -320,9 +320,8 @@ describe('TodoReaderService', () => {
|
||||
|
||||
await expect(service.listTodos({ entity: entityName })).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'InputError',
|
||||
message:
|
||||
'No entity location annotation found for component:default/my-component',
|
||||
name: 'Error',
|
||||
message: "Entity 'component:default/my-component' is missing location",
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -334,7 +333,8 @@ describe('TodoReaderService', () => {
|
||||
metadata: {
|
||||
...mockEntity.metadata,
|
||||
annotations: {
|
||||
['backstage.io/managed-by-location']: 'file:../info.yaml',
|
||||
['backstage.io/managed-by-location']:
|
||||
'file:../managed-by-location.yaml',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -343,7 +343,7 @@ describe('TodoReaderService', () => {
|
||||
await expect(service.listTodos({ entity: entityName })).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'InputError',
|
||||
message: `Invalid entity location type for component:default/my-component, got 'file'`,
|
||||
message: `Invalid entity location type for component:default/my-component, got 'file' for location ../managed-by-location.yaml`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -355,7 +355,7 @@ describe('TodoReaderService', () => {
|
||||
metadata: {
|
||||
...mockEntity.metadata,
|
||||
annotations: {
|
||||
['backstage.io/source-location']: 'file:../info.yaml',
|
||||
['backstage.io/source-location']: 'file:../source-location.yaml',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -364,7 +364,7 @@ describe('TodoReaderService', () => {
|
||||
await expect(service.listTodos({ entity: entityName })).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'InputError',
|
||||
message: `Invalid entity source location type for component:default/my-component, got 'file'`,
|
||||
message: `Invalid entity location type for component:default/my-component, got 'file' for location ../source-location.yaml`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -17,10 +17,7 @@
|
||||
import { InputError, NotFoundError } from '@backstage/errors';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import {
|
||||
ANNOTATION_LOCATION,
|
||||
ANNOTATION_SOURCE_LOCATION,
|
||||
Entity,
|
||||
parseLocationRef,
|
||||
getEntitySourceLocation,
|
||||
stringifyEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import { TodoReader } from '../lib';
|
||||
@@ -74,8 +71,15 @@ export class TodoReaderService implements TodoService {
|
||||
`Entity not found, ${stringifyEntityRef(req.entity)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const url = this.getEntitySourceUrl(entity);
|
||||
const entitySourceLocation = getEntitySourceLocation(entity);
|
||||
if (entitySourceLocation.type !== 'url') {
|
||||
throw new InputError(
|
||||
`Invalid entity location type for ${stringifyEntityRef(entity)}, got '${
|
||||
entitySourceLocation.type
|
||||
}' for location ${entitySourceLocation.target}`,
|
||||
);
|
||||
}
|
||||
const url = entitySourceLocation.target;
|
||||
const todos = await this.todoReader.readTodos({ url });
|
||||
|
||||
let limit = req.limit ?? this.defaultPageSize;
|
||||
@@ -125,36 +129,4 @@ export class TodoReaderService implements TodoService {
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
private getEntitySourceUrl(entity: Entity) {
|
||||
const sourceLocation =
|
||||
entity.metadata.annotations?.[ANNOTATION_SOURCE_LOCATION];
|
||||
if (sourceLocation) {
|
||||
const parsed = parseLocationRef(sourceLocation);
|
||||
if (parsed.type !== 'url') {
|
||||
throw new InputError(
|
||||
`Invalid entity source location type for ${stringifyEntityRef(
|
||||
entity,
|
||||
)}, got '${parsed.type}'`,
|
||||
);
|
||||
}
|
||||
return parsed.target;
|
||||
}
|
||||
|
||||
const location = entity.metadata.annotations?.[ANNOTATION_LOCATION];
|
||||
if (location) {
|
||||
const parsed = parseLocationRef(location);
|
||||
if (parsed.type !== 'url') {
|
||||
throw new InputError(
|
||||
`Invalid entity location type for ${stringifyEntityRef(
|
||||
entity,
|
||||
)}, got '${parsed.type}'`,
|
||||
);
|
||||
}
|
||||
return parsed.target;
|
||||
}
|
||||
throw new InputError(
|
||||
`No entity location annotation found for ${stringifyEntityRef(entity)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user