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/backstage_overview.png b/backstage_overview.png deleted file mode 100644 index ae32ae6628..0000000000 Binary files a/backstage_overview.png and /dev/null differ diff --git a/docs/getting-started/create-an-app.md b/docs/getting-started/create-an-app.md index 831e8af145..9be0e24452 100644 --- a/docs/getting-started/create-an-app.md +++ b/docs/getting-started/create-an-app.md @@ -74,6 +74,7 @@ app. ``` app ├── app-config.yaml +├── catalog-info.yaml ├── lerna.json ├── package.json └── packages @@ -83,6 +84,9 @@ app - **app-config.yaml**: Main configuration file for the app. See [Configuration](https://backstage.io/docs/conf/) for more information. +- **catalog-info.yaml**: Catalog Entities descriptors. See + [Descriptor Format of Catalog Entities](https://backstage.io/docs/features/software-catalog/descriptor-format) + to get started. - **lerna.json**: Contains information about workspaces and other lerna configuration needed for the monorepo setup. - **package.json**: Root package.json for the project. _Note: Be sure that you 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={} /> + } + /> (
- +
); export const MediumProgress = () => (
- +
); export const LowProgress = () => (
- +
); export const InverseLowProgress = () => (
- +
); export const AbsoluteProgress = () => (
- +
); diff --git a/packages/core/src/components/ProgressBars/GaugeProgress.test.jsx b/packages/core/src/components/ProgressBars/Gauge.test.jsx similarity index 82% rename from packages/core/src/components/ProgressBars/GaugeProgress.test.jsx rename to packages/core/src/components/ProgressBars/Gauge.test.jsx index 778abdf12c..709de7b0c4 100644 --- a/packages/core/src/components/ProgressBars/GaugeProgress.test.jsx +++ b/packages/core/src/components/ProgressBars/Gauge.test.jsx @@ -17,32 +17,32 @@ import React from 'react'; import { render } from '@testing-library/react'; import { wrapInTestApp } from '@backstage/test-utils'; -import { GaugeProgress, getProgressColor } from './GaugeProgress'; +import { Gauge, getProgressColor } from './Gauge'; -describe('', () => { +describe('', () => { it('renders without exploding', () => { const { getByText } = render( - wrapInTestApp(), + wrapInTestApp(), ); getByText('10%'); }); it('handles fractional prop', () => { const { getByText } = render( - wrapInTestApp(), + wrapInTestApp(), ); getByText('10%'); }); it('handles max prop', () => { const { getByText } = render( - wrapInTestApp(), + wrapInTestApp(), ); getByText('1%'); }); it('handles unit prop', () => { const { getByText } = render( - wrapInTestApp(), + wrapInTestApp(), ); getByText('10m'); }); diff --git a/packages/core/src/components/ProgressBars/GaugeProgress.tsx b/packages/core/src/components/ProgressBars/Gauge.tsx similarity index 98% rename from packages/core/src/components/ProgressBars/GaugeProgress.tsx rename to packages/core/src/components/ProgressBars/Gauge.tsx index 14776ed431..4c339bf8e1 100644 --- a/packages/core/src/components/ProgressBars/GaugeProgress.tsx +++ b/packages/core/src/components/ProgressBars/Gauge.tsx @@ -77,7 +77,7 @@ export function getProgressColor( return palette.status.ok; } -export const GaugeProgress: FC = props => { +export const Gauge: FC = props => { const classes = useStyles(props); const theme = useTheme(); const { value, fractional, inverse, unit, max } = { diff --git a/packages/core/src/components/ProgressBars/GaugeCard.tsx b/packages/core/src/components/ProgressBars/GaugeCard.tsx index fc7055f705..4eb4b2e075 100644 --- a/packages/core/src/components/ProgressBars/GaugeCard.tsx +++ b/packages/core/src/components/ProgressBars/GaugeCard.tsx @@ -18,7 +18,7 @@ import React, { FC } from 'react'; import { makeStyles } from '@material-ui/core'; import { InfoCard } from '../../layout/InfoCard'; import { BottomLinkProps } from '../../layout/BottomLink'; -import { GaugeProgress } from './GaugeProgress'; +import { Gauge } from './Gauge'; type Props = { title: string; @@ -48,7 +48,7 @@ export const GaugeCard: FC = props => { deepLink={deepLink} variant={variant} > - + ); diff --git a/packages/core/src/components/ProgressBars/LinearGauge.tsx b/packages/core/src/components/ProgressBars/LinearGauge.tsx index 73163b345f..2b8e77838d 100644 --- a/packages/core/src/components/ProgressBars/LinearGauge.tsx +++ b/packages/core/src/components/ProgressBars/LinearGauge.tsx @@ -19,7 +19,7 @@ import { Tooltip, useTheme } from '@material-ui/core'; // @ts-ignore import { Line } from 'rc-progress'; import { BackstageTheme } from '@backstage/theme'; -import { getProgressColor } from './GaugeProgress'; +import { getProgressColor } from './Gauge'; type Props = { /** diff --git a/packages/core/src/components/ProgressBars/index.ts b/packages/core/src/components/ProgressBars/index.ts index c7131c8831..4463aea29b 100644 --- a/packages/core/src/components/ProgressBars/index.ts +++ b/packages/core/src/components/ProgressBars/index.ts @@ -15,5 +15,5 @@ */ export { GaugeCard } from './GaugeCard'; -export { GaugeProgress } from './GaugeProgress'; +export { Gauge } from './Gauge'; export { LinearGauge } from './LinearGauge'; diff --git a/packages/core/src/layout/ErrorPage/ErrorPage.tsx b/packages/core/src/layout/ErrorPage/ErrorPage.tsx index 71a24652ba..3d80955cc9 100644 --- a/packages/core/src/layout/ErrorPage/ErrorPage.tsx +++ b/packages/core/src/layout/ErrorPage/ErrorPage.tsx @@ -65,7 +65,12 @@ export const ErrorPage = ({ Go back ... or if you think this is a bug, please file an{' '} - issue. + + issue. + diff --git a/packages/create-app/templates/default-app/catalog-info.yaml.hbs b/packages/create-app/templates/default-app/catalog-info.yaml.hbs new file mode 100644 index 0000000000..a370902020 --- /dev/null +++ b/packages/create-app/templates/default-app/catalog-info.yaml.hbs @@ -0,0 +1,13 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: {{name}} + description: An example of a Backstage application. + # Example for optional annotations + # annotations: + # github.com/project-slug: spotify/backstage + # backstage.io/techdocs-ref: github:https://github.com/spotify/backstage.git +spec: + type: website + owner: john@example.com + lifecycle: experimental \ No newline at end of file diff --git a/plugins/catalog-backend/migrations/20201005122705_add_entity_full_name.js b/plugins/catalog-backend/migrations/20201005122705_add_entity_full_name.js new file mode 100644 index 0000000000..26cc98e74e --- /dev/null +++ b/plugins/catalog-backend/migrations/20201005122705_add_entity_full_name.js @@ -0,0 +1,61 @@ +/* + * 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. + */ + +// @ts-check + +/** + * @param {import('knex')} knex + */ +exports.up = async function up(knex) { + await knex.schema.alterTable('entities', table => { + table.text('full_name').nullable(); + }); + + await knex('entities').update({ + full_name: knex.raw( + "LOWER(kind) || ':' || LOWER(COALESCE(namespace, 'default')) || '/' || LOWER(name)", + ), + }); + + try { + await knex.schema.alterTable('entities', table => { + table.text('full_name').notNullable().alter(); + }); + } catch (e) { + // SQLite does not support alter column, ignore + } + + await knex.schema.alterTable('entities', table => { + // https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta + table.unique(['full_name'], 'entities_unique_full_name'); + table.dropUnique([], 'entities_unique_name'); + }); +}; + +/** + * @param {import('knex')} knex + */ +exports.down = async function down(knex) { + await knex.schema.alterTable('entities', table => { + // https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta + table.dropUnique([], 'entities_unique_full_name'); + table.unique(['kind', 'namespace', 'name'], 'entities_unique_name'); + }); + + await knex.schema.alterTable('entities_search', table => { + table.dropColumn('full_name'); + }); +}; diff --git a/plugins/catalog-backend/src/database/CommonDatabase.ts b/plugins/catalog-backend/src/database/CommonDatabase.ts index 9d80b33940..5327384593 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.ts @@ -383,11 +383,18 @@ export class CommonDatabase implements Database { locationId: string | undefined, entity: Entity, ): DbEntitiesRow { + const lowerKind = entity.kind.toLowerCase(); + const lowerNamespace = ( + entity.metadata.namespace || ENTITY_DEFAULT_NAMESPACE + ).toLowerCase(); + const lowerName = entity.metadata.name.toLowerCase(); + return { id: entity.metadata.uid!, location_id: locationId || null, etag: entity.metadata.etag!, generation: entity.metadata.generation!, + full_name: `${lowerKind}:${lowerNamespace}/${lowerName}`, api_version: entity.apiVersion, kind: entity.kind, name: entity.metadata.name, diff --git a/plugins/catalog-backend/src/database/types.ts b/plugins/catalog-backend/src/database/types.ts index 8aaf212583..c0f5777743 100644 --- a/plugins/catalog-backend/src/database/types.ts +++ b/plugins/catalog-backend/src/database/types.ts @@ -25,6 +25,7 @@ export type DbEntitiesRow = { namespace: string | null; etag: string; generation: number; + full_name: string; metadata: string; spec: string | null; }; diff --git a/plugins/catalog/src/index.ts b/plugins/catalog/src/index.ts index a6d86e8102..762f4924c0 100644 --- a/plugins/catalog/src/index.ts +++ b/plugins/catalog/src/index.ts @@ -20,6 +20,6 @@ export * from './api/types'; export * from './routes'; export { useEntityCompoundName } from './components/useEntityCompoundName'; export { Router } from './components/Router'; -export { useEntity } from './hooks/useEntity'; +export { useEntity, EntityContext } from './hooks/useEntity'; export { AboutCard } from './components/AboutCard'; export { EntityPageLayout } from './components/EntityPageLayout'; diff --git a/plugins/github-actions/src/components/Cards/Cards.tsx b/plugins/github-actions/src/components/Cards/Cards.tsx index 28783efdea..7e4d5f5fb6 100644 --- a/plugins/github-actions/src/components/Cards/Cards.tsx +++ b/plugins/github-actions/src/components/Cards/Cards.tsx @@ -60,7 +60,10 @@ const WidgetContent = ({ metadata={{ status: ( <> - + ), message: lastRun.message, diff --git a/plugins/github-actions/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx b/plugins/github-actions/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx index fd2cc69376..7714ad6cb6 100644 --- a/plugins/github-actions/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx +++ b/plugins/github-actions/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx @@ -106,7 +106,10 @@ const StepView = ({ step }: { step: Step }) => { /> - + ); @@ -204,7 +207,10 @@ export const WorkflowRunDetails = ({ entity }: { entity: Entity }) => { Status - + diff --git a/plugins/github-actions/src/components/WorkflowRunStatus/WorkflowRunStatus.tsx b/plugins/github-actions/src/components/WorkflowRunStatus/WorkflowRunStatus.tsx index 089c319802..b1a71ea3ed 100644 --- a/plugins/github-actions/src/components/WorkflowRunStatus/WorkflowRunStatus.tsx +++ b/plugins/github-actions/src/components/WorkflowRunStatus/WorkflowRunStatus.tsx @@ -14,13 +14,22 @@ * limitations under the License. */ -import { StatusPending, StatusRunning, StatusOK } from '@backstage/core'; +import { + StatusPending, + StatusRunning, + StatusOK, + StatusWarning, + StatusAborted, + StatusError, +} from '@backstage/core'; import React from 'react'; export const WorkflowRunStatus = ({ status, + conclusion, }: { status: string | undefined; + conclusion: string | undefined; }) => { if (status === undefined) return null; switch (status.toLowerCase()) { @@ -37,11 +46,32 @@ export const WorkflowRunStatus = ({ ); case 'completed': - return ( - <> - Completed - - ); + switch (conclusion?.toLowerCase()) { + case 'skipped' || 'canceled': + return ( + <> + Aborted + + ); + case 'timed_out': + return ( + <> + Timed out + + ); + case 'failure': + return ( + <> + Error + + ); + default: + return ( + <> + Completed + + ); + } default: return ( <> diff --git a/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx b/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx index 4e99be6926..fcde5fefbb 100644 --- a/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx +++ b/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx @@ -46,6 +46,7 @@ export type WorkflowRun = { }; }; status: string; + conclusion: string; onReRunClick: () => void; }; @@ -84,7 +85,7 @@ const generatedColumns: TableColumn[] = [ render: (row: Partial) => ( - + ), }, diff --git a/plugins/github-actions/src/components/useWorkflowRuns.ts b/plugins/github-actions/src/components/useWorkflowRuns.ts index 25ede3f997..d0be852beb 100644 --- a/plugins/github-actions/src/components/useWorkflowRuns.ts +++ b/plugins/github-actions/src/components/useWorkflowRuns.ts @@ -87,6 +87,7 @@ export function useWorkflowRuns({ }, }, status: run.status, + conclusion: run.conclusion, url: run.url, githubUrl: run.html_url, })); diff --git a/plugins/kubernetes-backend/README.md b/plugins/kubernetes-backend/README.md index 0246da24ed..fbbde1fe65 100644 --- a/plugins/kubernetes-backend/README.md +++ b/plugins/kubernetes-backend/README.md @@ -6,6 +6,66 @@ This is the backend part of the Kubernetes plugin. It responds to Kubernetes requests from the frontend. -## Links +## Configuration -- [The Backstage homepage](https://backstage.io) +### clusterLocatorMethod + +This configures how to determine which clusters a component is running in. + +Currently, the only valid locator method is: + +#### configMultiTenant + +This configuration assumes that all components run on all the provided clusters. + +Example: + +```yaml +kubernetes: + clusterLocatorMethod: 'configMultiTenant' + clusters: + - url: http://127.0.0.1:9999 + name: minikube + serviceAccountToken: + 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-backend/src/cluster-locator/MultiTenantConfigClusterLocator.test.ts b/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.test.ts index 34788d16cb..dd77a11bae 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.test.ts @@ -43,6 +43,7 @@ describe('MultiTenantConfigClusterLocator', () => { { name: 'cluster1', url: 'http://localhost:8080', + authProvider: 'serviceAccount', }, ], }, @@ -60,6 +61,7 @@ describe('MultiTenantConfigClusterLocator', () => { name: 'cluster1', serviceAccountToken: undefined, url: 'http://localhost:8080', + authProvider: 'serviceAccount', }, ]); }); @@ -70,13 +72,14 @@ describe('MultiTenantConfigClusterLocator', () => { clusters: [ { name: 'cluster1', - serviceAccountToken: undefined, + serviceAccountToken: 'token', url: 'http://localhost:8080', + authProvider: 'serviceAccount', }, { name: 'cluster2', - serviceAccountToken: undefined, url: 'http://localhost:8081', + authProvider: 'google', }, ], }, @@ -92,13 +95,15 @@ describe('MultiTenantConfigClusterLocator', () => { expect(result).toStrictEqual([ { name: 'cluster1', - serviceAccountToken: undefined, + serviceAccountToken: 'token', url: 'http://localhost:8080', + authProvider: 'serviceAccount', }, { name: 'cluster2', serviceAccountToken: undefined, url: 'http://localhost:8081', + authProvider: 'google', }, ]); }); diff --git a/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.ts index 3d9e2dd8b4..5a1f0f3839 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.ts @@ -28,12 +28,15 @@ export class MultiTenantConfigClusterLocator } static fromConfig(config: Config[]): MultiTenantConfigClusterLocator { + // TODO: Add validation that authProvider is required and serviceAccountToken + // is required if authProvider is serviceAccount return new MultiTenantConfigClusterLocator( config.map(c => { return { name: c.getString('name'), url: c.getString('url'), serviceAccountToken: c.getOptionalString('serviceAccountToken'), + authProvider: c.getString('authProvider'), }; }), ); diff --git a/plugins/kubernetes-backend/src/cluster-locator/types.ts b/plugins/kubernetes-backend/src/cluster-locator/types.ts index 9b793da558..dae8eb6d60 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/types.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/types.ts @@ -15,3 +15,4 @@ */ export type ClusterLocatorMethod = 'configMultiTenant' | 'http'; +export type AuthProviderType = 'google' | 'serviceAccount'; diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/GoogleKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/GoogleKubernetesAuthTranslator.ts new file mode 100644 index 0000000000..7f9b2f0bae --- /dev/null +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/GoogleKubernetesAuthTranslator.ts @@ -0,0 +1,41 @@ +/* + * 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 { KubernetesAuthTranslator } from './types'; +import { AuthRequestBody, ClusterDetails } from '../types/types'; + +export class GoogleKubernetesAuthTranslator + implements KubernetesAuthTranslator { + async decorateClusterDetailsWithAuth( + clusterDetails: ClusterDetails, + requestBody: AuthRequestBody, + ): Promise { + const clusterDetailsWithAuthToken: ClusterDetails = Object.assign( + {}, + clusterDetails, + ); + const authToken: string | undefined = requestBody.auth?.google; + + if (authToken) { + clusterDetailsWithAuthToken.serviceAccountToken = authToken; + } else { + throw new Error( + 'Google token not found under auth.google in request body', + ); + } + return clusterDetailsWithAuthToken; + } +} diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts new file mode 100644 index 0000000000..3b27eb012a --- /dev/null +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts @@ -0,0 +1,46 @@ +/* + * 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 { KubernetesAuthTranslator } from './types'; +import { GoogleKubernetesAuthTranslator } from './GoogleKubernetesAuthTranslator'; +import { KubernetesAuthTranslatorGenerator } from './KubernetesAuthTranslatorGenerator'; +import { ServiceAccountKubernetesAuthTranslator } from './ServiceAccountKubernetesAuthTranslator'; + +describe('getKubernetesAuthTranslatorInstance', () => { + const sut = KubernetesAuthTranslatorGenerator; + + it('can return an auth translator for google auth', () => { + const authTranslator: KubernetesAuthTranslator = sut.getKubernetesAuthTranslatorInstance( + 'google', + ); + expect(authTranslator instanceof GoogleKubernetesAuthTranslator).toBe(true); + }); + + it('can return an auth translator for serviceAccount auth', () => { + const authTranslator: KubernetesAuthTranslator = sut.getKubernetesAuthTranslatorInstance( + 'serviceAccount', + ); + expect( + authTranslator instanceof ServiceAccountKubernetesAuthTranslator, + ).toBe(true); + }); + + it('throws an error when asked for an auth translator for an unsupported auth type', () => { + expect(() => sut.getKubernetesAuthTranslatorInstance('linode')).toThrow( + 'authProvider "linode" has no KubernetesAuthTranslator associated with it', + ); + }); +}); diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts new file mode 100644 index 0000000000..f7cfc92112 --- /dev/null +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts @@ -0,0 +1,39 @@ +/* + * 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 { KubernetesAuthTranslator } from './types'; +import { GoogleKubernetesAuthTranslator } from './GoogleKubernetesAuthTranslator'; +import { ServiceAccountKubernetesAuthTranslator } from './ServiceAccountKubernetesAuthTranslator'; + +export class KubernetesAuthTranslatorGenerator { + static getKubernetesAuthTranslatorInstance( + authProvider: String, + ): KubernetesAuthTranslator { + switch (authProvider) { + case 'google': { + return new GoogleKubernetesAuthTranslator(); + } + case 'serviceAccount': { + return new ServiceAccountKubernetesAuthTranslator(); + } + default: { + throw new Error( + `authProvider "${authProvider}" has no KubernetesAuthTranslator associated with it`, + ); + } + } + } +} diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/ServiceAccountKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/ServiceAccountKubernetesAuthTranslator.ts new file mode 100644 index 0000000000..ecf2f12b72 --- /dev/null +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/ServiceAccountKubernetesAuthTranslator.ts @@ -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 { KubernetesAuthTranslator } from './types'; +import { AuthRequestBody, ClusterDetails } from '../types/types'; + +export class ServiceAccountKubernetesAuthTranslator + implements KubernetesAuthTranslator { + async decorateClusterDetailsWithAuth( + clusterDetails: ClusterDetails, + // To ignore TS6133 linting error where it detects 'requestBody' is declared but its value is never read. + // @ts-ignore-start + requestBody: AuthRequestBody, // eslint-disable-line @typescript-eslint/no-unused-vars + // @ts-ignore-end + ): Promise { + return clusterDetails; + } +} diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/types.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/types.ts new file mode 100644 index 0000000000..f89e04456f --- /dev/null +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/types.ts @@ -0,0 +1,24 @@ +/* + * 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 { AuthRequestBody, ClusterDetails } from '../types/types'; + +export interface KubernetesAuthTranslator { + decorateClusterDetailsWithAuth( + clusterDetails: ClusterDetails, + requestBody: AuthRequestBody, + ): Promise; +} diff --git a/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts b/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts index a3dc67716a..409d41b783 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts @@ -33,6 +33,7 @@ describe('KubernetesClientProvider', () => { name: 'cluster-name', url: 'http://localhost:9999', serviceAccountToken: 'TOKEN', + authProvider: 'serviceAccount', }); expect(result.basePath).toBe('http://localhost:9999'); @@ -55,12 +56,14 @@ describe('KubernetesClientProvider', () => { name: 'cluster-name', url: 'http://localhost:9999', serviceAccountToken: 'TOKEN', + authProvider: 'serviceAccount', }); const result2 = sut.getCoreClientByClusterDetails({ name: 'cluster-name', url: 'http://localhost:9999', serviceAccountToken: 'TOKEN', + authProvider: 'serviceAccount', }); expect(result1.basePath).toBe('http://localhost:9999'); @@ -89,6 +92,7 @@ describe('KubernetesClientProvider', () => { name: 'cluster-name', url: 'http://localhost:9999', serviceAccountToken: 'TOKEN', + authProvider: 'serviceAccount', }); expect(result.basePath).toBe('http://localhost:9999'); @@ -111,12 +115,14 @@ describe('KubernetesClientProvider', () => { name: 'cluster-name', url: 'http://localhost:9999', serviceAccountToken: 'TOKEN', + authProvider: 'serviceAccount', }); const result2 = sut.getAppsClientByClusterDetails({ name: 'cluster-name', url: 'http://localhost:9999', serviceAccountToken: 'TOKEN', + authProvider: 'serviceAccount', }); expect(result1.basePath).toBe('http://localhost:9999'); diff --git a/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts b/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts index 8f390f309b..6294b3a7ed 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts @@ -62,7 +62,8 @@ describe('KubernetesClientProvider', () => { { name: 'cluster1', url: 'http://localhost:9999', - serviceAccountToken: undefined, + serviceAccountToken: 'token', + authProvider: 'serviceAccount', }, new Set(['pods', 'services']), ); @@ -124,7 +125,8 @@ describe('KubernetesClientProvider', () => { { name: 'cluster1', url: 'http://localhost:9999', - serviceAccountToken: undefined, + serviceAccountToken: 'token', + authProvider: 'serviceAccount', }, new Set(['pods', 'services']), ); @@ -172,7 +174,8 @@ describe('KubernetesClientProvider', () => { { name: 'cluster1', url: 'http://localhost:9999', - serviceAccountToken: undefined, + serviceAccountToken: 'token', + authProvider: 'serviceAccount', }, new Set(['foo']), ), diff --git a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts b/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts index 56da3235cc..4218c64e80 100644 --- a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts +++ b/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts @@ -74,6 +74,7 @@ describe('handleGetKubernetesObjectsByServiceId', () => { Promise.resolve([ { name: 'test-cluster', + authProvider: 'serviceAccount', }, ]), ); @@ -89,6 +90,7 @@ describe('handleGetKubernetesObjectsByServiceId', () => { getClusterByServiceId, }, getVoidLogger(), + {}, ); expect(getClusterByServiceId.mock.calls.length).toBe(1); @@ -142,9 +144,11 @@ describe('handleGetKubernetesObjectsByServiceId', () => { Promise.resolve([ { name: 'test-cluster', + authProvider: 'serviceAccount', }, { name: 'other-cluster', + authProvider: 'google', }, ]), ); @@ -160,6 +164,11 @@ describe('handleGetKubernetesObjectsByServiceId', () => { getClusterByServiceId, }, getVoidLogger(), + { + auth: { + google: 'google_token_123', + }, + }, ); expect(getClusterByServiceId.mock.calls.length).toBe(1); diff --git a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts b/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts index 1690b8be40..f0b21cbc0c 100644 --- a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts +++ b/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts @@ -16,17 +16,22 @@ import { Logger } from 'winston'; import { + AuthRequestBody, + ClusterDetails, KubernetesClusterLocator, KubernetesFetcher, KubernetesObjectTypes, ObjectsByServiceIdResponse, -} from '..'; +} from '../types/types'; +import { KubernetesAuthTranslator } from '../kubernetes-auth-translator/types'; +import { KubernetesAuthTranslatorGenerator } from '../kubernetes-auth-translator/KubernetesAuthTranslatorGenerator'; export type GetKubernetesObjectsByServiceIdHandler = ( serviceId: string, fetcher: KubernetesFetcher, clusterLocator: KubernetesClusterLocator, logger: Logger, + requestBody: AuthRequestBody, objectsToFetch?: Set, ) => Promise; @@ -46,18 +51,35 @@ export const handleGetKubernetesObjectsByServiceId: GetKubernetesObjectsByServic fetcher, clusterLocator, logger, + requestBody, objectsToFetch = DEFAULT_OBJECTS, ) => { - const clusterDetails = await clusterLocator.getClusterByServiceId(serviceId); + const clusterDetails: ClusterDetails[] = await clusterLocator.getClusterByServiceId( + serviceId, + ); + + // Execute all of these async actions simultaneously/without blocking sequentially as no common object is modified by them + const promises: Promise[] = clusterDetails.map(cd => { + const kubernetesAuthTranslator: KubernetesAuthTranslator = KubernetesAuthTranslatorGenerator.getKubernetesAuthTranslatorInstance( + cd.authProvider, + ); + return kubernetesAuthTranslator.decorateClusterDetailsWithAuth( + cd, + requestBody, + ); + }); + const clusterDetailsDecoratedForAuth: ClusterDetails[] = await Promise.all( + promises, + ); logger.info( - `serviceId=${serviceId} clusterDetails=[${clusterDetails + `serviceId=${serviceId} clusterDetails=[${clusterDetailsDecoratedForAuth .map(c => c.name) .join(', ')}]`, ); return Promise.all( - clusterDetails.map(cd => { + clusterDetailsDecoratedForAuth.map(cd => { return fetcher .fetchObjectsByServiceId(serviceId, cd, objectsToFetch) .then(result => { diff --git a/plugins/kubernetes-backend/src/service/router.test.ts b/plugins/kubernetes-backend/src/service/router.test.ts index 7731fe9abb..4e9a206eb4 100644 --- a/plugins/kubernetes-backend/src/service/router.test.ts +++ b/plugins/kubernetes-backend/src/service/router.test.ts @@ -54,8 +54,8 @@ describe('router', () => { jest.resetAllMocks(); }); - describe('GET /services/:serviceId', () => { - it('happy path: lists kubernetes objects', async () => { + describe('post /services/:serviceId', () => { + it('happy path: lists kubernetes objects without auth in request body', async () => { const result = { clusterOne: { pods: [ @@ -69,7 +69,34 @@ describe('router', () => { } as any; handleGetByServiceId.mockReturnValueOnce(Promise.resolve(result)); - const response = await request(app).get('/services/test-service'); + const response = await request(app).post('/services/test-service'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual(result); + }); + + it('happy path: lists kubernetes objects with auth in request body', async () => { + const result = { + clusterOne: { + pods: [ + { + metadata: { + name: 'pod1', + }, + }, + ], + }, + } as any; + handleGetByServiceId.mockReturnValueOnce(Promise.resolve(result)); + + const response = await request(app) + .post('/services/test-service') + .send({ + auth: { + google: 'google_token_123', + }, + }) + .set('Content-Type', 'application/json'); expect(response.status).toEqual(200); expect(response.body).toEqual(result); @@ -78,7 +105,7 @@ describe('router', () => { it('internal error: lists kubernetes objects', async () => { handleGetByServiceId.mockRejectedValue(Error('some internal error')); - const response = await request(app).get('/services/test-service'); + const response = await request(app).post('/services/test-service'); expect(response.status).toEqual(500); expect(response.body).toEqual({ error: 'some internal error' }); diff --git a/plugins/kubernetes-backend/src/service/router.ts b/plugins/kubernetes-backend/src/service/router.ts index bdb14bb74d..9e9b4ab2f5 100644 --- a/plugins/kubernetes-backend/src/service/router.ts +++ b/plugins/kubernetes-backend/src/service/router.ts @@ -26,7 +26,11 @@ import { GetKubernetesObjectsByServiceIdHandler, handleGetKubernetesObjectsByServiceId, } from './getKubernetesObjectsByServiceIdHandler'; -import { KubernetesClusterLocator, KubernetesFetcher } from '..'; +import { + AuthRequestBody, + KubernetesClusterLocator, + KubernetesFetcher, +} from '../types/types'; export interface RouterOptions { logger: Logger; @@ -62,15 +66,16 @@ export const makeRouter = ( router.use(express.json()); // TODO error handling - router.get('/services/:serviceId', async (req, res) => { + router.post('/services/:serviceId', async (req, res) => { const serviceId = req.params.serviceId; - + const requestBody: AuthRequestBody = req.body; try { const response = await handleGetByServiceId( serviceId, fetcher, clusterLocator, logger, + requestBody, ); res.send(response); } catch (e) { diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index 0cb42eec1a..eb2817f268 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -27,8 +27,14 @@ import { export interface ClusterDetails { name: string; url: string; - // TODO this will eventually be configured by the auth translation work - serviceAccountToken: string | undefined; + authProvider: string; + serviceAccountToken?: string | undefined; +} + +export interface AuthRequestBody { + auth?: { + google?: string; + }; } export interface ClusterObjects { 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/kubernetes/package.json b/plugins/kubernetes/package.json index 5dc9231c2a..2adefebbd6 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.1.1-alpha.24", + "@backstage/config": "^0.1.1-alpha.24", "@backstage/core": "^0.1.1-alpha.24", "@backstage/plugin-kubernetes-backend": "^0.1.1-alpha.24", "@backstage/theme": "^0.1.1-alpha.24", diff --git a/plugins/kubernetes/src/api/KubernetesBackendClient.ts b/plugins/kubernetes/src/api/KubernetesBackendClient.ts index cbb3b03a38..86bb830046 100644 --- a/plugins/kubernetes/src/api/KubernetesBackendClient.ts +++ b/plugins/kubernetes/src/api/KubernetesBackendClient.ts @@ -16,7 +16,10 @@ import { DiscoveryApi } from '@backstage/core'; import { KubernetesApi } from './types'; -import { ObjectsByServiceIdResponse } from '@backstage/plugin-kubernetes-backend'; +import { + AuthRequestBody, + ObjectsByServiceIdResponse, +} from '@backstage/plugin-kubernetes-backend'; export class KubernetesBackendClient implements KubernetesApi { private readonly discoveryApi: DiscoveryApi; @@ -25,9 +28,18 @@ export class KubernetesBackendClient implements KubernetesApi { this.discoveryApi = options.discoveryApi; } - private async getRequired(path: string): Promise { + private async getRequired( + path: string, + requestBody: AuthRequestBody, + ): Promise { const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}${path}`; - const response = await fetch(url); + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); if (!response.ok) { const payload = await response.text(); @@ -40,7 +52,8 @@ export class KubernetesBackendClient implements KubernetesApi { async getObjectsByServiceId( serviceId: String, + requestBody: AuthRequestBody, ): Promise { - return await this.getRequired(`/services/${serviceId}`); + return await this.getRequired(`/services/${serviceId}`, requestBody); } } diff --git a/plugins/kubernetes/src/api/types.ts b/plugins/kubernetes/src/api/types.ts index 5ec3cda6f8..8e44d58e6a 100644 --- a/plugins/kubernetes/src/api/types.ts +++ b/plugins/kubernetes/src/api/types.ts @@ -15,7 +15,10 @@ */ import { createApiRef } from '@backstage/core'; -import { ObjectsByServiceIdResponse } from '@backstage/plugin-kubernetes-backend'; +import { + AuthRequestBody, + ObjectsByServiceIdResponse, +} from '@backstage/plugin-kubernetes-backend'; export const kubernetesApiRef = createApiRef({ id: 'plugin.kubernetes.service', @@ -24,5 +27,8 @@ export const kubernetesApiRef = createApiRef({ }); export interface KubernetesApi { - getObjectsByServiceId(serviceId: String): Promise; + getObjectsByServiceId( + serviceId: String, + requestBody: AuthRequestBody, + ): Promise; } diff --git a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx index 6119884c43..013c7ad002 100644 --- a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx +++ b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx @@ -16,8 +16,10 @@ import React, { ReactElement, useEffect, useState } from 'react'; import { Grid, TabProps } from '@material-ui/core'; +import { Config } from '@backstage/config'; import { CardTab, + configApiRef, Content, Page, pageTheme, @@ -28,10 +30,12 @@ import { import { Entity } from '@backstage/catalog-model'; import { kubernetesApiRef } from '../../api/types'; import { + AuthRequestBody, ClusterObjects, FetchResponse, ObjectsByServiceIdResponse, } from '@backstage/plugin-kubernetes-backend'; +import { kubernetesAuthProvidersApiRef } from '../../kubernetes-auth-provider/types'; import { DeploymentTables } from '../DeploymentTables'; import { DeploymentTriple } from '../../types/types'; import { @@ -105,16 +109,40 @@ export const KubernetesContent = ({ entity }: KubernetesContentProps) => { >(undefined); const [error, setError] = useState(undefined); + const configApi = useApi(configApiRef); + const clusters: Config[] = configApi.getConfigArray('kubernetes.clusters'); + const allAuthProviders: string[] = clusters.map(c => + c.getString('authProvider'), + ); + const authProviders: string[] = [...new Set(allAuthProviders)]; + + const kubernetesAuthProvidersApi = useApi(kubernetesAuthProvidersApiRef); + useEffect(() => { - kubernetesApi - .getObjectsByServiceId(entity.metadata.name) - .then(result => { - setKubernetesObjects(result); - }) - .catch(e => { - setError(e.message); - }); - }, [entity.metadata.name, kubernetesApi]); + (async () => { + // For each auth type, invoke decorateRequestBodyForAuth on corresponding KubernetesAuthProvider + let requestBody: AuthRequestBody = {}; + for (const authProviderStr of authProviders) { + // Multiple asyncs done sequentially instead of all at once to prevent same requestBody from being modified simultaneously + requestBody = await kubernetesAuthProvidersApi.decorateRequestBodyForAuth( + authProviderStr, + requestBody, + ); + } + + // TODO: Add validation on contents/format of requestBody + kubernetesApi + .getObjectsByServiceId(entity.metadata.name, requestBody) + .then(result => { + setKubernetesObjects(result); + }) + .catch(e => { + setError(e.message); + }); + })(); + /* eslint-disable react-hooks/exhaustive-deps */ + }, [entity.metadata.name, kubernetesApi, kubernetesAuthProvidersApi]); + /* eslint-enable react-hooks/exhaustive-deps */ const clustersWithErrors = kubernetesObjects?.items.filter(r => r.errors.length > 0) ?? []; diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts new file mode 100644 index 0000000000..7de5c7c0a9 --- /dev/null +++ b/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts @@ -0,0 +1,41 @@ +/* + * 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 { OAuthApi } from '@backstage/core'; +import { KubernetesAuthProvider } from './types'; +import { AuthRequestBody } from '@backstage/plugin-kubernetes-backend'; + +export class GoogleKubernetesAuthProvider implements KubernetesAuthProvider { + authProvider: OAuthApi; + + constructor(authProvider: OAuthApi) { + this.authProvider = authProvider; + } + + async decorateRequestBodyForAuth( + requestBody: AuthRequestBody, + ): Promise { + const googleAuthToken: string = await this.authProvider.getAccessToken( + 'https://www.googleapis.com/auth/cloud-platform', + ); + if ('auth' in requestBody) { + requestBody.auth!.google = googleAuthToken; + } else { + requestBody.auth = { google: googleAuthToken }; + } + return requestBody; + } +} diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts new file mode 100644 index 0000000000..ac909d6695 --- /dev/null +++ b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts @@ -0,0 +1,57 @@ +/* + * 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 { OAuthApi } from '@backstage/core'; +import { AuthRequestBody } from '@backstage/plugin-kubernetes-backend'; +import { KubernetesAuthProvider, KubernetesAuthProvidersApi } from './types'; +import { GoogleKubernetesAuthProvider } from './GoogleKubernetesAuthProvider'; +import { ServiceAccountKubernetesAuthProvider } from './ServiceAccountKubernetesAuthProvider'; + +export class KubernetesAuthProviders implements KubernetesAuthProvidersApi { + private readonly kubernetesAuthProviderMap: Map< + string, + KubernetesAuthProvider + >; + + constructor(options: { googleAuthApi: OAuthApi }) { + this.kubernetesAuthProviderMap = new Map(); + this.kubernetesAuthProviderMap.set( + 'google', + new GoogleKubernetesAuthProvider(options.googleAuthApi), + ); + this.kubernetesAuthProviderMap.set( + 'serviceAccount', + new ServiceAccountKubernetesAuthProvider(), + ); + } + + async decorateRequestBodyForAuth( + authProvider: string, + requestBody: AuthRequestBody, + ): Promise { + const kubernetesAuthProvider: + | KubernetesAuthProvider + | undefined = this.kubernetesAuthProviderMap.get(authProvider); + if (kubernetesAuthProvider) { + return await kubernetesAuthProvider.decorateRequestBodyForAuth( + requestBody, + ); + } + throw new Error( + `authProvider "${authProvider}" has no KubernetesAuthProvider defined for it`, + ); + } +} diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/ServiceAccountKubernetesAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/ServiceAccountKubernetesAuthProvider.ts new file mode 100644 index 0000000000..3ac5a9494b --- /dev/null +++ b/plugins/kubernetes/src/kubernetes-auth-provider/ServiceAccountKubernetesAuthProvider.ts @@ -0,0 +1,28 @@ +/* + * 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 { KubernetesAuthProvider } from './types'; +import { AuthRequestBody } from '@backstage/plugin-kubernetes-backend'; + +export class ServiceAccountKubernetesAuthProvider + implements KubernetesAuthProvider { + async decorateRequestBodyForAuth( + requestBody: AuthRequestBody, + ): Promise { + // No-op, with service account for auth, cluster config/details should already have serviceAccountToken + return requestBody; + } +} diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/types.ts b/plugins/kubernetes/src/kubernetes-auth-provider/types.ts new file mode 100644 index 0000000000..24fee0f94c --- /dev/null +++ b/plugins/kubernetes/src/kubernetes-auth-provider/types.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createApiRef } from '@backstage/core'; +import { AuthRequestBody } from '@backstage/plugin-kubernetes-backend'; + +export interface KubernetesAuthProvider { + decorateRequestBodyForAuth( + requestBody: AuthRequestBody, + ): Promise; +} + +export const kubernetesAuthProvidersApiRef = createApiRef< + KubernetesAuthProvidersApi +>({ + id: 'plugin.kubernetes-auth-providers.service', + description: 'Used by the Kubernetes plugin to fetch KubernetesAuthProviders', +}); + +export interface KubernetesAuthProvidersApi { + decorateRequestBodyForAuth( + authProvider: string, + requestBody: AuthRequestBody, + ): Promise; +} diff --git a/plugins/kubernetes/src/plugin.ts b/plugins/kubernetes/src/plugin.ts index b7ad9615f5..cff9e1468b 100644 --- a/plugins/kubernetes/src/plugin.ts +++ b/plugins/kubernetes/src/plugin.ts @@ -18,9 +18,12 @@ import { createPlugin, createRouteRef, discoveryApiRef, + googleAuthApiRef, } from '@backstage/core'; import { KubernetesBackendClient } from './api/KubernetesBackendClient'; import { kubernetesApiRef } from './api/types'; +import { kubernetesAuthProvidersApiRef } from './kubernetes-auth-provider/types'; +import { KubernetesAuthProviders } from './kubernetes-auth-provider/KubernetesAuthProviders'; export const rootCatalogKubernetesRouteRef = createRouteRef({ path: '*', @@ -36,5 +39,12 @@ export const plugin = createPlugin({ factory: ({ discoveryApi }) => new KubernetesBackendClient({ discoveryApi }), }), + createApiFactory({ + api: kubernetesAuthProvidersApiRef, + deps: { googleAuthApi: googleAuthApiRef }, + factory: ({ googleAuthApi }) => { + return new KubernetesAuthProviders({ googleAuthApi }); + }, + }), ], }); 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 ( - -
+ - - -
- - - - - - - -
{ - ev.preventDefault(); - triggerAudit(); - }} - > - - - setUrl(ev.target.value)} - value={url} - inputProps={{ 'aria-label': 'URL' }} - /> - - - setEmulatedFormFactor(ev.target.value)} - value={emulatedFormFactor} - inputProps={{ 'aria-label': 'Emulated form factor' }} - > - Mobile - Desktop - - - - - - - -
-
-
+ + + + + +
{ + ev.preventDefault(); + triggerAudit(); + }} + > + + + setUrl(ev.target.value)} + value={url} + inputProps={{ 'aria-label': 'URL' }} + /> + + + setEmulatedFormFactor(ev.target.value)} + value={emulatedFormFactor} + inputProps={{ 'aria-label': 'Emulated form factor' }} + > + Mobile + Desktop + + + + + + + +
+
-
-
+ + ); }; +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/techdocs/src/reader/components/TechDocsPageHeader.tsx b/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx index b01ace4994..8084378ca1 100644 --- a/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx @@ -66,7 +66,11 @@ export const TechDocsPageHeader = ({ + } diff --git a/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx b/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx index 3ceb0af58d..1afdb53ad4 100644 --- a/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx +++ b/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx @@ -69,7 +69,10 @@ const WelcomePage = () => { the project and we have already begun work on various aspects of these phases. The best way to keep track of the progress is through the  - + Milestones . @@ -113,7 +116,10 @@ const WelcomePage = () => { We suggest you either check out the documentation for{' '} - + creating a plugin {' '} or have a look in the code for the{' '} @@ -121,7 +127,10 @@ const WelcomePage = () => { existing plugins {' '} in the directory{' '} - + plugins/ . @@ -135,7 +144,10 @@ const WelcomePage = () => { backstage.io - + Create a plugin diff --git a/yarn.lock b/yarn.lock index b9f5733acd..8b9b16ebe7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1194,7 +1194,7 @@ core-js-pure "^3.0.0" regenerator-runtime "^0.13.4" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.0", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.10.4", "@babel/runtime@^7.10.5", "@babel/runtime@^7.11.2", "@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.0", "@babel/runtime@^7.6.2", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": +"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.0", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.10.4", "@babel/runtime@^7.10.5", "@babel/runtime@^7.11.2", "@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.0", "@babel/runtime@^7.6.2", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": version "7.11.2" resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.11.2.tgz#f549c13c754cc40b87644b9fa9f09a6a95fe0736" integrity sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw== @@ -4520,6 +4520,14 @@ "@babel/runtime" "^7.5.4" "@types/testing-library__react-hooks" "^3.3.0" +"@testing-library/react-hooks@^3.4.2": + version "3.4.2" + resolved "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-3.4.2.tgz#8deb94f7684e0d896edd84a4c90e5b79a0810bc2" + integrity sha512-RfPG0ckOzUIVeIqlOc1YztKgFW+ON8Y5xaSPbiBkfj9nMkkiLhLeBXT5icfPX65oJV/zCZu4z8EVnUc6GY9C5A== + dependencies: + "@babel/runtime" "^7.5.4" + "@types/testing-library__react-hooks" "^3.4.0" + "@testing-library/react@^10.4.1": version "10.4.3" resolved "https://registry.npmjs.org/@testing-library/react/-/react-10.4.3.tgz#c6f356688cffc51f6b35385583d664bb11a161f4" @@ -5328,6 +5336,11 @@ resolved "https://registry.npmjs.org/@types/qs/-/qs-6.9.4.tgz#a59e851c1ba16c0513ea123830dd639a0a15cb6a" integrity sha512-+wYo+L6ZF6BMoEjtf8zB2esQsqdV6WsjRK/GP9WOgLPrq87PbNWgIxS76dS5uvl/QXtHGakZmwTznIfcPXcKlQ== +"@types/raf@^3.4.0": + version "3.4.0" + resolved "https://registry.npmjs.org/@types/raf/-/raf-3.4.0.tgz#2b72cbd55405e071f1c4d29992638e022b20acc2" + integrity sha512-taW5/WYqo36N7V39oYyHP9Ipfd5pNFvGTIQsNGj86xV88YQ7GnI30/yMfKDF7Zgin0m3e+ikX88FvImnK4RjGw== + "@types/range-parser@*": version "1.2.3" resolved "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c" @@ -5632,6 +5645,13 @@ dependencies: "@types/react-test-renderer" "*" +"@types/testing-library__react-hooks@^3.4.0": + version "3.4.1" + resolved "https://registry.npmjs.org/@types/testing-library__react-hooks/-/testing-library__react-hooks-3.4.1.tgz#b8d7311c6c1f7db3103e94095fe901f8fef6e433" + integrity sha512-G4JdzEcq61fUyV6wVW9ebHWEiLK2iQvaBuCHHn9eMSbZzVh4Z4wHnUGIvQOYCCYeu5DnUtFyNYuAAgbSaO/43Q== + dependencies: + "@types/react-test-renderer" "*" + "@types/through@*": version "0.0.30" resolved "https://registry.npmjs.org/@types/through/-/through-0.0.30.tgz#e0e42ce77e897bd6aead6f6ea62aeb135b8a3895" @@ -5999,11 +6019,6 @@ JSONStream@^1.0.4, JSONStream@^1.3.4: jsonparse "^1.2.0" through ">=2.2.7 <3" -abab@^1.0.0: - version "1.0.4" - resolved "https://registry.npmjs.org/abab/-/abab-1.0.4.tgz#5faad9c2c07f60dd76770f71cf025b62a63cfd4e" - integrity sha1-X6rZwsB/YN12dw9xzwJbYqY8/U4= - abab@^2.0.0, abab@^2.0.3: version "2.0.3" resolved "https://registry.npmjs.org/abab/-/abab-2.0.3.tgz#623e2075e02eb2d3f2475e49f99c91846467907a" @@ -6027,13 +6042,6 @@ accepts@^1.3.5, accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7: mime-types "~2.1.24" negotiator "0.6.2" -acorn-globals@^1.0.4: - version "1.0.9" - resolved "https://registry.npmjs.org/acorn-globals/-/acorn-globals-1.0.9.tgz#55bb5e98691507b74579d0513413217c380c54cf" - integrity sha1-VbtemGkVB7dFedBRNBMhfDgMVM8= - dependencies: - acorn "^2.1.0" - acorn-globals@^4.1.0: version "4.3.4" resolved "https://registry.npmjs.org/acorn-globals/-/acorn-globals-4.3.4.tgz#9fa1926addc11c97308c4e66d7add0d40c3272e7" @@ -6065,11 +6073,6 @@ acorn-walk@^7.1.1: resolved "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.1.1.tgz#345f0dffad5c735e7373d2fec9a1023e6a44b83e" integrity sha512-wdlPY2tm/9XBr7QkKlq0WQVgiuGTX6YWPyRyBviSoScBuLfTVQhvwg6wJ369GJ/1nPfTLMfnrFIfjqVg6d+jQQ== -acorn@^2.1.0, acorn@^2.4.0: - version "2.7.0" - resolved "https://registry.npmjs.org/acorn/-/acorn-2.7.0.tgz#ab6e7d9d886aaca8b085bc3312b79a198433f0e7" - integrity sha1-q259nYhqrKiwhbwzEreaGYQz8Oc= - acorn@^5.5.3: version "5.7.4" resolved "https://registry.npmjs.org/acorn/-/acorn-5.7.4.tgz#3e8d8a9947d0599a1796d10225d7432f4a4acf5e" @@ -7288,10 +7291,10 @@ balanced-match@^1.0.0: resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= -base64-arraybuffer@^0.1.5: - version "0.1.5" - resolved "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz#73926771923b5a19747ad666aa5cd4bf9c6e9ce8" - integrity sha1-c5JncZI7Whl0etZmqlzUv5xunOg= +base64-arraybuffer@^0.2.0: + version "0.2.0" + resolved "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.2.0.tgz#4b944fac0191aa5907afe2d8c999ccc57ce80f45" + integrity sha512-7emyCsu1/xiBXgQZrscw/8KPRT44I4Yq9Pe6EGs3aPRTsWuggML1/1DTuZUuIaJPIm1FTDUVXl4x/yW8s0kQDQ== base64-js@^1.0.2, base64-js@^1.2.0: version "1.3.1" @@ -7980,15 +7983,17 @@ canvas@^2.6.1: node-pre-gyp "^0.11.0" simple-get "^3.0.3" -canvg@1.5.3: - version "1.5.3" - resolved "https://registry.npmjs.org/canvg/-/canvg-1.5.3.tgz#aad17915f33368bf8eb80b25d129e3ae922ddc5f" - integrity sha512-7Gn2IuQzvUQWPIuZuFHrzsTM0gkPz2RRT9OcbdmA03jeKk8kltrD8gqUzNX15ghY/4PV5bbe5lmD6yDLDY6Ybg== +canvg@^3.0.6: + version "3.0.6" + resolved "https://registry.npmjs.org/canvg/-/canvg-3.0.6.tgz#4f82a34acc433daa06c494fc255420cbbb05f903" + integrity sha512-eFUy8R/4DgocR93LF8lr+YUxW4PYblUe/Q1gz2osk/cI5n8AsYdassvln0D9QPhLXQ6Lx7l8hwtT8FLvOn2Ihg== dependencies: - jsdom "^8.1.0" + "@babel/runtime" "^7.6.3" + "@types/raf" "^3.4.0" + core-js "3" + raf "^3.4.1" rgbcolor "^1.0.1" - stackblur-canvas "^1.4.1" - xmldom "^0.1.22" + stackblur-canvas "^2.0.0" capture-exit@^2.0.0: version "2.0.0" @@ -8866,16 +8871,16 @@ core-js-pure@^3.0.0, core-js-pure@^3.0.1: resolved "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.6.4.tgz#4bf1ba866e25814f149d4e9aaa08c36173506e3a" integrity sha512-epIhRLkXdgv32xIUFaaAry2wdxZYBi6bgM7cB136dzzXXa+dFyRLTZeLUJxnd8ShrmyVXBub63n2NHo2JAt8Cw== +core-js@3, core-js@^3.0.1, core-js@^3.0.4, core-js@^3.5.0, core-js@^3.6.0, core-js@^3.6.5: + version "3.6.5" + resolved "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz#7395dc273af37fb2e50e9bd3d9fe841285231d1a" + integrity sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA== + core-js@^2.4.0, core-js@^2.4.1, core-js@^2.5.7, core-js@^2.6.10, core-js@^2.6.11, core-js@^2.6.5: version "2.6.11" resolved "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c" integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg== -core-js@^3.0.1, core-js@^3.0.4, core-js@^3.5.0, core-js@^3.6.5: - version "3.6.5" - resolved "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz#7395dc273af37fb2e50e9bd3d9fe841285231d1a" - integrity sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA== - core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" @@ -9062,12 +9067,12 @@ css-in-js-utils@^2.0.0: hyphenate-style-name "^1.0.2" isobject "^3.0.1" -css-line-break@1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/css-line-break/-/css-line-break-1.0.1.tgz#19f2063a33e95fb2831b86446c0b80c188af450a" - integrity sha1-GfIGOjPpX7KDG4ZEbAuAwYivRQo= +css-line-break@1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/css-line-break/-/css-line-break-1.1.1.tgz#d5e9bdd297840099eb0503c7310fd34927a026ef" + integrity sha512-1feNVaM4Fyzdj4mKPIQNL2n70MmuYzAXZ1aytlROFX1JsOo070OsugwGjj7nl6jnDJWHDM8zRZswkmeYVWZJQA== dependencies: - base64-arraybuffer "^0.1.5" + base64-arraybuffer "^0.2.0" css-loader@^3.5.3: version "3.6.0" @@ -9268,7 +9273,7 @@ csso@^4.0.2: dependencies: css-tree "1.0.0-alpha.37" -cssom@0.3.x, "cssom@>= 0.3.0 < 0.4.0", "cssom@>= 0.3.2 < 0.4.0", cssom@~0.3.6: +cssom@0.3.x, "cssom@>= 0.3.2 < 0.4.0", cssom@~0.3.6: version "0.3.8" resolved "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz#9f1276f5b2b463f2114d3f2c75250af8c1a36f4a" integrity sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg== @@ -9278,13 +9283,6 @@ cssom@^0.4.4: resolved "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz#5a66cf93d2d0b661d80bf6a44fb65f5c2e4e0a10" integrity sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw== -"cssstyle@>= 0.2.34 < 0.3.0": - version "0.2.37" - resolved "https://registry.npmjs.org/cssstyle/-/cssstyle-0.2.37.tgz#541097234cb2513c83ceed3acddc27ff27987d54" - integrity sha1-VBCXI0yyUTyDzu06zdwn/yeYfVQ= - dependencies: - cssom "0.3.x" - cssstyle@^1.0.0: version "1.4.0" resolved "https://registry.npmjs.org/cssstyle/-/cssstyle-1.4.0.tgz#9d31328229d3c565c61e586b02041a28fccdccf1" @@ -10092,6 +10090,11 @@ dompurify@^1.0.11: resolved "https://registry.npmjs.org/dompurify/-/dompurify-1.0.11.tgz#fe0f4a40d147f7cebbe31a50a1357539cfc1eb4d" integrity sha512-XywCTXZtc/qCX3iprD1pIklRVk/uhl8BKpkTxr+ZyMVUzSUg7wkQXRBp/euJ5J5moa1QvfpvaPQVP71z1O59dQ== +dompurify@^2.0.12: + version "2.1.1" + resolved "https://registry.npmjs.org/dompurify/-/dompurify-2.1.1.tgz#b5aa988676b093a9c836d8b855680a8598af25fe" + integrity sha512-NijiNVkS/OL8mdQL1hUbCD6uty/cgFpmNiuFxrmJ5YPH2cXrPKIewoixoji56rbZ6XBPmtM8GA8/sf9unlSuwg== + dompurify@^2.0.7: version "2.0.12" resolved "https://registry.npmjs.org/dompurify/-/dompurify-2.0.12.tgz#284a2b041e1c60b8e72d7b4d2fadad36141254ae" @@ -10552,7 +10555,7 @@ escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= -escodegen@^1.14.1, escodegen@^1.6.1, escodegen@^1.9.1: +escodegen@^1.14.1, escodegen@^1.9.1: version "1.14.3" resolved "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz#4e7b81fba61581dc97582ed78cab7f0e8d63f503" integrity sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw== @@ -11347,10 +11350,6 @@ file-loader@^6.0.0: loader-utils "^2.0.0" schema-utils "^2.7.1" -file-saver@eligrey/FileSaver.js#1.3.8: - version "1.3.8" - resolved "https://codeload.github.com/eligrey/FileSaver.js/tar.gz/e865e37af9f9947ddcced76b549e27dc45c1cb2e" - file-system-cache@^1.0.5: version "1.0.5" resolved "https://registry.npmjs.org/file-system-cache/-/file-system-cache-1.0.5.tgz#84259b36a2bbb8d3d6eb1021d3132ffe64cfff4f" @@ -12795,12 +12794,12 @@ html-webpack-plugin@^4.2.1, html-webpack-plugin@^4.3.0: tapable "^1.1.3" util.promisify "1.0.0" -html2canvas@1.0.0-alpha.12: - version "1.0.0-alpha.12" - resolved "https://registry.npmjs.org/html2canvas/-/html2canvas-1.0.0-alpha.12.tgz#3b1992e3c9b3f56063c35fd620494f37eba88513" - integrity sha1-OxmS48mz9WBjw1/WIElPN+uohRM= +html2canvas@^1.0.0-rc.5: + version "1.0.0-rc.7" + resolved "https://registry.npmjs.org/html2canvas/-/html2canvas-1.0.0-rc.7.tgz#70c159ce0e63954a91169531894d08ad5627ac98" + integrity sha512-yvPNZGejB2KOyKleZspjK/NruXVQuowu8NnV2HYG7gW7ytzl+umffbtUI62v2dCHQLDdsK6HIDtyJZ0W3neerA== dependencies: - css-line-break "1.0.1" + css-line-break "1.1.1" htmlparser2@^3.3.0: version "3.10.1" @@ -13009,7 +13008,7 @@ hyphenate-style-name@^1.0.2, hyphenate-style-name@^1.0.3: resolved "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.3.tgz#097bb7fa0b8f1a9cf0bd5c734cf95899981a9b48" integrity sha512-EcuixamT82oplpoJ2XU4pDtKGWQ7b00CD9f1ug9IaQ3p1bkHMiKCZ9ut9QDI6qsa6cpUuB+A/I+zLtdNK4n2DQ== -iconv-lite@0.4.24, iconv-lite@^0.4.13, iconv-lite@^0.4.21, iconv-lite@^0.4.24, iconv-lite@^0.4.4, iconv-lite@~0.4.13: +iconv-lite@0.4.24, iconv-lite@^0.4.21, iconv-lite@^0.4.24, iconv-lite@^0.4.4, iconv-lite@~0.4.13: version "0.4.24" resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== @@ -14538,29 +14537,6 @@ jsdom@^16.2.2: ws "^7.2.3" xml-name-validator "^3.0.0" -jsdom@^8.1.0: - version "8.5.0" - resolved "https://registry.npmjs.org/jsdom/-/jsdom-8.5.0.tgz#d4d8f5dbf2768635b62a62823b947cf7071ebc98" - integrity sha1-1Nj12/J2hjW2KmKCO5R89wcevJg= - dependencies: - abab "^1.0.0" - acorn "^2.4.0" - acorn-globals "^1.0.4" - array-equal "^1.0.0" - cssom ">= 0.3.0 < 0.4.0" - cssstyle ">= 0.2.34 < 0.3.0" - escodegen "^1.6.1" - iconv-lite "^0.4.13" - nwmatcher ">= 1.3.7 < 2.0.0" - parse5 "^1.5.1" - request "^2.55.0" - sax "^1.1.4" - symbol-tree ">= 3.1.0 < 4.0.0" - tough-cookie "^2.2.0" - webidl-conversions "^3.0.1" - whatwg-url "^2.0.1" - xml-name-validator ">= 2.0.1 < 3.0.0" - jsesc@^2.5.1: version "2.5.2" resolved "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" @@ -14759,22 +14735,23 @@ jsonwebtoken@^8.5.1: ms "^2.1.1" semver "^5.6.0" -jspdf-autotable@3.5.3: - version "3.5.3" - resolved "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-3.5.3.tgz#2f73adb07f340e7dbf22950e3e6c8bf853991479" - integrity sha512-K+cNWW3x6w0R/1B5m6PYOm6v8CTTDXy/g32lZouc7SuC6zhvzMN2dauhk6dDYxPD0pky0oyPIJFwSJ/tV8PAeg== +jspdf-autotable@3.5.9: + version "3.5.9" + resolved "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-3.5.9.tgz#8a625ef2aead44271da95e9f649843c401536925" + integrity sha512-ZRfiI5P7leJuWmvC0jGVXu227m68C2Jfz1dkDckshmDYDeVFCGxwIBYdCUXJ8Eb2CyFQC2ok82fEWO+xRDovDQ== -jspdf@1.5.3: - version "1.5.3" - resolved "https://registry.npmjs.org/jspdf/-/jspdf-1.5.3.tgz#5a12c011479defabef5735de55c913060ed219f2" - integrity sha512-J9X76xnncMw+wIqb15HeWfPMqPwYxSpPY8yWPJ7rAZN/ZDzFkjCSZObryCyUe8zbrVRNiuCnIeQteCzMn7GnWw== +jspdf@2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/jspdf/-/jspdf-2.1.0.tgz#2322f8644bc41845b3abe20db4c3ca0adeadb84c" + integrity sha512-NQygqZEKhSw+nExySJxB72Ge/027YEyIM450Vh/hgay/H9cgZNnkXXOQPRspe9EuCW4sq92zg8hpAXyyBdnaIQ== dependencies: - canvg "1.5.3" - file-saver eligrey/FileSaver.js#1.3.8 - html2canvas "1.0.0-alpha.12" - omggif "1.0.7" - promise-polyfill "8.1.0" - stackblur-canvas "2.2.0" + atob "^2.1.2" + btoa "^1.2.1" + optionalDependencies: + canvg "^3.0.6" + core-js "^3.6.0" + dompurify "^2.0.12" + html2canvas "^1.0.0-rc.5" jsprim@^1.2.2: version "1.4.1" @@ -15762,10 +15739,10 @@ material-colors@^1.2.1: resolved "https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz#6d1958871126992ceecc72f4bcc4d8f010865f46" integrity sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg== -material-table@1.68.0: - version "1.68.0" - resolved "https://registry.npmjs.org/material-table/-/material-table-1.68.0.tgz#275c3d9a885c40ae4bc5a7461c00e877f92397b9" - integrity sha512-dyJJaVsS3m+i6sn71AvYcVdA1P9X1XiUOM2PekfvEeeMtkdQb66oChGkk77ndYi3Ja6j4DovGVNrgeVLwXLZiw== +material-table@^1.69.1: + version "1.69.1" + resolved "https://registry.npmjs.org/material-table/-/material-table-1.69.1.tgz#8d1c8b23207f18bd3328cae1b5775ede284682e6" + integrity sha512-7MA8kMtr8ToPE6gNUbOGIb4g+RGOLWK8s9gXZYNwFtg6fGAjWEJ+iqBrMmdq7fkMmTRcyOd7/sC/5OPPY8CNGg== dependencies: "@date-io/date-fns" "^1.1.0" "@material-ui/pickers" "^3.2.2" @@ -15774,8 +15751,8 @@ material-table@1.68.0: debounce "^1.2.0" fast-deep-equal "2.0.1" filefy "0.1.10" - jspdf "1.5.3" - jspdf-autotable "3.5.3" + jspdf "2.1.0" + jspdf-autotable "3.5.9" prop-types "^15.6.2" react-beautiful-dnd "^13.0.0" react-double-scrollbar "0.0.15" @@ -16836,11 +16813,6 @@ number-is-nan@^1.0.0: resolved "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0= -"nwmatcher@>= 1.3.7 < 2.0.0": - version "1.4.4" - resolved "https://registry.npmjs.org/nwmatcher/-/nwmatcher-1.4.4.tgz#2285631f34a95f0d0395cd900c96ed39b58f346e" - integrity sha512-3iuY4N5dhgMpCUrOVnuAdGrgxVqV2cJpM+XNccjR2DKOB1RUP0aA+wGXEiNziG/UKboFyGBIoKOaNlJxx8bciQ== - nwsapi@^2.0.7, nwsapi@^2.2.0: version "2.2.0" resolved "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.0.tgz#204879a9e3d068ff2a55139c2c772780681a38b7" @@ -17000,11 +16972,6 @@ oidc-token-hash@^5.0.0: resolved "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.0.tgz#acdfb1f4310f58e64d5d74a4e8671a426986e888" integrity sha512-8Yr4CZSv+Tn8ZkN3iN2i2w2G92mUKClp4z7EGUfdsERiYSbj7P4i/NHm72ft+aUdsiFx9UdIPSTwbyzQ6C4URg== -omggif@1.0.7: - version "1.0.7" - resolved "https://registry.npmjs.org/omggif/-/omggif-1.0.7.tgz#59d2eecb0263de84635b3feb887c0c9973f1e49d" - integrity sha1-WdLuywJj3oRjWz/riHwMmXPx5J0= - on-finished@^2.3.0, on-finished@~2.3.0: version "2.3.0" resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" @@ -17540,11 +17507,6 @@ parse5@5.1.1: resolved "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178" integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug== -parse5@^1.5.1: - version "1.5.1" - resolved "https://registry.npmjs.org/parse5/-/parse5-1.5.1.tgz#9b7f3b0de32be78dc2401b17573ccaf0f6f59d94" - integrity sha1-m387DeMr543CQBsXVzzK8Pb1nZQ= - parseurl@^1.3.2, parseurl@~1.3.2, parseurl@~1.3.3: version "1.3.3" resolved "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" @@ -18597,11 +18559,6 @@ promise-inflight@^1.0.1: resolved "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM= -promise-polyfill@8.1.0: - version "8.1.0" - resolved "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.1.0.tgz#30059da54d1358ce905ac581f287e184aedf995d" - integrity sha512-OzSf6gcCUQ01byV4BgwyUCswlaQQ6gzXc23aLQWhicvfX9kfsUiUhgt3CCQej8jDnl8/PhGF31JdHX2/MzF3WA== - promise-polyfill@^8.1.3: version "8.1.3" resolved "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.1.3.tgz#8c99b3cf53f3a91c68226ffde7bde81d7f904116" @@ -20074,7 +20031,7 @@ request-promise-native@^1.0.5, request-promise-native@^1.0.8: stealthy-require "^1.1.1" tough-cookie "^2.3.3" -request@^2.55.0, request@^2.85.0, request@^2.87.0, request@^2.88.0, request@^2.88.2: +request@^2.85.0, request@^2.87.0, request@^2.88.0, request@^2.88.2: version "2.88.2" resolved "https://registry.npmjs.org/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw== @@ -20472,7 +20429,7 @@ sanitize-html@^1.27.0: srcset "^2.0.1" xtend "^4.0.1" -sax@>=0.6.0, sax@^1.1.4, sax@^1.2.4, sax@~1.2.4: +sax@>=0.6.0, sax@^1.2.4, sax@~1.2.4: version "1.2.4" resolved "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== @@ -21238,15 +21195,10 @@ stack-utils@^2.0.2: dependencies: escape-string-regexp "^2.0.0" -stackblur-canvas@2.2.0: - version "2.2.0" - resolved "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.2.0.tgz#cacc5924a0744b3e183eb2e6c1d8559c1a17c26e" - integrity sha512-5Gf8dtlf8k6NbLzuly2NkGrkS/Ahh+I5VUjO7TnFizdJtgpfpLLEdQlLe9umbcnZlitU84kfYjXE67xlSXfhfQ== - -stackblur-canvas@^1.4.1: - version "1.4.1" - resolved "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-1.4.1.tgz#849aa6f94b272ff26f6471fa4130ed1f7e47955b" - integrity sha1-hJqm+UsnL/JvZHH6QTDtH35HlVs= +stackblur-canvas@^2.0.0: + version "2.4.0" + resolved "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.4.0.tgz#2b2eba910cb46f6feae918e1c402f863d602c01b" + integrity sha512-Z+HixfgYV0ss3C342DxPwc+UvN1SYWqoz7Wsi3xEDWEnaBkSCL3Ey21gF4io+WlLm8/RIrSnCrDBIEcH4O+q5Q== stackframe@^1.1.1: version "1.1.1" @@ -21868,7 +21820,7 @@ symbol-observable@^1.0.4, symbol-observable@^1.1.0, symbol-observable@^1.2.0: resolved "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== -"symbol-tree@>= 3.1.0 < 4.0.0", symbol-tree@^3.2.2, symbol-tree@^3.2.4: +symbol-tree@^3.2.2, symbol-tree@^3.2.4: version "3.2.4" resolved "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== @@ -22348,7 +22300,7 @@ touch@^3.1.0: dependencies: nopt "~1.0.10" -tough-cookie@^2.2.0, tough-cookie@^2.3.3, tough-cookie@^2.3.4, tough-cookie@~2.5.0: +tough-cookie@^2.3.3, tough-cookie@^2.3.4, tough-cookie@~2.5.0: version "2.5.0" resolved "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g== @@ -22379,11 +22331,6 @@ tr46@^2.0.2: dependencies: punycode "^2.1.1" -tr46@~0.0.3: - version "0.0.3" - resolved "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" - integrity sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o= - traverse@~0.6.6: version "0.6.6" resolved "https://registry.npmjs.org/traverse/-/traverse-0.6.6.tgz#cbdf560fd7b9af632502fed40f918c157ea97137" @@ -23317,11 +23264,6 @@ webapi-parser@^0.5.0: dependencies: ajv "6.5.2" -webidl-conversions@^3.0.0, webidl-conversions@^3.0.1: - version "3.0.1" - resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" - integrity sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE= - webidl-conversions@^4.0.2: version "4.0.2" resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" @@ -23524,14 +23466,6 @@ whatwg-mimetype@^2.1.0, whatwg-mimetype@^2.2.0, whatwg-mimetype@^2.3.0: resolved "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf" integrity sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g== -whatwg-url@^2.0.1: - version "2.0.1" - resolved "https://registry.npmjs.org/whatwg-url/-/whatwg-url-2.0.1.tgz#5396b2043f020ee6f704d9c45ea8519e724de659" - integrity sha1-U5ayBD8CDub3BNnEXqhRnnJN5lk= - dependencies: - tr46 "~0.0.3" - webidl-conversions "^3.0.0" - whatwg-url@^6.4.1: version "6.5.0" resolved "https://registry.npmjs.org/whatwg-url/-/whatwg-url-6.5.0.tgz#f2df02bff176fd65070df74ad5ccbb5a199965a8" @@ -23846,11 +23780,6 @@ xml-encryption@^1.0.0: xmldom "~0.1.15" xpath "0.0.27" -"xml-name-validator@>= 2.0.1 < 3.0.0": - version "2.0.1" - resolved "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-2.0.1.tgz#4d8b8f1eccd3419aa362061becef515e1e559635" - integrity sha1-TYuPHszTQZqjYgYb7O9RXh5VljU= - xml-name-validator@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" @@ -23879,7 +23808,7 @@ xmldom@0.1.27: resolved "https://registry.npmjs.org/xmldom/-/xmldom-0.1.27.tgz#d501f97b3bdb403af8ef9ecc20573187aadac0e9" integrity sha1-1QH5ezvbQDr4757MIFcxh6rawOk= -xmldom@0.1.x, xmldom@^0.1.22, xmldom@~0.1.15: +xmldom@0.1.x, xmldom@~0.1.15: version "0.1.31" resolved "https://registry.npmjs.org/xmldom/-/xmldom-0.1.31.tgz#b76c9a1bd9f0a9737e5a72dc37231cf38375e2ff" integrity sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ==