Merge branch 'master' into alund/transparent

This commit is contained in:
Stefan Ålund
2020-10-07 19:11:11 +02:00
67 changed files with 1749 additions and 346 deletions
+3
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

+4
View File
@@ -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
@@ -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 }) => (
<AboutCard entity={entity} />
</Grid>
<RecentCICDRunsSwitcher entity={entity} />
{isLighthouseAvailable(entity) && (
<Grid item sm={4}>
<LastLighthouseAuditCard />
</Grid>
)}
</Grid>
);
@@ -165,6 +175,11 @@ const WebsiteEntityPage = ({ entity }: { entity: Entity }) => (
title="CI/CD"
element={<CICDSwitcher entity={entity} />}
/>
<EntityPageLayout.Content
path="/lighthouse/*"
title="Lighthouse"
element={<LighthouseRouter entity={entity} />}
/>
<EntityPageLayout.Content
path="/sentry"
title="Sentry"
+1 -1
View File
@@ -40,7 +40,7 @@
"classnames": "^2.2.6",
"clsx": "^1.1.0",
"lodash": "^4.17.15",
"material-table": "1.68.0",
"material-table": "^1.69.1",
"prop-types": "^15.7.2",
"rc-progress": "^3.0.0",
"react": "^16.12.0",
@@ -15,41 +15,41 @@
*/
import React from 'react';
import { GaugeProgress } from './GaugeProgress';
import { Gauge } from './Gauge';
const containerStyle = { width: 300 };
export default {
title: 'GaugeProgress',
component: GaugeProgress,
title: 'Gauge',
component: Gauge,
};
export const Default = () => (
<div style={containerStyle}>
<GaugeProgress value={0.8} />
<Gauge value={0.8} />
</div>
);
export const MediumProgress = () => (
<div style={containerStyle}>
<GaugeProgress value={0.5} />
<Gauge value={0.5} />
</div>
);
export const LowProgress = () => (
<div style={containerStyle}>
<GaugeProgress value={0.2} />
<Gauge value={0.2} />
</div>
);
export const InverseLowProgress = () => (
<div style={containerStyle}>
<GaugeProgress value={0.2} inverse />
<Gauge value={0.2} inverse />
</div>
);
export const AbsoluteProgress = () => (
<div style={containerStyle}>
<GaugeProgress value={89.2} fractional={false} unit="m/s" />
<Gauge value={89.2} fractional={false} unit="m/s" />
</div>
);
@@ -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('<GaugeProgress />', () => {
describe('<Gauge />', () => {
it('renders without exploding', () => {
const { getByText } = render(
wrapInTestApp(<GaugeProgress value={10} fractional={false} />),
wrapInTestApp(<Gauge value={10} fractional={false} />),
);
getByText('10%');
});
it('handles fractional prop', () => {
const { getByText } = render(
wrapInTestApp(<GaugeProgress value={0.1} fractional />),
wrapInTestApp(<Gauge value={0.1} fractional />),
);
getByText('10%');
});
it('handles max prop', () => {
const { getByText } = render(
wrapInTestApp(<GaugeProgress value={1} max={10} fractional={false} />),
wrapInTestApp(<Gauge value={1} max={10} fractional={false} />),
);
getByText('1%');
});
it('handles unit prop', () => {
const { getByText } = render(
wrapInTestApp(<GaugeProgress value={10} fractional={false} unit="m" />),
wrapInTestApp(<Gauge value={10} fractional={false} unit="m" />),
);
getByText('10m');
});
@@ -77,7 +77,7 @@ export function getProgressColor(
return palette.status.ok;
}
export const GaugeProgress: FC<Props> = props => {
export const Gauge: FC<Props> = props => {
const classes = useStyles(props);
const theme = useTheme<BackstageTheme>();
const { value, fractional, inverse, unit, max } = {
@@ -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> = props => {
deepLink={deepLink}
variant={variant}
>
<GaugeProgress value={progress} />
<Gauge value={progress} />
</InfoCard>
</div>
);
@@ -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 = {
/**
@@ -15,5 +15,5 @@
*/
export { GaugeCard } from './GaugeCard';
export { GaugeProgress } from './GaugeProgress';
export { Gauge } from './Gauge';
export { LinearGauge } from './LinearGauge';
@@ -65,7 +65,12 @@ export const ErrorPage = ({
Go back
</Link>
... or if you think this is a bug, please file an{' '}
<Link href="https://github.com/spotify/backstage/issues">issue.</Link>
<Link
href="https://github.com/spotify/backstage/issues"
rel="noopener noreferrer"
>
issue.
</Link>
</Typography>
</Grid>
</Grid>
@@ -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
@@ -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');
});
};
@@ -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,
@@ -25,6 +25,7 @@ export type DbEntitiesRow = {
namespace: string | null;
etag: string;
generation: number;
full_name: string;
metadata: string;
spec: string | null;
};
+1 -1
View File
@@ -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';
@@ -60,7 +60,10 @@ const WidgetContent = ({
metadata={{
status: (
<>
<WorkflowRunStatus status={lastRun.status} />
<WorkflowRunStatus
status={lastRun.status}
conclusion={lastRun.conclusion}
/>
</>
),
message: lastRun.message,
@@ -106,7 +106,10 @@ const StepView = ({ step }: { step: Step }) => {
/>
</TableCell>
<TableCell>
<WorkflowRunStatus status={step.status.toUpperCase()} />
<WorkflowRunStatus
status={step.status.toUpperCase()}
conclusion={step.conclusion.toUpperCase()}
/>
</TableCell>
</TableRow>
);
@@ -204,7 +207,10 @@ export const WorkflowRunDetails = ({ entity }: { entity: Entity }) => {
<Typography noWrap>Status</Typography>
</TableCell>
<TableCell>
<WorkflowRunStatus status={details.value?.status} />
<WorkflowRunStatus
status={details.value?.status}
conclusion={details.value?.conclusion}
/>
</TableCell>
</TableRow>
<TableRow>
@@ -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 (
<>
<StatusOK /> Completed
</>
);
switch (conclusion?.toLowerCase()) {
case 'skipped' || 'canceled':
return (
<>
<StatusAborted /> Aborted
</>
);
case 'timed_out':
return (
<>
<StatusWarning /> Timed out
</>
);
case 'failure':
return (
<>
<StatusError /> Error
</>
);
default:
return (
<>
<StatusOK /> Completed
</>
);
}
default:
return (
<>
@@ -46,6 +46,7 @@ export type WorkflowRun = {
};
};
status: string;
conclusion: string;
onReRunClick: () => void;
};
@@ -84,7 +85,7 @@ const generatedColumns: TableColumn[] = [
render: (row: Partial<WorkflowRun>) => (
<Box display="flex" alignItems="center">
<WorkflowRunStatus status={row.status} />
<WorkflowRunStatus status={row.status} conclusion={row.conclusion} />
</Box>
),
},
@@ -87,6 +87,7 @@ export function useWorkflowRuns({
},
},
status: run.status,
conclusion: run.conclusion,
url: run.url,
githubUrl: run.html_url,
}));
+62 -2
View File
@@ -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: <TOKEN FROM STEP 4>
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
@@ -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: <KUBERNETES MASTER BASE URL FROM STEP 2>
name: minikube
serviceAccountToken: <TOKEN FROM STEP 4>
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`
@@ -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',
},
]);
});
@@ -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'),
};
}),
);
@@ -15,3 +15,4 @@
*/
export type ClusterLocatorMethod = 'configMultiTenant' | 'http';
export type AuthProviderType = 'google' | 'serviceAccount';
@@ -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<ClusterDetails> {
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;
}
}
@@ -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',
);
});
});
@@ -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`,
);
}
}
}
}
@@ -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<ClusterDetails> {
return clusterDetails;
}
}
@@ -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<ClusterDetails>;
}
@@ -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');
@@ -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<any>(['foo']),
),
@@ -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);
@@ -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<KubernetesObjectTypes>,
) => Promise<ObjectsByServiceIdResponse>;
@@ -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>[] = 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 => {
@@ -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' });
@@ -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) {
@@ -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 {
+21
View File
@@ -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': <ENTITY_NAME>
```
+1
View File
@@ -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",
@@ -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<any> {
private async getRequired(
path: string,
requestBody: AuthRequestBody,
): Promise<any> {
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<ObjectsByServiceIdResponse> {
return await this.getRequired(`/services/${serviceId}`);
return await this.getRequired(`/services/${serviceId}`, requestBody);
}
}
+8 -2
View File
@@ -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<KubernetesApi>({
id: 'plugin.kubernetes.service',
@@ -24,5 +27,8 @@ export const kubernetesApiRef = createApiRef<KubernetesApi>({
});
export interface KubernetesApi {
getObjectsByServiceId(serviceId: String): Promise<ObjectsByServiceIdResponse>;
getObjectsByServiceId(
serviceId: String,
requestBody: AuthRequestBody,
): Promise<ObjectsByServiceIdResponse>;
}
@@ -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<string | undefined>(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) ?? [];
@@ -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<AuthRequestBody> {
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;
}
}
@@ -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<string, KubernetesAuthProvider>();
this.kubernetesAuthProviderMap.set(
'google',
new GoogleKubernetesAuthProvider(options.googleAuthApi),
);
this.kubernetesAuthProviderMap.set(
'serviceAccount',
new ServiceAccountKubernetesAuthProvider(),
);
}
async decorateRequestBodyForAuth(
authProvider: string,
requestBody: AuthRequestBody,
): Promise<AuthRequestBody> {
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`,
);
}
}
@@ -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<AuthRequestBody> {
// No-op, with service account for auth, cluster config/details should already have serviceAccountToken
return requestBody;
}
}
@@ -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<AuthRequestBody>;
}
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<AuthRequestBody>;
}
+10
View File
@@ -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 });
},
}),
],
});
+63
View File
@@ -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 }) => (
<EntityPageLayout>
// ...
<EntityPageLayout.Content
path="/lighthouse"
title="Lighthouse"
element={<LighthouseRouter entity={entity} />}
/>
</EntityPageLayout>
);
```
> 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 }) => (
<Grid container spacing={3}>
// ...
{isLighthouseAvailable(entity) && (
<Grid item sm={4}>
<LastLighthouseAuditCard />
</Grid>
)}
</Grid>
);
```
+16
View File
@@ -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';
+6 -1
View File
@@ -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",
+32 -4
View File
@@ -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 = () => (
<Routes>
@@ -28,3 +35,24 @@ export const Router = () => (
<Route path={`/${createAuditRouteRef.path}`} element={<CreateAudit />} />
</Routes>
);
export const EmbeddedRouter = ({ entity }: { entity: Entity }) =>
!isPluginApplicableToEntity(entity) ? (
<EmptyState
missing="field"
title="Your plugin is missing an annotation"
description={`Please add the ${LIGHTHOUSE_WEBSITE_URL_ANNOTATION} annotation`}
/>
) : (
<Routes>
<Route path={`/${rootRouteRef.path}`} element={<AuditListForEntity />} />
<Route
path={`/${viewAuditRouteRef.path}`}
element={<AuditViewContent />}
/>
<Route
path={`/${createAuditRouteRef.path}`}
element={<CreateAuditContent />}
/>
</Routes>
);
+7
View File
@@ -104,6 +104,7 @@ export type LighthouseApi = {
getWebsiteList: (listOptions: LASListRequest) => Promise<WebsiteListResponse>;
getWebsiteForAuditId: (auditId: string) => Promise<Website>;
triggerAudit: (payload: TriggerAuditPayload) => Promise<Audit>;
getWebsiteByUrl: (websiteUrl: string) => Promise<Website>;
};
export const lighthouseApiRef = createApiRef<LighthouseApi>({
@@ -150,4 +151,10 @@ export class LighthouseRestApi implements LighthouseApi {
},
});
}
async getWebsiteByUrl(websiteUrl: string): Promise<Website> {
return this.fetch<Website>(
`/v1/websites/${encodeURIComponent(websiteUrl)}`,
);
}
}
@@ -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('<AuditListTableForEntity />', () => {
let apis: ApiRegistry;
const mockErrorApi: jest.Mocked<typeof errorApiRef.T> = {
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(
<ThemeProvider theme={lightTheme}>
<MemoryRouter>
<ApiProvider apis={apis}>
<EntityContext.Provider
value={{ entity: entity, loading: false, ...value }}
>
<AuditListForEntity />
</EntityContext.Provider>
</ApiProvider>
</MemoryRouter>
</ThemeProvider>,
);
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();
});
});
});
@@ -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 <Progress />;
}
if (error || !value) {
return null;
}
return <AuditListTable data-test-id="AuditListTable" items={[value]} />;
};
@@ -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 (
<Page theme={pageTheme.tool}>
<Header
title="Lighthouse"
subtitle="Website audits powered by Lighthouse"
<>
<ContentHeader
title={value?.url || 'Audit'}
description="See a history of all Lighthouse audits for your website run through Backstage."
>
<HeaderLabel label="Owner" value="Spotify" />
<HeaderLabel label="Lifecycle" value="Alpha" />
</Header>
<Content stretch>
<ContentHeader
title={value?.url || 'Audit'}
description="See a history of all Lighthouse audits for your website run through Backstage."
<Button
variant="contained"
color="primary"
onClick={() => navigate(`../../${createAuditButtonUrl}`)}
>
<Button
variant="contained"
color="primary"
onClick={() => navigate(`../../${createAuditButtonUrl}`)}
>
Create New Audit
</Button>
<LighthouseSupportButton />
</ContentHeader>
{content}
</Content>
</Page>
Create New Audit
</Button>
<LighthouseSupportButton />
</ContentHeader>
{content}
</>
);
};
const ConnectedAuditView = () => (
<Page theme={pageTheme.tool}>
<Header title="Lighthouse" subtitle="Website audits powered by Lighthouse">
<HeaderLabel label="Owner" value="Spotify" />
<HeaderLabel label="Lifecycle" value="Alpha" />
</Header>
<Content stretch>
<AuditViewContent />
</Content>
</Page>
);
export default ConnectedAuditView;
@@ -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('<LastLighthouseAuditCard />', () => {
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(
<ThemeProvider theme={lightTheme}>
<MemoryRouter>
<EntityContext.Provider
value={{ entity: entity, loading: false, ...value }}
>
<LastLighthouseAuditCard />
</EntityContext.Provider>
</MemoryRouter>
</ThemeProvider>,
);
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();
});
});
});
@@ -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 (
<>
<StatusOK />
{scoreAsPercentage}%
</>
);
case scoreAsPercentage >= 50 && scoreAsPercentage < 90:
return (
<>
<StatusWarning />
{scoreAsPercentage}%
</>
);
case scoreAsPercentage < 50:
return (
<>
<StatusError />
{scoreAsPercentage}%
</>
);
default:
return <span>N/A</span>;
}
};
const LighthouseAuditStatus: FC<{ audit: Audit }> = ({ audit }) => (
<>
<AuditStatusIcon audit={audit} />
{audit.status.toUpperCase()}
</>
);
const LighthouseAuditSummary: FC<{ audit: Audit; dense?: boolean }> = ({
audit,
dense = false,
}) => {
const { url } = audit;
const flattenedCategoryData: Record<string, React.ReactNode> = {};
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] = (
<LighthouseCategoryScoreStatus score={score} />
);
});
}
const tableData = {
url,
status: <LighthouseAuditStatus audit={audit} />,
...flattenedCategoryData,
};
return <StructuredMetadataTable metadata={tableData} dense={dense} />;
};
export const LastLighthouseAuditCard: FC<{ dense?: boolean }> = ({
dense = false,
}) => {
const { value: website, loading, error } = useWebsiteForEntity();
let content;
if (loading) {
content = <Progress />;
}
if (error) {
content = null;
}
if (website) {
content = (
<LighthouseAuditSummary audit={website.lastAudit} dense={dense} />
);
}
return <InfoCard title="Lighthouse Audit">{content}</InfoCard>;
};
@@ -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';
@@ -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 (
<Page theme={pageTheme.tool}>
<Header
title="Lighthouse"
subtitle="Website audits powered by Lighthouse"
<>
<ContentHeader
title="Trigger a new audit"
description="Submitting this form will immediately trigger and store a new Lighthouse audit. Trigger audits to track your website's accessibility, performance, SEO, and best practices over time."
>
<HeaderLabel label="Owner" value="Spotify" />
<HeaderLabel label="Lifecycle" value="Alpha" />
</Header>
<Content>
<ContentHeader
title="Trigger a new audit"
description="Submitting this form will immediately trigger and store a new Lighthouse audit. Trigger audits to track your website's accessibility, performance, SEO, and best practices over time."
>
<LighthouseSupportButton />
</ContentHeader>
<Grid container direction="column">
<Grid item xs={12} sm={6}>
<InfoCard>
<form
onSubmit={ev => {
ev.preventDefault();
triggerAudit();
}}
>
<List>
<ListItem>
<TextField
name="lighthouse-create-audit-url-tf"
className={classes.input}
label="URL"
placeholder="https://spotify.com"
helperText="The target URL for Lighthouse to use."
required
disabled={submitting}
onChange={ev => setUrl(ev.target.value)}
value={url}
inputProps={{ 'aria-label': 'URL' }}
/>
</ListItem>
<ListItem>
<TextField
name="lighthouse-create-audit-emulated-form-factor-tf"
className={classes.input}
label="Emulated Form Factor"
helperText="Device to simulate when auditing"
select
required
disabled={submitting}
onChange={ev => setEmulatedFormFactor(ev.target.value)}
value={emulatedFormFactor}
inputProps={{ 'aria-label': 'Emulated form factor' }}
>
<MenuItem value="mobile">Mobile</MenuItem>
<MenuItem value="desktop">Desktop</MenuItem>
</TextField>
</ListItem>
<ListItem className={classes.buttonList}>
<Button
variant="outlined"
color="primary"
onClick={() => navigate('..')}
disabled={submitting}
>
Cancel
</Button>
<Button
variant="contained"
color="primary"
type="submit"
disabled={submitting}
>
Create Audit
</Button>
</ListItem>
</List>
</form>
</InfoCard>
</Grid>
<LighthouseSupportButton />
</ContentHeader>
<Grid container direction="column">
<Grid item xs={12} sm={6}>
<InfoCard>
<form
onSubmit={ev => {
ev.preventDefault();
triggerAudit();
}}
>
<List>
<ListItem>
<TextField
name="lighthouse-create-audit-url-tf"
className={classes.input}
label="URL"
placeholder="https://spotify.com"
helperText="The target URL for Lighthouse to use."
required
disabled={submitting}
onChange={ev => setUrl(ev.target.value)}
value={url}
inputProps={{ 'aria-label': 'URL' }}
/>
</ListItem>
<ListItem>
<TextField
name="lighthouse-create-audit-emulated-form-factor-tf"
className={classes.input}
label="Emulated Form Factor"
helperText="Device to simulate when auditing"
select
required
disabled={submitting}
onChange={ev => setEmulatedFormFactor(ev.target.value)}
value={emulatedFormFactor}
inputProps={{ 'aria-label': 'Emulated form factor' }}
>
<MenuItem value="mobile">Mobile</MenuItem>
<MenuItem value="desktop">Desktop</MenuItem>
</TextField>
</ListItem>
<ListItem className={classes.buttonList}>
<Button
variant="outlined"
color="primary"
onClick={() => navigate('..')}
disabled={submitting}
>
Cancel
</Button>
<Button
variant="contained"
color="primary"
type="submit"
disabled={submitting}
>
Create Audit
</Button>
</ListItem>
</List>
</form>
</InfoCard>
</Grid>
</Content>
</Page>
</Grid>
</>
);
};
const CreateAudit = () => (
<Page theme={pageTheme.tool}>
<Header title="Lighthouse" subtitle="Website audits powered by Lighthouse">
<HeaderLabel label="Owner" value="Spotify" />
<HeaderLabel label="Lifecycle" value="Alpha" />
</Header>
<Content>
<CreateAuditContent />
</Content>
</Page>
);
export default CreateAudit;
@@ -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<typeof errorApiRef.T> = {
post: jest.fn(),
error$: jest.fn(),
};
const mockLighthouseApi: jest.Mocked<Partial<typeof lighthouseApiRef.T>> = {
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 (
<ApiProvider
apis={ApiRegistry.with(errorApiRef, mockErrorApi).with(
lighthouseApiRef,
mockLighthouseApi,
)}
>
<EntityContext.Provider value={{ entity: entity, loading: false }}>
{children}
</EntityContext.Provider>
</ApiProvider>
);
};
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);
});
});
});
@@ -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;
};
+2 -1
View File
@@ -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';
@@ -66,7 +66,11 @@ export const TechDocsPageHeader = ({
<HeaderLabel
label=""
value={
<a href={locationMetadata.target} target="_blank">
<a
href={locationMetadata.target}
target="_blank"
rel="noopener noreferrer"
>
<GitHubIcon style={{ marginTop: '-25px', fill: '#fff' }} />
</a>
}
@@ -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&nbsp;
<Link href="https://github.com/spotify/backstage/milestones">
<Link
href="https://github.com/spotify/backstage/milestones"
rel="noopener noreferrer"
>
Milestones
</Link>
.
@@ -113,7 +116,10 @@ const WelcomePage = () => {
</Typography>
<Typography variant="body1" paragraph>
We suggest you either check out the documentation for{' '}
<Link href="https://github.com/spotify/backstage/blob/master/docs/plugins/create-a-plugin.md">
<Link
href="https://github.com/spotify/backstage/blob/master/docs/plugins/create-a-plugin.md"
rel="noopener noreferrer"
>
creating a plugin
</Link>{' '}
or have a look in the code for the{' '}
@@ -121,7 +127,10 @@ const WelcomePage = () => {
existing plugins
</Link>{' '}
in the directory{' '}
<Link href="https://github.com/spotify/backstage/tree/master/plugins">
<Link
href="https://github.com/spotify/backstage/tree/master/plugins"
rel="noopener noreferrer"
>
<code>plugins/</code>
</Link>
.
@@ -135,7 +144,10 @@ const WelcomePage = () => {
<Link href="https://backstage.io">backstage.io</Link>
</ListItem>
<ListItem>
<Link href="https://github.com/spotify/backstage/blob/master/docs/plugins/create-a-plugin.md">
<Link
href="https://github.com/spotify/backstage/blob/master/docs/plugins/create-a-plugin.md"
rel="noopener noreferrer"
>
Create a plugin
</Link>
</ListItem>
+87 -158
View File
@@ -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==