diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt
index 21045f04dd..c9774ae237 100644
--- a/.github/styles/vocab.txt
+++ b/.github/styles/vocab.txt
@@ -32,6 +32,7 @@ Codehilite
codeowners
config
Config
+configmaps
configs
const
cookiecutter
@@ -67,6 +68,7 @@ graphviz
Hackathons
haproxy
heroku
+horizontalpodautoscalers
Hostname
http
https
@@ -140,6 +142,7 @@ rankdir
readme
Readme
Redash
+replicasets
repo
Repo
repos
diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx
index c2483b40ee..393ffe2631 100644
--- a/packages/app/src/components/catalog/EntityPage.tsx
+++ b/packages/app/src/components/catalog/EntityPage.tsx
@@ -49,6 +49,11 @@ import {
import { Entity } from '@backstage/catalog-model';
import { Button, Grid } from '@material-ui/core';
import { EmptyState } from '@backstage/core';
+import {
+ EmbeddedRouter as LighthouseRouter,
+ LastLighthouseAuditCard,
+ isPluginApplicableToEntity as isLighthouseAvailable,
+} from '@backstage/plugin-lighthouse/';
const CICDSwitcher = ({ entity }: { entity: Entity }) => {
// This component is just an example of how you can implement your company's logic in entity page.
@@ -115,6 +120,11 @@ const OverviewContent = ({ entity }: { entity: Entity }) => (
+ {isLighthouseAvailable(entity) && (
+
+
+
+ )}
);
@@ -165,6 +175,11 @@ const WebsiteEntityPage = ({ entity }: { entity: Entity }) => (
title="CI/CD"
element={}
/>
+ }
+ />
+ authProvider: 'serviceAccount'
+ - url: http://127.0.0.2:9999
+ name: gke-cluster-1
+ authProvider: 'google'
+```
+
+##### clusters
+
+Used by the `configMultiTenant` `clusterLocatorMethod` to construct Kubernetes clients.
+
+###### url
+
+The base url to the Kubernetes control plane. Can be found by using the `Kubernetes master` result from running the `kubectl cluster-info` command.
+
+###### name
+
+A name to represent this cluster, this must be unique within the `clusters` array. Users will see this value in the Service Catalog Kubernetes plugin.
+
+###### authProvider
+
+This determines how the Kubernetes client authenticate with the Kubernetes cluster. Valid values are:
+
+| Value | Description |
+| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `serviceAccount` | This will use a Kubernetes [service account](https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/) to access the Kubernetes API. When this is used the `serviceAccountToken` field should also be set. |
+| `google` | This will use a user's google auth token from the [google auth plugin](https://backstage.io/docs/auth/) to access the Kubernetes API. |
+
+###### serviceAccount (optional)
+
+The service account token to be used when using the `authProvider`, `serviceAccount`.
+
+## RBAC
+
+The current RBAC permissions required are read-only cluster wide, for the following objects:
+
+- pods
+- services
+- configmaps
+- deployments
+- replicasets
+- horizontalpodautoscalers
+- ingresses
diff --git a/plugins/kubernetes-backend/examples/dice-roller/README.md b/plugins/kubernetes-backend/examples/dice-roller/README.md
index 47fc345c73..f97f760c9f 100644
--- a/plugins/kubernetes-backend/examples/dice-roller/README.md
+++ b/plugins/kubernetes-backend/examples/dice-roller/README.md
@@ -1,6 +1,6 @@
# Dice roller
-An app to roll dice (it doesn't actually do that).
+This can be used to run the kubernetes plugin locally against a mock service.
# Viewing in local Minikube running Backstage locally
@@ -23,22 +23,24 @@ An app to roll dice (it doesn't actually do that).
6. Register existing component in Backstage
- https://github.com/mclarke47/dice-roller/blob/master/catalog-info.yaml
-Update `app-config.yaml` as follows.
+Update `app-config.development.yaml` as follows.
```yaml
----
kubernetes:
clusterLocatorMethod: 'configMultiTenant'
clusters:
- url:
name: minikube
serviceAccountToken:
+ authProvider: 'serviceAccount'
```
### Getting the service account token
+Mac copy to clipboard:
+
```
-kubectl get secret DICE_ROLLER_TOKEN_NAME -o=json | jq -r '.data["token"]' | base64 --decode | pbcopy
+kubectl get secret $(kubectl get sa dice-roller -o=json | jq -r .secrets[0].name) -o=json | jq -r '.data["token"]' | base64 --decode | pbcopy
```
-Paste into `app-config.yaml` `kubernetes.clusters[].serviceAccountToken`
+Paste into `app-config.development.yaml` `kubernetes.clusters[0].serviceAccountToken`
diff --git a/plugins/kubernetes/README.md b/plugins/kubernetes/README.md
index 678c9a96a6..1ad478bd89 100644
--- a/plugins/kubernetes/README.md
+++ b/plugins/kubernetes/README.md
@@ -11,3 +11,24 @@ Your plugin has been added to the example app in this repository, meaning you'll
You can also serve the plugin in isolation by running `yarn start` in the plugin directory.
This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory.
+
+## Surfacing your Kubernetes components as part of an entity
+
+### Adding the entity annotation
+
+In order for Backstage to detect that an entity has Kubernetes components,
+the following annotation should be added to the entity.
+
+```yaml
+annotations:
+ 'backstage.io/kubernetes-id': dice-roller
+```
+
+### Labeling Kubernetes components
+
+In order for Kubernetes components to show up in the service catalog
+as a part of an entity, Kubernetes components must be labeled with the following label:
+
+```yaml
+'backstage.io/kubernetes-id':
+```
diff --git a/plugins/lighthouse/README.md b/plugins/lighthouse/README.md
index b5fecb85f8..19244f3cf0 100644
--- a/plugins/lighthouse/README.md
+++ b/plugins/lighthouse/README.md
@@ -57,3 +57,66 @@ Then configure the lighthouse service url in your [`app-config.yaml`](https://gi
lighthouse:
baseUrl: http://your-service-url
```
+
+### Integration with the Catalog
+
+The lighthouse plugin can be integrated into the catalog so that lighthouse audit information relating to a component
+can be displayed within that component's entity page. In order to link an Entity to its lighthouse audits the entity
+must be annotated as follows:
+
+```yaml
+apiVersion: backstage.io/v1alpha1
+kind: Component
+metadata:
+ # ...
+ annotations:
+ # ...
+ lighthouse.com/website-url: # A single website url e.g. https://backstage.io/
+```
+
+> NOTE: The lighthouse plugin only supports one website url per component at this time.
+
+Add a lighthouse tab to the EntityPage:
+
+```tsx
+// packages/app/src/components/catalog/EntityPage.tsx
+import { EmbeddedRouter as LighthouseRouter } from '@backstage/plugin-lighthouse';
+
+// ...
+const WebsiteEntityPage = ({ entity }: { entity: Entity }) => (
+
+ // ...
+ }
+ />
+
+);
+```
+
+> NOTE: The embedded router renders page content without a header section allowing it to be rendered within a
+> catalog plugin page.
+
+Add a Lighthouse card to the overview tab on the EntityPage:
+
+```tsx
+// packages/app/src/components/catalog/EntityPage.tsx
+import {
+ LastLighthouseAuditCard,
+ isPluginApplicableToEntity as isLighthouseAvailable,
+} from '@backstage/plugin-lighthouse';
+
+// ...
+
+const OverviewContent = ({ entity }: { entity: Entity }) => (
+
+ // ...
+ {isLighthouseAvailable(entity) && (
+
+
+
+ )}
+
+);
+```
diff --git a/plugins/lighthouse/constants.ts b/plugins/lighthouse/constants.ts
new file mode 100644
index 0000000000..7a60e2be67
--- /dev/null
+++ b/plugins/lighthouse/constants.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export const LIGHTHOUSE_WEBSITE_URL_ANNOTATION = 'lighthouse.com/website-url';
diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json
index 0b30e48689..7446f09ed4 100644
--- a/plugins/lighthouse/package.json
+++ b/plugins/lighthouse/package.json
@@ -21,17 +21,22 @@
"start": "backstage-cli plugin:serve"
},
"dependencies": {
+ "@backstage/catalog-model": "^0.1.1-alpha.24",
"@backstage/config": "^0.1.1-alpha.24",
"@backstage/core": "^0.1.1-alpha.24",
+ "@backstage/core-api": "^0.1.1-alpha.24",
+ "@backstage/plugin-catalog": "^0.1.1-alpha.24",
"@backstage/theme": "^0.1.1-alpha.24",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
+ "@testing-library/react-hooks": "^3.4.2",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-markdown": "^4.3.1",
"react-router-dom": "6.0.0-beta.0",
- "react-use": "^15.3.3"
+ "react-use": "^15.3.3",
+ "@types/react": "^16.9"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.24",
diff --git a/plugins/lighthouse/src/Router.tsx b/plugins/lighthouse/src/Router.tsx
index 46843504fd..df481483c3 100644
--- a/plugins/lighthouse/src/Router.tsx
+++ b/plugins/lighthouse/src/Router.tsx
@@ -15,11 +15,18 @@
*/
import React from 'react';
-import { Routes, Route } from 'react-router-dom';
-import { rootRouteRef, viewAuditRouteRef, createAuditRouteRef } from './plugin';
+import { Route, Routes } from 'react-router-dom';
+import { createAuditRouteRef, rootRouteRef, viewAuditRouteRef } from './plugin';
import AuditList from './components/AuditList';
-import AuditView from './components/AuditView';
-import CreateAudit from './components/CreateAudit';
+import AuditView, { AuditViewContent } from './components/AuditView';
+import CreateAudit, { CreateAuditContent } from './components/CreateAudit';
+import { Entity } from '@backstage/catalog-model';
+import { LIGHTHOUSE_WEBSITE_URL_ANNOTATION } from '../constants';
+import { AuditListForEntity } from './components/AuditList/AuditListForEntity';
+import { EmptyState } from '@backstage/core';
+
+export const isPluginApplicableToEntity = (entity: Entity) =>
+ Boolean(entity.metadata.annotations?.[LIGHTHOUSE_WEBSITE_URL_ANNOTATION]);
export const Router = () => (
@@ -28,3 +35,24 @@ export const Router = () => (
} />
);
+
+export const EmbeddedRouter = ({ entity }: { entity: Entity }) =>
+ !isPluginApplicableToEntity(entity) ? (
+
+ ) : (
+
+ } />
+ }
+ />
+ }
+ />
+
+ );
diff --git a/plugins/lighthouse/src/api.ts b/plugins/lighthouse/src/api.ts
index 5b6cd9aae7..03b42a38b9 100644
--- a/plugins/lighthouse/src/api.ts
+++ b/plugins/lighthouse/src/api.ts
@@ -104,6 +104,7 @@ export type LighthouseApi = {
getWebsiteList: (listOptions: LASListRequest) => Promise;
getWebsiteForAuditId: (auditId: string) => Promise;
triggerAudit: (payload: TriggerAuditPayload) => Promise;
+ getWebsiteByUrl: (websiteUrl: string) => Promise;
};
export const lighthouseApiRef = createApiRef({
@@ -150,4 +151,10 @@ export class LighthouseRestApi implements LighthouseApi {
},
});
}
+
+ async getWebsiteByUrl(websiteUrl: string): Promise {
+ return this.fetch(
+ `/v1/websites/${encodeURIComponent(websiteUrl)}`,
+ );
+ }
}
diff --git a/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx b/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx
new file mode 100644
index 0000000000..7f10914d9c
--- /dev/null
+++ b/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx
@@ -0,0 +1,144 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { render } from '@testing-library/react';
+import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
+import {
+ lighthouseApiRef,
+ LighthouseRestApi,
+ WebsiteListResponse,
+} from '../../api';
+import mockFetch from 'jest-fetch-mock';
+
+import * as data from '../../__fixtures__/website-list-response.json';
+import { EntityContext } from '@backstage/plugin-catalog';
+import { Entity } from '@backstage/catalog-model';
+import { AuditListForEntity } from './AuditListForEntity';
+import { lightTheme } from '@backstage/theme';
+import { ThemeProvider } from '@material-ui/core';
+import { MemoryRouter } from 'react-router-dom';
+import { useWebsiteForEntity } from '../../hooks/useWebsiteForEntity';
+
+jest.mock('../../hooks/useWebsiteForEntity', () => ({
+ useWebsiteForEntity: jest.fn(),
+}));
+
+const websiteListResponse = data as WebsiteListResponse;
+const entityWebsite = websiteListResponse.items[0];
+
+describe('', () => {
+ let apis: ApiRegistry;
+
+ const mockErrorApi: jest.Mocked = {
+ post: jest.fn(),
+ error$: jest.fn(),
+ };
+
+ beforeEach(() => {
+ apis = ApiRegistry.from([
+ [lighthouseApiRef, new LighthouseRestApi('http://lighthouse')],
+ [errorApiRef, mockErrorApi],
+ ]);
+ mockFetch.mockResponse(JSON.stringify(entityWebsite));
+ (useWebsiteForEntity as jest.Mock).mockReturnValue({
+ value: entityWebsite,
+ loading: false,
+ error: null,
+ });
+ });
+
+ const entity: Entity = {
+ apiVersion: 'v1',
+ kind: 'Component',
+ metadata: {
+ name: 'software',
+ annotations: {
+ 'lighthouse.com/website-url': entityWebsite.url,
+ },
+ },
+ spec: {
+ owner: 'guest',
+ type: 'Website',
+ lifecycle: 'development',
+ },
+ };
+
+ const subject = (value = {}) =>
+ render(
+
+
+
+
+
+
+
+
+ ,
+ );
+
+ it('renders the audit list for the entity', async () => {
+ const { findByText } = subject();
+ expect(await findByText(entityWebsite.url)).toBeInTheDocument();
+ });
+
+ describe('where the data is loading', () => {
+ beforeEach(() => {
+ (useWebsiteForEntity as jest.Mock).mockReturnValue({
+ value: null,
+ loading: true,
+ error: null,
+ });
+ });
+
+ it('renders a Progress element', async () => {
+ const { findByTestId } = subject();
+ expect(await findByTestId('progress')).toBeInTheDocument();
+ });
+ });
+
+ describe('where there is an error loading data', () => {
+ beforeEach(() => {
+ (useWebsiteForEntity as jest.Mock).mockReturnValue({
+ value: null,
+ loading: false,
+ error: 'error',
+ });
+ });
+
+ it('renders nothing', async () => {
+ const { queryByTestId } = subject();
+ expect(await queryByTestId('AuditListTable')).toBeNull();
+ });
+ });
+
+ describe('where there is not data', () => {
+ beforeEach(() => {
+ (useWebsiteForEntity as jest.Mock).mockReturnValue({
+ value: null,
+ loading: false,
+ error: null,
+ });
+ });
+
+ it('renders nothing', async () => {
+ const { queryByTestId } = subject();
+ expect(await queryByTestId('AuditListTable')).toBeNull();
+ });
+ });
+});
diff --git a/plugins/lighthouse/src/components/AuditList/AuditListForEntity.tsx b/plugins/lighthouse/src/components/AuditList/AuditListForEntity.tsx
new file mode 100644
index 0000000000..616fd0fa8a
--- /dev/null
+++ b/plugins/lighthouse/src/components/AuditList/AuditListForEntity.tsx
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React from 'react';
+import { AuditListTable } from './AuditListTable';
+import { Progress } from '@backstage/core';
+import { useWebsiteForEntity } from '../../hooks/useWebsiteForEntity';
+
+export const AuditListForEntity = () => {
+ const { value, loading, error } = useWebsiteForEntity();
+ if (loading) {
+ return ;
+ }
+ if (error || !value) {
+ return null;
+ }
+
+ return ;
+};
diff --git a/plugins/lighthouse/src/components/AuditView/index.tsx b/plugins/lighthouse/src/components/AuditView/index.tsx
index d0e743a77f..54bd179418 100644
--- a/plugins/lighthouse/src/components/AuditView/index.tsx
+++ b/plugins/lighthouse/src/components/AuditView/index.tsx
@@ -124,7 +124,7 @@ const AuditView: FC<{ audit?: Audit }> = ({ audit }: { audit?: Audit }) => {
);
};
-const ConnectedAuditView: FC<{}> = () => {
+export const AuditViewContent: FC<{}> = () => {
const lighthouseApi = useApi(lighthouseApiRef);
const params = useParams() as { id: string };
const classes = useStyles();
@@ -173,32 +173,35 @@ const ConnectedAuditView: FC<{}> = () => {
}
return (
-
-
-
- navigate(`../../${createAuditButtonUrl}`)}
>
-
-
-
- {content}
-
-
+ Create New Audit
+
+
+
+ {content}
+ >
);
};
+const ConnectedAuditView = () => (
+
+
+
+
+
+
+);
+
export default ConnectedAuditView;
diff --git a/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx
new file mode 100644
index 0000000000..20b3b9c072
--- /dev/null
+++ b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx
@@ -0,0 +1,172 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { render } from '@testing-library/react';
+import {
+ AuditCompleted,
+ LighthouseCategoryId,
+ WebsiteListResponse,
+} from '../../api';
+import { EntityContext } from '@backstage/plugin-catalog';
+import { Entity } from '@backstage/catalog-model';
+import { LastLighthouseAuditCard } from './LastLighthouseAuditCard';
+import { lightTheme } from '@backstage/theme';
+import { ThemeProvider } from '@material-ui/core';
+import { useWebsiteForEntity } from '../../hooks/useWebsiteForEntity';
+import { MemoryRouter } from 'react-router-dom';
+import * as data from '../../__fixtures__/website-list-response.json';
+
+jest.mock('../../hooks/useWebsiteForEntity', () => ({
+ useWebsiteForEntity: jest.fn(),
+}));
+
+const websiteListResponse = data as WebsiteListResponse;
+let entityWebsite = websiteListResponse.items[2];
+
+describe('', () => {
+ const asPercentage = (fraction: number) => `${fraction * 100}%`;
+
+ beforeEach(() => {
+ (useWebsiteForEntity as jest.Mock).mockReturnValue({
+ value: entityWebsite,
+ loading: false,
+ error: null,
+ });
+ });
+
+ const entity: Entity = {
+ apiVersion: 'v1',
+ kind: 'Component',
+ metadata: {
+ name: 'software',
+ annotations: {
+ 'lighthouse.com/website-url': entityWebsite.url,
+ },
+ },
+ spec: {
+ owner: 'guest',
+ type: 'Website',
+ lifecycle: 'development',
+ },
+ };
+
+ const subject = (value = {}) =>
+ render(
+
+
+
+
+
+
+ ,
+ );
+
+ describe('where the last audit completed successfully', () => {
+ const audit = entityWebsite.lastAudit as AuditCompleted;
+
+ it('renders the performance data for the audit', async () => {
+ const { findByText } = subject();
+ expect(await findByText(audit.url)).toBeInTheDocument();
+ expect(await findByText(audit.status)).toBeInTheDocument();
+ for (const category of Object.keys(audit.categories)) {
+ const { score } = audit.categories[category as LighthouseCategoryId];
+ expect(await findByText(asPercentage(score))).toBeInTheDocument();
+ }
+ });
+
+ describe('where a category score is not a number', () => {
+ beforeEach(() => {
+ entityWebsite = { ...entityWebsite };
+ (entityWebsite.lastAudit as AuditCompleted).categories.accessibility.score = NaN;
+ });
+
+ afterEach(() => {
+ entityWebsite = websiteListResponse.items[2];
+ });
+
+ it('renders the performance data for the audit', async () => {
+ const { findByText } = subject();
+ expect(await findByText('N/A')).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('where the last audit is in running', () => {
+ const audit = websiteListResponse.items[0].lastAudit as AuditCompleted;
+
+ beforeEach(() => {
+ (useWebsiteForEntity as jest.Mock).mockReturnValue({
+ value: websiteListResponse.items[0],
+ loading: false,
+ error: null,
+ });
+ });
+
+ it('renders the url and status of the audit', async () => {
+ const { findByText } = subject();
+ expect(await findByText(audit.url)).toBeInTheDocument();
+ expect(await findByText(audit.status)).toBeInTheDocument();
+ });
+ });
+
+ describe('where the data is loading', () => {
+ beforeEach(() => {
+ (useWebsiteForEntity as jest.Mock).mockReturnValue({
+ value: null,
+ loading: true,
+ error: null,
+ });
+ });
+
+ it('renders a Progress element', async () => {
+ const { findByTestId } = subject();
+ expect(await findByTestId('progress')).toBeInTheDocument();
+ });
+ });
+
+ describe('where there is an error loading data', () => {
+ beforeEach(() => {
+ (useWebsiteForEntity as jest.Mock).mockReturnValue({
+ value: null,
+ loading: false,
+ error: 'error',
+ });
+ });
+
+ it('renders nothing', async () => {
+ const { queryByTestId } = subject();
+ expect(await queryByTestId('AuditListTable')).toBeNull();
+ });
+ });
+ //
+ describe('where there is no data', () => {
+ beforeEach(() => {
+ (useWebsiteForEntity as jest.Mock).mockReturnValue({
+ value: null,
+ loading: false,
+ error: null,
+ });
+ });
+
+ it('renders nothing', async () => {
+ const { queryByTestId } = subject();
+ expect(await queryByTestId('AuditListTable')).toBeNull();
+ });
+ });
+});
diff --git a/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.tsx b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.tsx
new file mode 100644
index 0000000000..d3bc362877
--- /dev/null
+++ b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.tsx
@@ -0,0 +1,109 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React, { FC } from 'react';
+import { Audit, AuditCompleted, LighthouseCategoryId } from '../../api';
+import {
+ InfoCard,
+ Progress,
+ StatusError,
+ StatusOK,
+ StatusWarning,
+ StructuredMetadataTable,
+} from '@backstage/core';
+import { useWebsiteForEntity } from '../../hooks/useWebsiteForEntity';
+import AuditStatusIcon from '../AuditStatusIcon';
+
+const LighthouseCategoryScoreStatus: FC<{ score: number }> = ({ score }) => {
+ const scoreAsPercentage = score * 100;
+ switch (true) {
+ case scoreAsPercentage >= 90:
+ return (
+ <>
+
+ {scoreAsPercentage}%
+ >
+ );
+ case scoreAsPercentage >= 50 && scoreAsPercentage < 90:
+ return (
+ <>
+
+ {scoreAsPercentage}%
+ >
+ );
+ case scoreAsPercentage < 50:
+ return (
+ <>
+
+ {scoreAsPercentage}%
+ >
+ );
+ default:
+ return N/A;
+ }
+};
+
+const LighthouseAuditStatus: FC<{ audit: Audit }> = ({ audit }) => (
+ <>
+
+ {audit.status.toUpperCase()}
+ >
+);
+
+const LighthouseAuditSummary: FC<{ audit: Audit; dense?: boolean }> = ({
+ audit,
+ dense = false,
+}) => {
+ const { url } = audit;
+ const flattenedCategoryData: Record = {};
+ if (audit.status === 'COMPLETED') {
+ const categories = (audit as AuditCompleted).categories;
+ const categoryIds = Object.keys(categories) as LighthouseCategoryId[];
+ categoryIds.forEach((id: LighthouseCategoryId) => {
+ const { title, score } = categories[id];
+
+ flattenedCategoryData[title] = (
+
+ );
+ });
+ }
+ const tableData = {
+ url,
+ status: ,
+ ...flattenedCategoryData,
+ };
+
+ return ;
+};
+
+export const LastLighthouseAuditCard: FC<{ dense?: boolean }> = ({
+ dense = false,
+}) => {
+ const { value: website, loading, error } = useWebsiteForEntity();
+
+ let content;
+ if (loading) {
+ content = ;
+ }
+ if (error) {
+ content = null;
+ }
+ if (website) {
+ content = (
+
+ );
+ }
+ return {content};
+};
diff --git a/plugins/lighthouse/src/components/Cards/index.ts b/plugins/lighthouse/src/components/Cards/index.ts
new file mode 100644
index 0000000000..7595619808
--- /dev/null
+++ b/plugins/lighthouse/src/components/Cards/index.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export { LastLighthouseAuditCard } from './LastLighthouseAuditCard';
diff --git a/plugins/lighthouse/src/components/CreateAudit/index.tsx b/plugins/lighthouse/src/components/CreateAudit/index.tsx
index 36bd97485a..ad46b06020 100644
--- a/plugins/lighthouse/src/components/CreateAudit/index.tsx
+++ b/plugins/lighthouse/src/components/CreateAudit/index.tsx
@@ -53,7 +53,7 @@ const useStyles = makeStyles(theme => ({
},
}));
-const CreateAudit: FC<{}> = () => {
+export const CreateAuditContent: FC<{}> = () => {
const errorApi = useApi(errorApiRef);
const lighthouseApi = useApi(lighthouseApiRef);
const classes = useStyles();
@@ -94,88 +94,91 @@ const CreateAudit: FC<{}> = () => {
]);
return (
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
+
+ >
);
};
+const CreateAudit = () => (
+
+
+
+
+
+
+);
+
export default CreateAudit;
diff --git a/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx b/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx
new file mode 100644
index 0000000000..868e26f04c
--- /dev/null
+++ b/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React from 'react';
+import { renderHook } from '@testing-library/react-hooks';
+import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core-api';
+import { lighthouseApiRef, WebsiteListResponse } from '../api';
+import { useWebsiteForEntity } from './useWebsiteForEntity';
+import { EntityContext } from '@backstage/plugin-catalog';
+import { Entity } from '@backstage/catalog-model';
+import * as data from '../__fixtures__/website-list-response.json';
+
+const websiteListResponse = data as WebsiteListResponse;
+const website = websiteListResponse.items[0];
+
+const mockErrorApi: jest.Mocked = {
+ post: jest.fn(),
+ error$: jest.fn(),
+};
+
+const mockLighthouseApi: jest.Mocked> = {
+ getWebsiteByUrl: jest.fn(),
+};
+
+describe('useWebsiteForEntity', () => {
+ const entity: Entity = {
+ apiVersion: 'v1',
+ kind: 'Component',
+ metadata: {
+ name: 'software',
+ annotations: {
+ 'lighthouse.com/website-url': website.url,
+ },
+ },
+ spec: {
+ owner: 'guest',
+ type: 'Website',
+ lifecycle: 'development',
+ },
+ };
+
+ const wrapper: React.FC<{}> = ({ children }) => {
+ return (
+
+
+ {children}
+
+
+ );
+ };
+
+ const subject = () =>
+ renderHook(useWebsiteForEntity, {
+ wrapper,
+ });
+
+ beforeEach(() => {
+ (mockLighthouseApi.getWebsiteByUrl as jest.Mock).mockResolvedValue(website);
+ });
+
+ it('returns the lighthouse information for the website url in annotations ', async () => {
+ const { result, waitForNextUpdate } = subject();
+ await waitForNextUpdate();
+ expect(result.current?.value).toBe(website);
+ });
+
+ describe('where there is an error', () => {
+ const error = new Error('useWebsiteForEntity unit test');
+
+ beforeEach(() => {
+ (mockLighthouseApi.getWebsiteByUrl as jest.Mock).mockRejectedValueOnce(
+ error,
+ );
+ });
+
+ it('posts the error to the error api and returns the error to the caller', async () => {
+ const { result, waitForNextUpdate } = subject();
+ await waitForNextUpdate();
+ expect(result.current?.error).toBe(error);
+ expect(mockErrorApi.post).toHaveBeenCalledWith(error);
+ });
+ });
+});
diff --git a/plugins/lighthouse/src/hooks/useWebsiteForEntity.ts b/plugins/lighthouse/src/hooks/useWebsiteForEntity.ts
new file mode 100644
index 0000000000..c52e38f473
--- /dev/null
+++ b/plugins/lighthouse/src/hooks/useWebsiteForEntity.ts
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { useEntity } from '@backstage/plugin-catalog';
+import { LIGHTHOUSE_WEBSITE_URL_ANNOTATION } from '../../constants';
+import { errorApiRef, useApi } from '@backstage/core-api';
+import { lighthouseApiRef } from '../api';
+import { useAsync } from 'react-use';
+
+// For the sake of simplicity we assume that an entity has only one website url. This is to avoid encoding a list
+// type in an annotation which is a plain string.
+export const useWebsiteForEntity = () => {
+ const { entity } = useEntity();
+ const websiteUrl =
+ entity.metadata.annotations?.[LIGHTHOUSE_WEBSITE_URL_ANNOTATION] ?? '';
+ const lighthouseApi = useApi(lighthouseApiRef);
+ const errorApi = useApi(errorApiRef);
+ const response = useAsync(() => lighthouseApi.getWebsiteByUrl(websiteUrl), [
+ websiteUrl,
+ ]);
+ if (response.error) {
+ errorApi.post(response.error);
+ }
+ return response;
+};
diff --git a/plugins/lighthouse/src/index.ts b/plugins/lighthouse/src/index.ts
index bebdaaf713..64fe2f8cc0 100644
--- a/plugins/lighthouse/src/index.ts
+++ b/plugins/lighthouse/src/index.ts
@@ -15,5 +15,6 @@
*/
export { plugin } from './plugin';
-export { Router } from './Router';
+export { Router, isPluginApplicableToEntity, EmbeddedRouter } from './Router';
export * from './api';
+export * from './components/Cards';
diff --git a/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx b/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx
index 861cb7b275..6561e5153d 100644
--- a/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx
+++ b/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx
@@ -49,12 +49,11 @@ export const JobStatusModal = ({
useEffect(() => {
if (jobStatus === 'COMPLETED') {
- onComplete(job!);
setDialogTitle('Successfully created component');
- } else if (jobStatus === 'FAILED') {
+ onComplete(job!);
+ } else if (jobStatus === 'FAILED')
setDialogTitle('Failed to create component');
- }
- }, [jobStatus, onComplete]); // eslint-disable-line react-hooks/exhaustive-deps
+ }, [jobStatus, onComplete, setDialogTitle]);
return (