Merge branch 'master' of github.com:spotify/backstage into shmidt-i/circle-ci-plugin-new-route-api

This commit is contained in:
Ivan Shmidt
2020-09-07 22:15:12 +02:00
187 changed files with 9837 additions and 3115 deletions
+1
View File
@@ -31,6 +31,7 @@
"@material-ui/lab": "4.0.0-alpha.45",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^15.3.3",
"swagger-ui-react": "^3.31.1"
@@ -0,0 +1,47 @@
/*
* 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 { ComponentEntity, Entity } from '@backstage/catalog-model';
import { Progress } from '@backstage/core';
import React, { FC } from 'react';
import { Grid } from '@material-ui/core';
import {
ApiDefinitionCard,
useComponentApiEntities,
useComponentApiNames,
} from '../../components';
export const EntityPageApi: FC<{ entity: Entity }> = ({ entity }) => {
const apiNames = useComponentApiNames(entity as ComponentEntity);
const { apiEntities, loading } = useComponentApiEntities({
entity: entity as ComponentEntity,
});
if (loading) {
return <Progress />;
}
return (
<Grid container spacing={3}>
{apiNames.map(api => (
<Grid item xs={12} key={api}>
<ApiDefinitionCard title={api} apiEntity={apiEntities!.get(api)} />
</Grid>
))}
</Grid>
);
};
@@ -0,0 +1,17 @@
/*
* 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 { EntityPageApi } from './EntityPageApi';
+42
View File
@@ -0,0 +1,42 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { Route, Routes } from 'react-router';
import { WarningPanel } from '@backstage/core';
import { catalogRoute } from '../routes';
import { EntityPageApi } from './EntityPageApi';
const isPluginApplicableToEntity = (entity: Entity) => {
return ((entity.spec?.implementsApis as string[]) || []).length > 0;
};
export const Router = ({ entity }: { entity: Entity }) =>
// TODO(shmidt-i): move warning to a separate standardized component
!isPluginApplicableToEntity(entity) ? (
<WarningPanel title="API Docs plugin:">
The entity doesn't implement any APIs.
</WarningPanel>
) : (
<Routes>
<Route
path={`/${catalogRoute.path}`}
element={<EntityPageApi entity={entity} />}
/>
)
</Routes>
);
+17
View File
@@ -0,0 +1,17 @@
/*
* 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 { Router } from './Router';
@@ -16,7 +16,6 @@
import { Entity } from '@backstage/catalog-model';
import { ApiProvider, ApiRegistry, storageApiRef } from '@backstage/core';
// TODO: Circular ref!
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog';
import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils';
import { render } from '@testing-library/react';
@@ -15,11 +15,10 @@
*/
import { Content, useApi } from '@backstage/core';
// TODO: Circular ref
import { catalogApiRef } from '@backstage/plugin-catalog';
import React from 'react';
import { useAsync } from 'react-use';
import { ApiCatalogTable } from '../ApiCatalogTable/ApiCatalogTable';
import { ApiCatalogTable } from '../ApiCatalogTable';
import ApiCatalogLayout from './ApiCatalogLayout';
const CatalogPageContents = () => {
@@ -0,0 +1,17 @@
/*
* 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 { ApiCatalogPage } from './ApiCatalogPage';
@@ -0,0 +1,17 @@
/*
* 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 { ApiCatalogTable } from './ApiCatalogTable';
@@ -14,23 +14,32 @@
* limitations under the License.
*/
import { ApiEntityV1alpha1 } from '@backstage/catalog-model';
import { ApiEntity } from '@backstage/catalog-model';
import { InfoCard } from '@backstage/core';
import React from 'react';
import { ApiDefinitionWidget } from '../ApiDefinitionWidget/ApiDefinitionWidget';
import { ApiDefinitionWidget } from '../ApiDefinitionWidget';
import { Alert } from '@material-ui/lab';
type Props = {
title?: string;
apiEntity: ApiEntityV1alpha1;
apiEntity?: ApiEntity;
};
export const ApiDefinitionCard = ({ title, apiEntity }: Props) => {
const type = apiEntity?.spec?.type || '';
const definition = apiEntity?.spec?.definition || '';
if (!apiEntity) {
return (
<InfoCard title={title}>
<Alert severity="error">Could not fetch the API</Alert>
</InfoCard>
);
}
return (
<InfoCard title={title} subheader={type}>
<ApiDefinitionWidget type={type} definition={definition} />
<InfoCard title={title} subheader={apiEntity.spec.type}>
<ApiDefinitionWidget
type={apiEntity.spec.type}
definition={apiEntity.spec.definition}
/>
</InfoCard>
);
};
@@ -0,0 +1,17 @@
/*
* 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 { ApiDefinitionCard } from './ApiDefinitionCard';
@@ -15,9 +15,9 @@
*/
import React from 'react';
import { AsyncApiDefinitionWidget } from '../AsyncApiDefinitionWidget/AsyncApiDefinitionWidget';
import { OpenApiDefinitionWidget } from '../OpenApiDefinitionWidget/OpenApiDefinitionWidget';
import { PlainApiDefinitionWidget } from '../PlainApiDefinitionWidget/PlainApiDefinitionWidget';
import { AsyncApiDefinitionWidget } from '../AsyncApiDefinitionWidget';
import { OpenApiDefinitionWidget } from '../OpenApiDefinitionWidget';
import { PlainApiDefinitionWidget } from '../PlainApiDefinitionWidget';
type Props = {
type: string;
@@ -0,0 +1,17 @@
/*
* 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 { ApiDefinitionWidget } from './ApiDefinitionWidget';
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { ApiEntityV1alpha1, Entity } from '@backstage/catalog-model';
import { ApiEntity, Entity } from '@backstage/catalog-model';
import {
Content,
errorApiRef,
@@ -25,14 +25,13 @@ import {
Progress,
useApi,
} from '@backstage/core';
// TODO: Circular ref
import { catalogApiRef } from '@backstage/plugin-catalog';
import { Box } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React, { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useAsync } from 'react-use';
import { ApiDefinitionCard } from '../ApiDefinitionCard/ApiDefinitionCard';
import { ApiDefinitionCard } from '../ApiDefinitionCard';
const REDIRECT_DELAY = 1000;
function headerProps(
@@ -125,7 +124,7 @@ export const ApiEntityPage = () => {
{entity && (
<>
<Content>
<ApiDefinitionCard apiEntity={entity as ApiEntityV1alpha1} />
<ApiDefinitionCard apiEntity={entity as ApiEntity} />
</Content>
</>
)}
@@ -0,0 +1,17 @@
/*
* 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 { ApiEntityPage } from './ApiEntityPage';
@@ -48,7 +48,7 @@ const useStyles = makeStyles(theme => ({
border: `1px solid ${fade(theme.palette.primary.main, 0.5)}`,
'&:hover': {
textDecoration: 'none',
'&$disabled': {
'&.Mui-disabled': {
backgroundColor: 'transparent',
},
border: `1px solid ${theme.palette.primary.main}`,
@@ -61,7 +61,7 @@ const useStyles = makeStyles(theme => ({
backgroundColor: 'transparent',
},
},
'&$disabled': {
'&.Mui-disabled': {
color: theme.palette.action.disabled,
},
},
@@ -0,0 +1,17 @@
/*
* 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 { AsyncApiDefinitionWidget } from './AsyncApiDefinitionWidget';
@@ -0,0 +1,17 @@
/*
* 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 { OpenApiDefinitionWidget } from './OpenApiDefinitionWidget';
@@ -0,0 +1,17 @@
/*
* 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 { PlainApiDefinitionWidget } from './PlainApiDefinitionWidget';
+19
View File
@@ -0,0 +1,19 @@
/*
* 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 { ApiDefinitionCard } from './ApiDefinitionCard';
export { useComponentApiNames } from './useComponentApiNames';
export { useComponentApiEntities } from './useComponentApiEntities';
@@ -0,0 +1,69 @@
/*
* 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 { useAsyncRetry } from 'react-use';
import { errorApiRef, useApi } from '@backstage/core';
import { ApiEntity, ComponentEntity } from '@backstage/catalog-model';
import { catalogApiRef } from '@backstage/plugin-catalog';
import { useComponentApiNames } from './useComponentApiNames';
export function useComponentApiEntities({
entity,
}: {
entity: ComponentEntity;
}): {
loading: boolean;
apiEntities?: Map<String, ApiEntity>;
error?: Error;
retry: () => void;
} {
const catalogApi = useApi(catalogApiRef);
const errorApi = useApi(errorApiRef);
const apiNames = useComponentApiNames(entity);
const { loading, value: apiEntities, retry, error } = useAsyncRetry<
Map<string, ApiEntity>
>(async () => {
const resultMap = new Map<string, ApiEntity>();
await Promise.all(
apiNames.map(async name => {
try {
const api = (await catalogApi.getEntityByName({
kind: 'API',
name,
})) as ApiEntity | undefined;
if (api) {
resultMap.set(api.metadata.name, api);
}
} catch (e) {
errorApi.post(e);
}
}),
);
return resultMap;
}, [catalogApi, entity]);
return {
apiEntities,
loading,
error,
retry,
};
}
@@ -0,0 +1,21 @@
/*
* 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 { ComponentEntity } from '@backstage/catalog-model';
export const useComponentApiNames = (entity: ComponentEntity) => {
return (entity.spec?.implementsApis as string[]) || [];
};
+1 -1
View File
@@ -14,5 +14,5 @@
* limitations under the License.
*/
export { ApiDefinitionCard } from './components/ApiDefinitionCard/ApiDefinitionCard';
export { Router } from './catalog';
export { plugin } from './plugin';
+5
View File
@@ -28,3 +28,8 @@ export const entityRoute = createRouteRef({
path: '/api-docs/:optionalNamespaceAndName/',
title: 'API',
});
export const catalogRoute = createRouteRef({
icon: NoIcon,
path: '',
title: 'API',
});
@@ -47,21 +47,21 @@ describe('createRouter', () => {
const response = await request(app).get('/index.html');
expect(response.status).toBe(200);
expect(response.text).toBe('this is index.html\n');
expect(response.text.trim()).toBe('this is index.html');
});
it('returns other.html', async () => {
const response = await request(app).get('/other.html');
expect(response.status).toBe(200);
expect(response.text).toBe('this is other.html\n');
expect(response.text.trim()).toBe('this is other.html');
});
it('returns index.html if missing', async () => {
const response = await request(app).get('/missing.html');
expect(response.status).toBe(200);
expect(response.text).toBe('this is index.html\n');
expect(response.text.trim()).toBe('this is index.html');
});
});
@@ -83,11 +83,11 @@ describe('createRouter with static fallback handler', () => {
const response1 = await request(app).get('/static/main.txt');
expect(response1.status).toBe(200);
expect(response1.text).toBe('this is main.txt\n');
expect(response1.text.trim()).toBe('this is main.txt');
const response2 = await request(app).get('/static/test.txt');
expect(response2.status).toBe(200);
expect(response2.text).toBe('this is test.txt');
expect(response2.text.trim()).toBe('this is test.txt');
const response3 = await request(app).get('/static/missing.txt');
expect(response3.status).toBe(404);
+2 -2
View File
@@ -85,8 +85,8 @@ Click [here](https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMe
- Give the app a name. e.g. `backstage-dev`
- Select `Accounts in this organizational directory only` under supported account types.
- Enter the callback URL for your backstage backend instance:
- For local development, this is likely `http://localhost:7000/auth/microsoft/handler/frame`
- For non-local deployments, this will be `https://{APP_FQDN}:{APP_BACKEND_PORT}/auth/microsoft/handler/frame`
- For local development, this is likely `http://localhost:7000/auth/microsoft/handler/frame`
- For non-local deployments, this will be `https://{APP_FQDN}:{APP_BACKEND_PORT}/auth/microsoft/handler/frame`
- Click `Register`.
We also need to generate a client secret so Backstage can authenticate as this app.
@@ -24,9 +24,9 @@ import {
} from '../../providers/types';
import { InputError } from '@backstage/backend-common';
import { TokenIssuer } from '../../identity';
import { verifyNonce, encodeState } from './helpers';
import { verifyNonce } from './helpers';
import { postMessageResponse, ensuresXRequestedWith } from '../flow';
import { OAuthHandlers } from './types';
import { OAuthHandlers, OAuthStartRequest, OAuthRefreshRequest } from './types';
export const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000;
export const TEN_MINUTES_MS = 600 * 1000;
@@ -86,15 +86,12 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
// set a nonce cookie before redirecting to oauth provider
this.setNonceCookie(res, nonce);
const stateObject = { nonce: nonce, env: env };
const stateParameter = encodeState(stateObject);
const state = { nonce: nonce, env: env };
const forwardReq = Object.assign(req, { scope, state });
const queryParameters = {
scope,
state: stateParameter,
};
const { url, status } = await this.handlers.start(req, queryParameters);
const { url, status } = await this.handlers.start(
forwardReq as OAuthStartRequest,
);
res.statusCode = status || 302;
res.setHeader('Location', url);
@@ -185,8 +182,12 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
const scope = req.query.scope?.toString() ?? '';
const forwardReq = Object.assign(req, { scope, refreshToken });
// get new access_token
const response = await this.handlers.refresh(refreshToken, scope);
const response = await this.handlers.refresh(
forwardReq as OAuthRefreshRequest,
);
await this.populateIdentity(response.backstageIdentity);
@@ -16,10 +16,13 @@
export { OAuthEnvironmentHandler } from './OAuthEnvironmentHandler';
export { OAuthAdapter } from './OAuthAdapter';
export { encodeState } from './helpers';
export type {
OAuthHandlers,
OAuthProviderInfo,
OAuthProviderOptions,
OAuthResponse,
OAuthState,
OAuthStartRequest,
OAuthRefreshRequest,
} from './types';
+12 -8
View File
@@ -67,6 +67,16 @@ export type OAuthState = {
env: string;
};
export type OAuthStartRequest = express.Request<{}> & {
scope: string;
state: OAuthState;
};
export type OAuthRefreshRequest = express.Request<{}> & {
scope: string;
refreshToken: string;
};
/**
* Any OAuth provider needs to implement this interface which has provider specific
* handlers for different methods to perform authentication, get access tokens,
@@ -78,10 +88,7 @@ export interface OAuthHandlers {
* @param {express.Request} req
* @param options
*/
start(
req: express.Request,
options: Record<string, string>,
): Promise<RedirectInfo>;
start(req: OAuthStartRequest): Promise<RedirectInfo>;
/**
* Handles the redirect from the auth provider when the user has signed in.
@@ -99,10 +106,7 @@ export interface OAuthHandlers {
* @param {string} refreshToken
* @param {string} scope
*/
refresh?(
refreshToken: string,
scope: string,
): Promise<AuthResponse<OAuthProviderInfo>>;
refresh?(req: OAuthRefreshRequest): Promise<AuthResponse<OAuthProviderInfo>>;
/**
* (Optional) Sign out of the auth provider.
@@ -23,6 +23,9 @@ import {
OAuthHandlers,
OAuthResponse,
OAuthEnvironmentHandler,
OAuthStartRequest,
encodeState,
OAuthRefreshRequest,
} from '../../lib/oauth';
import {
executeFetchUserProfileStrategy,
@@ -81,16 +84,13 @@ export class Auth0AuthProvider implements OAuthHandlers {
);
}
async start(
req: express.Request,
options: Record<string, string>,
): Promise<RedirectInfo> {
const providerOptions = {
...options,
async start(req: OAuthStartRequest): Promise<RedirectInfo> {
return await executeRedirectStrategy(req, this._strategy, {
accessType: 'offline',
prompt: 'consent',
};
return await executeRedirectStrategy(req, this._strategy, providerOptions);
scope: req.scope,
state: encodeState(req.state),
});
}
async handler(
@@ -107,11 +107,11 @@ export class Auth0AuthProvider implements OAuthHandlers {
};
}
async refresh(refreshToken: string, scope: string): Promise<OAuthResponse> {
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
const { accessToken, params } = await executeRefreshTokenStrategy(
this._strategy,
refreshToken,
scope,
req.refreshToken,
req.scope,
);
const profile = await executeFetchUserProfileStrategy(
@@ -29,6 +29,8 @@ import {
OAuthHandlers,
OAuthResponse,
OAuthEnvironmentHandler,
OAuthStartRequest,
encodeState,
} from '../../lib/oauth';
import passport from 'passport';
@@ -117,11 +119,11 @@ export class GithubAuthProvider implements OAuthHandlers {
);
}
async start(
req: express.Request,
options: Record<string, string>,
): Promise<RedirectInfo> {
return await executeRedirectStrategy(req, this._strategy, options);
async start(req: OAuthStartRequest): Promise<RedirectInfo> {
return await executeRedirectStrategy(req, this._strategy, {
scope: req.scope,
state: encodeState(req.state),
});
}
async handler(req: express.Request) {
@@ -29,6 +29,8 @@ import {
OAuthHandlers,
OAuthResponse,
OAuthEnvironmentHandler,
OAuthStartRequest,
encodeState,
} from '../../lib/oauth';
import passport from 'passport';
@@ -122,11 +124,11 @@ export class GitlabAuthProvider implements OAuthHandlers {
);
}
async start(
req: express.Request,
options: Record<string, string>,
): Promise<RedirectInfo> {
return await executeRedirectStrategy(req, this._strategy, options);
async start(req: OAuthStartRequest): Promise<RedirectInfo> {
return await executeRedirectStrategy(req, this._strategy, {
scope: req.scope,
state: encodeState(req.state),
});
}
async handler(req: express.Request): Promise<{ response: OAuthResponse }> {
@@ -31,6 +31,9 @@ import {
OAuthProviderOptions,
OAuthResponse,
OAuthEnvironmentHandler,
OAuthStartRequest,
encodeState,
OAuthRefreshRequest,
} from '../../lib/oauth';
import passport from 'passport';
@@ -79,16 +82,13 @@ export class GoogleAuthProvider implements OAuthHandlers {
);
}
async start(
req: express.Request,
options: Record<string, string>,
): Promise<RedirectInfo> {
const providerOptions = {
...options,
async start(req: OAuthStartRequest): Promise<RedirectInfo> {
return await executeRedirectStrategy(req, this._strategy, {
accessType: 'offline',
prompt: 'consent',
};
return await executeRedirectStrategy(req, this._strategy, providerOptions);
scope: req.scope,
state: encodeState(req.state),
});
}
async handler(
@@ -105,11 +105,11 @@ export class GoogleAuthProvider implements OAuthHandlers {
};
}
async refresh(refreshToken: string, scope: string): Promise<OAuthResponse> {
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
const { accessToken, params } = await executeRefreshTokenStrategy(
this._strategy,
refreshToken,
scope,
req.refreshToken,
req.scope,
);
const profile = await executeFetchUserProfileStrategy(
@@ -35,6 +35,9 @@ import {
OAuthHandlers,
OAuthResponse,
OAuthEnvironmentHandler,
OAuthStartRequest,
encodeState,
OAuthRefreshRequest,
} from '../../lib/oauth';
import got from 'got';
@@ -111,11 +114,11 @@ export class MicrosoftAuthProvider implements OAuthHandlers {
);
}
async start(
req: express.Request,
options: Record<string, string>,
): Promise<RedirectInfo> {
return await executeRedirectStrategy(req, this._strategy, options);
async start(req: OAuthStartRequest): Promise<RedirectInfo> {
return await executeRedirectStrategy(req, this._strategy, {
scope: req.scope,
state: encodeState(req.state),
});
}
async handler(
@@ -132,11 +135,11 @@ export class MicrosoftAuthProvider implements OAuthHandlers {
};
}
async refresh(refreshToken: string, scope: string): Promise<OAuthResponse> {
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
const { accessToken, params } = await executeRefreshTokenStrategy(
this._strategy,
refreshToken,
scope,
req.refreshToken,
req.scope,
);
const profile = await executeFetchUserProfileStrategy(
@@ -23,6 +23,9 @@ import {
OAuthHandlers,
OAuthResponse,
OAuthEnvironmentHandler,
OAuthStartRequest,
encodeState,
OAuthRefreshRequest,
} from '../../lib/oauth';
import {
executeFetchUserProfileStrategy,
@@ -84,16 +87,13 @@ export class OAuth2AuthProvider implements OAuthHandlers {
);
}
async start(
req: express.Request,
options: Record<string, string>,
): Promise<RedirectInfo> {
const providerOptions = {
...options,
async start(req: OAuthStartRequest): Promise<RedirectInfo> {
return await executeRedirectStrategy(req, this._strategy, {
accessType: 'offline',
prompt: 'consent',
};
return await executeRedirectStrategy(req, this._strategy, providerOptions);
scope: req.scope,
state: encodeState(req.state),
});
}
async handler(
@@ -110,11 +110,11 @@ export class OAuth2AuthProvider implements OAuthHandlers {
};
}
async refresh(refreshToken: string, scope: string): Promise<OAuthResponse> {
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
const refreshTokenResponse = await executeRefreshTokenStrategy(
this._strategy,
refreshToken,
scope,
req.refreshToken,
req.scope,
);
const {
accessToken,
@@ -13,4 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { createOktaProvider } from './provider';
export { createOktaProvider } from './provider';
@@ -20,6 +20,9 @@ import {
OAuthHandlers,
OAuthResponse,
OAuthEnvironmentHandler,
OAuthStartRequest,
encodeState,
OAuthRefreshRequest,
} from '../../lib/oauth';
import { Strategy as OktaStrategy } from 'passport-okta-oauth';
import passport from 'passport';
@@ -101,16 +104,13 @@ export class OktaAuthProvider implements OAuthHandlers {
);
}
async start(
req: express.Request,
options: Record<string, string>,
): Promise<RedirectInfo> {
const providerOptions = {
...options,
async start(req: OAuthStartRequest): Promise<RedirectInfo> {
return await executeRedirectStrategy(req, this._strategy, {
accessType: 'offline',
prompt: 'consent',
};
return await executeRedirectStrategy(req, this._strategy, providerOptions);
scope: req.scope,
state: encodeState(req.state),
});
}
async handler(
@@ -127,11 +127,11 @@ export class OktaAuthProvider implements OAuthHandlers {
};
}
async refresh(refreshToken: string, scope: string): Promise<OAuthResponse> {
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
const { accessToken, params } = await executeRefreshTokenStrategy(
this._strategy,
refreshToken,
scope,
req.refreshToken,
req.scope,
);
const profile = await executeFetchUserProfileStrategy(
+1 -3
View File
@@ -14,9 +14,7 @@
* limitations under the License.
*/
declare module 'passport-okta-oauth' {
export class Strategy {
constructor(options: any, verify: any)
constructor(options: any, verify: any);
}
}
-1
View File
@@ -23,7 +23,6 @@
"dependencies": {
"@backstage/catalog-model": "^0.1.1-alpha.21",
"@backstage/core": "^0.1.1-alpha.21",
"@backstage/plugin-api-docs": "^0.1.1-alpha.21",
"@backstage/plugin-github-actions": "^0.1.1-alpha.21",
"@backstage/plugin-jenkins": "^0.1.1-alpha.21",
"@backstage/plugin-scaffolder": "^0.1.1-alpha.21",
@@ -104,7 +104,9 @@ export function AboutCard({ entity }: AboutCardProps) {
<IconLinkVertical
label="View Techdocs"
icon={<DocsIcon />}
href={`/docs/${''}`}
href={`/docs/${entity.kind}:${entity.metadata.namespace ?? ''}:${
entity.metadata.name
}`}
/>
</nav>
}
@@ -31,12 +31,13 @@ type Props = {
const CatalogLayout = ({ children }: Props) => {
const greeting = getTimeBasedGreeting();
const profile = useApi(identityApiRef).getProfile();
const userId = useApi(identityApiRef).getUserId();
return (
<Page theme={pageTheme.home}>
<Header
title={`${greeting.greeting}, ${userId}!`}
title={`${greeting.greeting}, ${profile.displayName || userId}!`}
subtitle="Backstage Service Catalog"
tooltip={greeting.language}
pageTitleOverride="Home"
@@ -21,6 +21,7 @@ import {
IdentityApi,
identityApiRef,
storageApiRef,
ProfileInfo,
} from '@backstage/core';
import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils';
import { fireEvent, render } from '@testing-library/react';
@@ -60,8 +61,12 @@ describe('CatalogPage', () => {
getLocationByEntity: () =>
Promise.resolve({ id: 'id', type: 'github', target: 'url' }),
};
const testProfile: Partial<ProfileInfo> = {
displayName: 'Display Name',
};
const indentityApi: Partial<IdentityApi> = {
getUserId: () => 'tools@example.com',
getProfile: () => testProfile,
};
const renderWrapped = (children: React.ReactNode) =>
@@ -1,72 +0,0 @@
/*
* 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 { ApiEntityV1alpha1, Entity } from '@backstage/catalog-model';
import { Content, Progress, useApi } from '@backstage/core';
import { ApiDefinitionCard } from '@backstage/plugin-api-docs';
import { Grid } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React, { FC } from 'react';
import { useAsync } from 'react-use';
import { catalogApiRef } from '../..';
export const EntityPageApi: FC<{ entity: Entity }> = ({ entity }) => {
const catalogApi = useApi(catalogApiRef);
const { value: apiEntities, loading } = useAsync(async () => {
const a = await Promise.all(
((entity?.spec?.implementsApis as string[]) || []).map(api =>
catalogApi.getEntityByName({
kind: 'API',
name: api,
}),
),
);
const b = new Map<string, ApiEntityV1alpha1>();
a.filter(api => !!api).forEach(api => {
b.set(api?.metadata?.name!, api as ApiEntityV1alpha1);
});
return b;
}, [catalogApi, entity]);
return (
<Content>
{loading && <Progress />}
{!loading && (
<Grid container spacing={3}>
{((entity?.spec?.implementsApis as string[]) || []).map(api => {
const apiEntity = apiEntities && apiEntities.get(api);
return (
<Grid item sm={12} key={api}>
{!apiEntity && (
<Alert severity="error">
Error on fetching the API: {api}
</Alert>
)}
{apiEntity && (
<ApiDefinitionCard title={api} apiEntity={apiEntity} />
)}
</Grid>
);
})}
</Grid>
)}
</Content>
);
};
@@ -17,7 +17,10 @@
// TODO(shmidt-i): move to the app
import { Entity } from '@backstage/catalog-model';
import { Content } from '@backstage/core';
import { LatestWorkflowsForBranchCard } from '@backstage/plugin-github-actions';
import {
LatestWorkflowsForBranchCard,
GITHUB_ACTIONS_ANNOTATION,
} from '@backstage/plugin-github-actions';
import { Grid } from '@material-ui/core';
import React, { FC } from 'react';
@@ -25,7 +28,7 @@ export const EntityPageCi: FC<{ entity: Entity }> = ({ entity }) => {
return (
<Content>
<Grid container spacing={3}>
{entity.metadata?.annotations?.['backstage.io/github-actions-id'] && (
{entity.metadata?.annotations?.[GITHUB_ACTIONS_ANNOTATION] && (
<Grid item sm={12}>
<LatestWorkflowsForBranchCard entity={entity} branch="master" />
</Grid>
@@ -17,7 +17,10 @@
// TODO(shmidt-i): move to the app
import { Entity } from '@backstage/catalog-model';
import { Content } from '@backstage/core';
import { LatestWorkflowRunCard } from '@backstage/plugin-github-actions';
import {
LatestWorkflowRunCard,
GITHUB_ACTIONS_ANNOTATION,
} from '@backstage/plugin-github-actions';
import {
JenkinsBuildsWidget,
JenkinsLastBuildWidget,
@@ -47,7 +50,7 @@ export const EntityPageOverview: FC<{ entity: Entity }> = ({ entity }) => {
<JenkinsBuildsWidget entity={entity} />
</Grid>
)}
{entity.metadata?.annotations?.['backstage.io/github-actions-id'] && (
{entity.metadata?.annotations?.[GITHUB_ACTIONS_ANNOTATION] && (
<Grid item sm={3}>
<LatestWorkflowRunCard entity={entity} branch="master" />
</Grid>
+1
View File
@@ -1,4 +1,5 @@
# Title
Welcome to the explore plugin!
## Sub-section 1
@@ -28,7 +28,7 @@ import {
} from '@material-ui/core';
import { BackstageTheme } from '@backstage/theme';
const useStyles = makeStyles<BackstageTheme>((theme) => ({
const useStyles = makeStyles<BackstageTheme>(theme => ({
card: {
display: 'flex',
flexDirection: 'column',
+1 -3
View File
@@ -17,6 +17,4 @@
import { createDevApp } from '@backstage/dev-utils';
import { plugin } from '../src/plugin';
createDevApp()
.registerPlugin(plugin)
.render();
createDevApp().registerPlugin(plugin).render();
@@ -32,6 +32,7 @@ import {
useApi,
} from '@backstage/core';
import ExternalLinkIcon from '@material-ui/icons/Launch';
import { GITHUB_ACTIONS_ANNOTATION } from '../useProjectName';
const useStyles = makeStyles<Theme>({
externalLinkIcon: {
@@ -83,7 +84,7 @@ export const LatestWorkflowRunCard = ({
}) => {
const errorApi = useApi(errorApiRef);
const [owner, repo] = (
entity?.metadata.annotations?.['backstage.io/github-actions-id'] ?? '/'
entity?.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION] ?? '/'
).split('/');
const [{ runs, loading, error }] = useWorkflowRuns({
owner,
@@ -24,7 +24,7 @@ import { BackstageTheme } from '@backstage/theme';
const GraphiQL = React.lazy(() => import('graphiql'));
const useStyles = makeStyles<BackstageTheme>((theme) => ({
const useStyles = makeStyles<BackstageTheme>(theme => ({
root: {
height: '100%',
display: 'flex',
@@ -31,7 +31,7 @@ const columns: TableColumn[] = [
title: 'Website URL',
field: 'websiteUrl',
},
...CATEGORIES.map((category) => ({
...CATEGORIES.map(category => ({
title: CATEGORY_LABELS[category],
field: category,
})),
@@ -56,7 +56,7 @@ export const AuditListTable: FC<{ items: Website[] }> = ({ items }) => {
const lighthouseApi = useApi(lighthouseApiRef);
const runRefresh = (websites: Website[]) => {
websites.forEach(async (website) => {
websites.forEach(async website => {
const response = await lighthouseApi.getWebsiteForAuditId(
website.lastAudit.id,
);
@@ -64,7 +64,7 @@ export const AuditListTable: FC<{ items: Website[] }> = ({ items }) => {
if (auditStatus === 'COMPLETED' || auditStatus === 'FAILED') {
const newWebsiteData = websiteState.slice(0);
newWebsiteData[
newWebsiteData.findIndex((w) => w.url === response.url)
newWebsiteData.findIndex(w => w.url === response.url)
] = response;
setWebsiteState(newWebsiteData);
}
@@ -72,7 +72,7 @@ export const AuditListTable: FC<{ items: Website[] }> = ({ items }) => {
};
const runningWebsiteAudits = websiteState
? websiteState.filter((website) => website.lastAudit.status === 'RUNNING')
? websiteState.filter(website => website.lastAudit.status === 'RUNNING')
: [];
useInterval(
@@ -80,10 +80,10 @@ export const AuditListTable: FC<{ items: Website[] }> = ({ items }) => {
runningWebsiteAudits.length > 0 ? 5000 : null,
);
const data = websiteState.map((website) => {
const data = websiteState.map(website => {
const trendlineData = buildSparklinesDataForItem(website);
const trendlines: any = {};
CATEGORIES.forEach((category) => {
CATEGORIES.forEach(category => {
trendlines[category] = (
<TrendLine
title={`trendline for ${CATEGORY_LABELS[category]} category of ${website.url}`}
@@ -71,7 +71,7 @@ export default builder.build() as ApiHolder;
\`\`\`
`;
const useStyles = makeStyles((theme) => ({
const useStyles = makeStyles(theme => ({
tabs: { marginBottom: -18 },
tab: { minWidth: 72, paddingLeft: 1, paddingRight: 1 },
content: { marginBottom: theme.spacing(2) },
+46 -14
View File
@@ -26,25 +26,57 @@ export { plugin as Rollbar } from '@backstage/plugin-rollbar';
import { RollbarClient, rollbarApiRef } from '@backstage/plugin-rollbar';
// ...
builder.add(
rollbarApiRef,
new RollbarClient({
apiOrigin: backendUrl,
basePath: '/rollbar',
}),
);
// Alternatively you can use the mock client
// builder.add(rollbarApiRef, new RollbarMockClient());
builder.add(rollbarApiRef, new RollbarClient({ discoveryApi }));
```
5. Run app with `yarn start` and navigate to `/rollbar`
5. Add to the app `EntityPage` component:
```ts
// packages/app/src/components/catalog/EntityPage.tsx
import { Router as RollbarRouter } from '@backstage/plugin-rollbar';
// ...
const ServiceEntityPage = ({ entity }: { entity: Entity }) => (
<EntityPageLayout>
// ...
<EntityPageLayout.Content
path="/rollbar"
title="Errors"
element={<RollbarRouter entity={entity} />}
/>
</EntityPageLayout>
);
```
6. Setup the `app.config.yaml` and account token environment variable
```yaml
# app.config.yaml
rollbar:
organization: spotify
accountToken:
$secret:
env: ROLLBAR_ACCOUNT_TOKEN
```
7. Annotate entities with the rollbar project slug
```yaml
# pump-station-catalog-component.yaml
# ...
metadata:
annotations:
rollbar.com/project-slug: organization-name/project-name
# -- or just ---
rollbar.com/project-slug: project-name
```
8. Run app with `yarn start` and navigate to `/rollbar` or a catalog entity
## Features
- List rollbar projects
- View top active items for each project
- List rollbar entities that are annotated with `rollbar.com/project-slug`
- View top active items for each rollbar annotated entity
## Limitations
+2
View File
@@ -21,7 +21,9 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.1.1-alpha.21",
"@backstage/core": "^0.1.1-alpha.21",
"@backstage/plugin-catalog": "^0.1.1-alpha.21",
"@backstage/theme": "^0.1.1-alpha.21",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
+1
View File
@@ -29,6 +29,7 @@ export const rollbarApiRef = createApiRef<RollbarApi>({
export interface RollbarApi {
getAllProjects(): Promise<RollbarProject[]>;
getProject(projectName: string): Promise<RollbarProject>;
getTopActiveItems(
project: string,
hours?: number,
+8 -8
View File
@@ -30,9 +30,11 @@ export class RollbarClient implements RollbarApi {
}
async getAllProjects(): Promise<RollbarProject[]> {
const path = `/projects`;
return await this.get(`/projects`);
}
return await this.get(path);
async getProject(projectName: string): Promise<RollbarProject> {
return await this.get(`/projects/${projectName}`);
}
async getTopActiveItems(
@@ -40,15 +42,13 @@ export class RollbarClient implements RollbarApi {
hours = 24,
environment = 'production',
): Promise<RollbarTopActiveItem[]> {
const path = `/projects/${project}/top_active_items?environment=${environment}&hours=${hours}`;
return await this.get(path);
return await this.get(
`/projects/${project}/top_active_items?environment=${environment}&hours=${hours}`,
);
}
async getProjectItems(project: string): Promise<RollbarItemsResponse> {
const path = `/projects/${project}/items`;
return await this.get(path);
return await this.get(`/projects/${project}/items`);
}
private async get(path: string): Promise<any> {
@@ -1,70 +0,0 @@
/*
* 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.
*/
/* eslint-disable @typescript-eslint/no-unused-vars */
import { RollbarApi } from './RollbarApi';
import {
RollbarItemsResponse,
RollbarProject,
RollbarTopActiveItem,
} from './types';
export class RollbarMockClient implements RollbarApi {
async getAllProjects(): Promise<RollbarProject[]> {
return Promise.resolve([
{ id: 123, name: 'project-a', accountId: 1, status: 'enabled' },
{ id: 356, name: 'project-b', accountId: 1, status: 'enabled' },
{ id: 789, name: 'project-c', accountId: 1, status: 'enabled' },
]);
}
async getTopActiveItems(
_project: string,
_hours = 24,
_environment = 'production',
): Promise<RollbarTopActiveItem[]> {
const createItem = (id: number): RollbarTopActiveItem => ({
item: {
id,
counter: id,
environment: 'production',
framework: 2,
lastOccurrenceTimestamp: new Date().getTime() / 1000,
level: 50,
occurrences: 100,
projectId: 12345,
title: `Some error occurred in service - ${id}`,
uniqueOccurrences: 10,
},
counts: Array.from({ length: 168 }, () =>
Math.floor(Math.random() * 100),
),
});
const items = Array.from({ length: 10 }, (_, i) => createItem(i));
return Promise.resolve(items);
}
async getProjectItems(_project: string): Promise<RollbarItemsResponse> {
return Promise.resolve({
items: [],
page: 0,
totalCount: 0,
});
}
}
+18
View File
@@ -0,0 +1,18 @@
/*
* 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 * from './RollbarApi';
export * from './RollbarClient';
@@ -0,0 +1,27 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { RollbarProject } from '../RollbarProject/RollbarProject';
type Props = {
entity: Entity;
};
export const EntityPageRollbar = ({ entity }: Props) => {
return <RollbarProject entity={entity} />;
};
@@ -21,13 +21,14 @@ import {
ConfigApi,
configApiRef,
} from '@backstage/core';
import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog';
import { wrapInTestApp } from '@backstage/test-utils';
import { render } from '@testing-library/react';
import { RollbarApi, rollbarApiRef } from '../../api/RollbarApi';
import { RollbarProject } from '../../api/types';
import { RollbarPage } from './RollbarPage';
import { RollbarHome } from './RollbarHome';
describe('RollbarPage component', () => {
describe('RollbarHome component', () => {
const projects: RollbarProject[] = [
{ id: 123, name: 'abc', accountId: 1, status: 'enabled' },
{ id: 456, name: 'xyz', accountId: 1, status: 'enabled' },
@@ -47,6 +48,14 @@ describe('RollbarPage component', () => {
apis={ApiRegistry.from([
[rollbarApiRef, rollbarApi],
[configApiRef, config],
[
catalogApiRef,
({
async getEntities() {
return [];
},
} as Partial<CatalogApi>) as CatalogApi,
],
])}
>
{children}
@@ -55,7 +64,7 @@ describe('RollbarPage component', () => {
);
it('should render rollbar landing page', async () => {
const rendered = renderWrapped(<RollbarPage />);
const rendered = renderWrapped(<RollbarHome />);
expect(rendered.getByText(/Rollbar/)).toBeInTheDocument();
});
});
@@ -14,42 +14,26 @@
* limitations under the License.
*/
import React, { ReactNode } from 'react';
import {
Header,
HeaderLabel,
Page,
pageTheme,
Content,
ContentHeader,
SupportButton,
} from '@backstage/core';
import { Grid } from '@material-ui/core';
import React from 'react';
import { Content, Header, Page, pageTheme } from '@backstage/core';
import { RollbarProjectTable } from '../RollbarProjectTable/RollbarProjectTable';
import { useRollbarEntities } from '../../hooks/useRollbarEntities';
type Props = {
title?: string;
children: ReactNode;
};
export const RollbarHome = () => {
const { entities, loading, error } = useRollbarEntities();
export const RollbarLayout = ({ title = 'Dashboard', children }: Props) => {
return (
<Page theme={pageTheme.tool}>
<Header
title="Rollbar"
subtitle="Real-time error tracking & debugging tools for developers"
>
<HeaderLabel label="Owner" value="Spotify" />
<HeaderLabel label="Lifecycle" value="Alpha" />
</Header>
/>
<Content>
<ContentHeader title={title}>
<SupportButton>
Rollbar plugin allows you to preview issues and navigate to rollbar.
</SupportButton>
</ContentHeader>
<Grid container spacing={3} direction="column">
{children}
</Grid>
<RollbarProjectTable
entities={entities || []}
loading={loading}
error={error}
/>
</Content>
</Page>
);
@@ -0,0 +1,40 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { useTopActiveItems } from '../../hooks/useTopActiveItems';
import { RollbarTopItemsTable } from '../RollbarTopItemsTable/RollbarTopItemsTable';
type Props = {
entity: Entity;
};
export const RollbarProject = ({ entity }: Props) => {
const { items, organization, project, loading, error } = useTopActiveItems(
entity,
);
return (
<RollbarTopItemsTable
organization={organization}
project={project}
items={items || []}
loading={loading}
error={error}
/>
);
};
@@ -26,6 +26,7 @@ import { render } from '@testing-library/react';
import { RollbarApi, rollbarApiRef } from '../../api/RollbarApi';
import { RollbarTopActiveItem } from '../../api/types';
import { RollbarProjectPage } from './RollbarProjectPage';
import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog';
describe('RollbarProjectPage component', () => {
const items: RollbarTopActiveItem[] = [
@@ -60,6 +61,17 @@ describe('RollbarProjectPage component', () => {
apis={ApiRegistry.from([
[rollbarApiRef, rollbarApi],
[configApiRef, config],
[
catalogApiRef,
({
async getEntityByName() {
return {
metadata: { name: 'foo' },
spec: { owner: 'bar', lifecycle: 'experimental' },
} as any;
},
} as Partial<CatalogApi>) as CatalogApi,
],
])}
>
{children}
@@ -69,6 +81,6 @@ describe('RollbarProjectPage component', () => {
it('should render rollbar project page', async () => {
const rendered = renderWrapped(<RollbarProjectPage />);
expect(rendered.getByText(/Top Active Items/)).toBeInTheDocument();
expect(rendered.getByText(/Rollbar/)).toBeInTheDocument();
});
});
@@ -15,39 +15,22 @@
*/
import React from 'react';
import { useParams } from 'react-router-dom';
import { useAsync } from 'react-use';
import { configApiRef, useApi } from '@backstage/core';
import { rollbarApiRef } from '../../api/RollbarApi';
import { RollbarLayout } from '../RollbarLayout/RollbarLayout';
import { RollbarTopItemsTable } from '../RollbarTopItemsTable/RollbarTopItemsTable';
import { Content, Header, HeaderLabel, Page, pageTheme } from '@backstage/core';
import { useCatalogEntity } from '../../hooks/useCatalogEntity';
import { RollbarProject } from '../RollbarProject/RollbarProject';
export const RollbarProjectPage = () => {
const configApi = useApi(configApiRef);
const rollbarApi = useApi(rollbarApiRef);
const org =
configApi.getOptionalString('rollbar.organization') ??
configApi.getString('organization.name');
const { componentId } = useParams() as {
componentId: string;
};
const { value, loading, error } = useAsync(() =>
rollbarApi
.getTopActiveItems(componentId, 168)
.then(data =>
data.sort((a, b) => b.item.occurrences - a.item.occurrences),
),
);
const { entity } = useCatalogEntity();
return (
<RollbarLayout>
<RollbarTopItemsTable
items={value || []}
organization={org}
project={componentId}
loading={loading}
error={error}
/>
</RollbarLayout>
<Page theme={pageTheme.tool}>
<Header title={entity?.metadata?.name} subtitle="Rollbar Project">
<HeaderLabel label="Owner" value={entity?.spec?.owner} />
<HeaderLabel label="Lifecycle" value={entity?.spec?.lifecycle} />
</Header>
<Content>
{entity ? <RollbarProject entity={entity} /> : 'Loading'}
</Content>
</Page>
);
};
@@ -15,68 +15,49 @@
*/
import React from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { Link as RouterLink, generatePath } from 'react-router-dom';
import { Table, TableColumn } from '@backstage/core';
import { Entity } from '@backstage/catalog-model';
import { Link } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import OpenInNewIcon from '@material-ui/icons/OpenInNew';
import { Table, TableColumn } from '@backstage/core';
import { RollbarProject } from '../../api/types';
const projectUrl = (org: string, id: number) =>
`https://rollbar.com/${org}/all/items/?projects=${id}`;
import { entityRouteRef } from '../../routes';
const columns: TableColumn[] = [
{
title: 'ID',
field: 'id',
type: 'numeric',
align: 'left',
width: '100px',
},
{
title: 'Name',
field: 'name',
field: 'metadata.name',
type: 'string',
highlight: true,
render: (row: Partial<RollbarProject>) => (
<Link component={RouterLink} to={`/rollbar/${row.name}`}>
{row.name}
</Link>
),
},
{
title: 'Status',
field: 'status',
type: 'string',
},
{
title: 'Open',
width: '10%',
render: (row: any) => (
render: (entity: any) => (
<Link
href={projectUrl(row.organization, row.id)}
target="_blank"
rel="noreferrer"
component={RouterLink}
to={generatePath(entityRouteRef.path, {
optionalNamespaceAndName: [
entity.metadata.namespace,
entity.metadata.name,
]
.filter(Boolean)
.join(':'),
kind: entity.kind,
})}
>
<OpenInNewIcon />
{entity.metadata.name}
</Link>
),
},
{
title: 'Description',
field: 'metadata.description',
},
];
type Props = {
projects: RollbarProject[];
entities: Entity[];
loading: boolean;
organization: string;
error?: any;
};
export const RollbarProjectTable = ({
projects,
organization,
loading,
error,
}: Props) => {
export const RollbarProjectTable = ({ entities, loading, error }: Props) => {
if (error) {
return (
<div>
@@ -92,14 +73,13 @@ export const RollbarProjectTable = ({
isLoading={loading}
columns={columns}
options={{
padding: 'dense',
search: true,
paging: true,
pageSize: 10,
showEmptyDataSourceMessage: !loading,
}}
title="Projects"
data={projects.map(p => ({ organization, ...p }))}
data={entities}
/>
);
};
@@ -16,17 +16,15 @@
import React from 'react';
import { Table, TableColumn } from '@backstage/core';
import { Link } from '@material-ui/core';
import { Box, Link, Typography } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import {
RollbarFrameworkId,
RollbarLevel,
RollbarTopActiveItem,
} from '../../api/types';
import { RollbarTrendGraph } from '../RollbarTrendGraph/RollbarTrendGraph';
const itemUrl = (org: string, project: string, id: number) =>
`https://rollbar.com/${org}/${project}/items/${id}`;
import { buildItemUrl } from '../../utils';
import { TrendGraph } from '../TrendGraph/TrendGraph';
const columns: TableColumn[] = [
{
@@ -37,7 +35,7 @@ const columns: TableColumn[] = [
width: '70px',
render: (data: any) => (
<Link
href={itemUrl(data.org, data.project, data.item.counter)}
href={buildItemUrl(data.org, data.project, data.item.counter)}
target="_blank"
rel="noreferrer"
>
@@ -54,7 +52,7 @@ const columns: TableColumn[] = [
{
title: 'Trend',
sorting: false,
render: (data: any) => <RollbarTrendGraph counts={data.counts} />,
render: (data: any) => <TrendGraph counts={data.counts} />,
},
{
title: 'Occurrences',
@@ -127,7 +125,12 @@ export const RollbarTopItemsTable = ({
pageSize: 5,
showEmptyDataSourceMessage: !loading,
}}
title="Top Active Items"
title={
<Box display="flex" alignItems="center">
<Box mr={1} />
<Typography variant="h6">Top Active Items / {project}</Typography>
</Box>
}
data={items.map(i => ({ org: organization, project, ...i }))}
/>
);
+47
View File
@@ -0,0 +1,47 @@
/*
* 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 { Routes, Route } from 'react-router';
import { Entity } from '@backstage/catalog-model';
import { WarningPanel } from '@backstage/core';
import { catalogRouteRef } from '../routes';
import { ROLLBAR_ANNOTATION } from '../constants';
import { EntityPageRollbar } from './EntityPageRollbar/EntityPageRollbar';
export const isPluginApplicableToEntity = (entity: Entity) =>
entity.metadata.annotations?.[ROLLBAR_ANNOTATION] !== '';
type Props = {
entity: Entity;
};
export const Router = ({ entity }: Props) =>
!isPluginApplicableToEntity(entity) ? (
<WarningPanel title="Rollbar plugin:">
<pre>
entity.metadata.annotations['{ROLLBAR_ANNOTATION}']` key is missing on
the entity.
</pre>
</WarningPanel>
) : (
<Routes>
<Route
path={`/${catalogRouteRef.path}`}
element={<EntityPageRollbar entity={entity} />}
/>
</Routes>
);
@@ -17,15 +17,13 @@
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { RollbarTrendGraph } from './RollbarTrendGraph';
import { TrendGraph } from './TrendGraph';
describe('RollbarTrendGraph component', () => {
describe('TrendGraph component', () => {
it('should render a trend graph sparkline', async () => {
const mockCounts = [1, 2, 3, 4];
const rendered = render(
wrapInTestApp(
<RollbarTrendGraph counts={mockCounts} data-testid="graph" />,
),
wrapInTestApp(<TrendGraph counts={mockCounts} data-testid="graph" />),
);
expect(rendered).toBeTruthy();
});
@@ -21,7 +21,7 @@ type Props = {
counts: number[];
};
export const RollbarTrendGraph = ({ counts }: Props) => {
export const TrendGraph = ({ counts }: Props) => {
return (
<Sparklines data={counts} svgHeight={48} min={0} margin={4}>
<SparklinesBars barWidth={2} />
+17
View File
@@ -0,0 +1,17 @@
/*
* 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 ROLLBAR_ANNOTATION = 'rollbar.com/project-slug';
@@ -0,0 +1,34 @@
/*
* 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 { useAsync } from 'react-use';
import { useApi } from '@backstage/core';
import {
catalogApiRef,
useEntityCompoundName,
} from '@backstage/plugin-catalog';
export function useCatalogEntity() {
const catalogApi = useApi(catalogApiRef);
const { namespace, name } = useEntityCompoundName();
const { value: entity, error, loading } = useAsync(
() => catalogApi.getEntityByName({ kind: 'Component', namespace, name }),
[catalogApi, namespace, name],
);
return { entity, error, loading };
}
+37
View File
@@ -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 { useApi, configApiRef } from '@backstage/core';
import { Entity } from '@backstage/catalog-model';
import { ROLLBAR_ANNOTATION } from '../constants';
export function useProjectSlugFromEntity(entity: Entity) {
const configApi = useApi(configApiRef);
const [project, organization] = (
entity?.metadata?.annotations?.[ROLLBAR_ANNOTATION] ?? ''
)
.split('/')
.reverse();
return {
project,
organization:
organization ??
configApi.getOptionalString('rollbar.organization') ??
configApi.getString('organization.name'),
};
}
@@ -14,29 +14,25 @@
* limitations under the License.
*/
import React from 'react';
import { useAsync } from 'react-use';
import { configApiRef, useApi } from '@backstage/core';
import { rollbarApiRef } from '../../api/RollbarApi';
import { RollbarLayout } from '../RollbarLayout/RollbarLayout';
import { RollbarProjectTable } from './RollbarProjectTable';
import { useApi, configApiRef } from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog';
import { ROLLBAR_ANNOTATION } from '../constants';
export const RollbarPage = () => {
export function useRollbarEntities() {
const configApi = useApi(configApiRef);
const rollbarApi = useApi(rollbarApiRef);
const org =
const catalogApi = useApi(catalogApiRef);
const organization =
configApi.getOptionalString('rollbar.organization') ??
configApi.getString('organization.name');
const { value, loading, error } = useAsync(() => rollbarApi.getAllProjects());
return (
<RollbarLayout>
<RollbarProjectTable
projects={value || []}
organization={org}
loading={loading}
error={error}
/>
</RollbarLayout>
);
};
const { value, loading, error } = useAsync(async () => {
const entities = await catalogApi.getEntities();
return entities.filter(entity => {
return !!entity.metadata.annotations?.[ROLLBAR_ANNOTATION];
});
}, [catalogApi]);
return { entities: value, organization, loading, error };
}
@@ -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 { useAsync } from 'react-use';
import { useApi } from '@backstage/core';
import { Entity } from '@backstage/catalog-model';
import { rollbarApiRef } from '../api';
import { RollbarTopActiveItem } from '../api/types';
import { useProjectSlugFromEntity } from './useProject';
export function useTopActiveItems(entity: Entity) {
const api = useApi(rollbarApiRef);
const { organization, project } = useProjectSlugFromEntity(entity);
const { value, loading, error } = useAsync(() => {
if (!project) {
return Promise.resolve([]);
}
return api
.getTopActiveItems(project, 168)
.then(data =>
data.sort((a, b) => b.item.occurrences - a.item.occurrences),
);
}, [api, organization, project, entity]);
return {
items: value as RollbarTopActiveItem[],
organization,
project,
loading,
error,
};
}
+6 -3
View File
@@ -15,6 +15,9 @@
*/
export { plugin } from './plugin';
export * from './api/RollbarApi';
export { RollbarClient } from './api/RollbarClient';
export { RollbarMockClient } from './api/RollbarMockClient';
export * from './api';
export * from './routes';
export { Router } from './components/Router';
export { RollbarProjectPage } from './components/RollbarProjectPage/RollbarProjectPage';
export { EntityPageRollbar } from './components/EntityPageRollbar/EntityPageRollbar';
export { ROLLBAR_ANNOTATION } from './constants';
+4 -4
View File
@@ -15,14 +15,14 @@
*/
import { createPlugin } from '@backstage/core';
import { RollbarPage } from './components/RollbarPage/RollbarPage';
import { rootRouteRef, entityRouteRef } from './routes';
import { RollbarHome } from './components/RollbarHome/RollbarHome';
import { RollbarProjectPage } from './components/RollbarProjectPage/RollbarProjectPage';
import { rootRoute, rootProjectRoute } from './routes';
export const plugin = createPlugin({
id: 'rollbar',
register({ router }) {
router.addRoute(rootRoute, RollbarPage);
router.addRoute(rootProjectRoute, RollbarProjectPage);
router.addRoute(rootRouteRef, RollbarHome);
router.addRoute(entityRouteRef, RollbarProjectPage);
},
});
+12 -3
View File
@@ -16,12 +16,21 @@
import { createRouteRef } from '@backstage/core';
export const rootRoute = createRouteRef({
const NoIcon = () => null;
export const rootRouteRef = createRouteRef({
icon: NoIcon,
path: '/rollbar',
title: 'Rollbar Home',
});
export const rootProjectRoute = createRouteRef({
path: '/rollbar/:componentId/*',
export const entityRouteRef = createRouteRef({
path: '/rollbar/:optionalNamespaceAndName',
title: 'Rollbar',
});
export const catalogRouteRef = createRouteRef({
icon: NoIcon,
path: '',
title: 'Rollbar',
});
+21
View File
@@ -0,0 +1,21 @@
/*
* 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 buildProjectUrl = (org: string, id: number) =>
`https://rollbar.com/${org}/all/items/?projects=${id}`;
export const buildItemUrl = (org: string, project: string, id: number) =>
`https://rollbar.com/${org}/${project}/items/${id}`;
+1
View File
@@ -1,4 +1,5 @@
# Title
Welcome to the scaffolder plugin!
## Sub-section 1
@@ -5,7 +5,6 @@ metadata:
description: {{cookiecutter.description}}
annotations:
github.com/project-slug: {{cookiecutter.storePath}}
backstage.io/github-actions-id: {{cookiecutter.storePath}}
backstage.io/techdocs-ref: github:https://github.com/{{cookiecutter.storePath}}
spec:
type: website
@@ -2,11 +2,12 @@ apiVersion: backstage.io/v1alpha1
kind: Template
metadata:
name: springboot-template
title: Spring Boot GRPC Service
title: Spring Boot gRPC Service
description: Create a simple microservice using gRPC and Spring Boot Java
tags:
- recommended
- java
- grpc
spec:
owner: service@example.com
templater: cookiecutter
@@ -29,4 +30,4 @@ spec:
title: Port
type: integer
default: 8080
description: The port to run the GRPC service on
description: The port to run the gRPC service on
@@ -5,7 +5,7 @@ metadata:
description: {{cookiecutter.description}}
annotations:
github.com/project-slug: {{cookiecutter.storePath}}
backstage.io/github-actions-id: {{cookiecutter.storePath}}
backstage.io/techdocs-ref: github:https://github.com/{{cookiecutter.storePath}}
spec:
type: service
lifecycle: experimental
@@ -0,0 +1,28 @@
## {{ cookiecutter.component_id }}
{{ cookiecutter.description }}
## Getting started
Start write your documentation by adding more markdown (.md) files to this folder (/docs) or replace the content in this file.
## Table of Contents
The Table of Contents on the right is generated automatically based on the hierarchy
of headings. Only use one H1 (`#` in Markdown) per file.
## Site navigation
For new pages to appear in the left hand navigation you need edit the `mkdocs.yml`
file in root of your repo. The navigation can also link out to other sites.
Alternatively, if there is no `nav` section in `mkdocs.yml`, a navigation section
will be created for you. However, you will not be able to use alternate titles for
pages, or include links to other sites.
Note that MkDocs uses `mkdocs.yml`, not `mkdocs.yaml`, although both appear to work.
See also <https://www.mkdocs.org/user-guide/configuration/>.
## Support
That's it. If you need support, reach out in [#docs-like-code](https://discord.com/channels/687207715902193673/714754240933003266) on Discord.
@@ -0,0 +1,8 @@
site_name: {{cookiecutter.component_id}}
site_description: {{cookiecutter.description}}
nav:
- Introduction: index.md
plugins:
- techdocs-core
-1
View File
@@ -16,4 +16,3 @@
export * from './scaffolder';
export * from './service/router';
@@ -96,6 +96,8 @@ describe('GitHubPreparer', () => {
mockEntity.spec.path = './template/test/1/2/3';
const response = await preparer.prepare(mockEntity);
expect(response).toMatch(new RegExp(/\/template\/test\/1\/2\/3$/));
expect(response.split('\\').join('/')).toMatch(
/\/template\/test\/1\/2\/3$/,
);
});
});
@@ -20,6 +20,13 @@ import Docker from 'dockerode';
import { runDockerContainer } from './helpers';
describe('helpers', () => {
if (process.platform === 'win32') {
// eslint-disable-next-line jest/no-focused-tests
it.only('should skip tests on windows', () => {
expect('test').not.toBe('run');
});
}
const mockDocker = new Docker() as jest.Mocked<Docker>;
beforeEach(() => {
@@ -26,7 +26,7 @@ export function getRequestHeaders(token: string) {
}
export function getSentryApiForwarder(token: string, logger: Logger) {
return function fowardRequest(
return function forwardRequest(
request: express.Request,
response: express.Response,
) {
@@ -17,6 +17,13 @@ import { DirectoryPreparer } from './dir';
import { getVoidLogger } from '@backstage/backend-common';
import { checkoutGitRepository } from './helpers';
function normalizePath(path: string) {
return path
.replace(/^[a-z]:/i, '')
.split('\\')
.join('/');
}
jest.mock('./helpers', () => ({
...jest.requireActual<{}>('./helpers'),
checkoutGitRepository: jest.fn(() => '/tmp/backstage-repo/org/name/branch/'),
@@ -47,7 +54,7 @@ describe('directory preparer', () => {
'backstage.io/techdocs-ref': 'dir:./our-documentation',
});
expect(await directoryPreparer.prepare(mockEntity)).toEqual(
expect(normalizePath(await directoryPreparer.prepare(mockEntity))).toEqual(
'/directory/our-documentation',
);
});
@@ -61,7 +68,7 @@ describe('directory preparer', () => {
'backstage.io/techdocs-ref': 'dir:/our-documentation/techdocs',
});
expect(await directoryPreparer.prepare(mockEntity)).toEqual(
expect(normalizePath(await directoryPreparer.prepare(mockEntity))).toEqual(
'/our-documentation/techdocs',
);
});
@@ -75,7 +82,7 @@ describe('directory preparer', () => {
'backstage.io/techdocs-ref': 'dir:./docs',
});
expect(await directoryPreparer.prepare(mockEntity)).toEqual(
expect(normalizePath(await directoryPreparer.prepare(mockEntity))).toEqual(
'/tmp/backstage-repo/org/name/branch/docs',
);
expect(checkoutGitRepository).toHaveBeenCalledTimes(1);
@@ -18,6 +18,13 @@ import { getVoidLogger } from '@backstage/backend-common';
import { GithubPreparer } from './github';
import { checkoutGithubRepository } from './helpers';
function normalizePath(path: string) {
return path
.replace(/^[a-z]:/i, '')
.split('\\')
.join('/');
}
jest.mock('./helpers', () => ({
...jest.requireActual<{}>('./helpers'),
checkoutGithubRepository: jest.fn(
@@ -51,7 +58,7 @@ describe('github preparer', () => {
const tempDocsPath = await preparer.prepare(mockEntity);
expect(checkoutGithubRepository).toHaveBeenCalledTimes(1);
expect(tempDocsPath).toEqual(
expect(normalizePath(tempDocsPath)).toEqual(
'/tmp/backstage-repo/org/name/branch/plugins/techdocs-backend/examples/documented-component',
);
});
@@ -16,19 +16,16 @@
import React from 'react';
import { Entity } from '@backstage/catalog-model';
import { Reader } from '@backstage/plugin-techdocs';
import { Content } from '@backstage/core';
import { Reader } from './reader';
export const EntityPageDocs = ({ entity }: { entity: Entity }) => {
return (
<Content>
<Reader
entityId={{
kind: entity.kind,
namespace: entity.metadata.namespace,
name: entity.metadata.name,
}}
/>
</Content>
<Reader
entityId={{
kind: entity.kind,
namespace: entity.metadata.namespace,
name: entity.metadata.name,
}}
/>
);
};
+48
View File
@@ -0,0 +1,48 @@
/*
* 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 { Route, Routes } from 'react-router-dom';
import { Entity } from '@backstage/catalog-model';
import {
rootRouteRef,
rootDocsRouteRef,
rootCatalogDocsRouteRef,
} from './plugin';
import { TechDocsHome } from './reader/components/TechDocsHome';
import { TechDocsPage } from './reader/components/TechDocsPage';
import { EntityPageDocs } from './EntityPageDocs';
export const Router = () => {
return (
<Routes>
<Route path={`/${rootRouteRef.path}`} element={<TechDocsHome />} />
<Route path={`/${rootDocsRouteRef.path}`} element={<TechDocsPage />} />
</Routes>
);
};
export const EmbeddedDocsRouter = ({ entity }: { entity: Entity }) => {
return (
<Routes>
<Route
path={`/${rootCatalogDocsRouteRef.path}`}
element={<EntityPageDocs entity={entity} />}
/>
</Routes>
);
};
+6 -2
View File
@@ -42,7 +42,9 @@ export class TechDocsStorageApi implements TechDocsStorage {
async getEntityDocs(entityId: ParsedEntityId, path: string) {
const { kind, namespace, name } = entityId;
const url = `${this.apiOrigin}/${kind}/${namespace ? namespace : 'default'}/${name}/${path}`;
const url = `${this.apiOrigin}/${kind}/${
namespace ? namespace : 'default'
}/${name}/${path}`;
const request = await fetch(
`${url.endsWith('/') ? url : `${url}/`}index.html`,
@@ -64,7 +66,9 @@ export class TechDocsStorageApi implements TechDocsStorage {
return new URL(
oldBaseUrl,
`${this.apiOrigin}/${kind}/${namespace ? namespace : 'default'}/${name}/${path}`,
`${this.apiOrigin}/${kind}/${
namespace ? namespace : 'default'
}/${name}/${path}`,
).toString();
}
}
+1
View File
@@ -15,5 +15,6 @@
*/
export { plugin } from './plugin';
export { Router, EmbeddedDocsRouter } from './Router';
export * from './reader';
export * from './api';
+7 -8
View File
@@ -30,23 +30,22 @@
*/
import { createPlugin, createRouteRef } from '@backstage/core';
import { TechDocsHome } from './reader/components/TechDocsHome';
import { TechDocsPage } from './reader/components/TechDocsPage';
export const rootRouteRef = createRouteRef({
path: '/docs',
path: '',
title: 'TechDocs Landing Page',
});
export const rootDocsRouteRef = createRouteRef({
path: '/docs/:entityId/*',
path: ':entityId/*',
title: 'Docs',
});
export const rootCatalogDocsRouteRef = createRouteRef({
path: '*',
title: 'Docs',
});
export const plugin = createPlugin({
id: 'techdocs',
register({ router }) {
router.addRoute(rootRouteRef, TechDocsHome);
router.addRoute(rootDocsRouteRef, TechDocsPage);
},
});
@@ -16,11 +16,12 @@
import React from 'react';
import { useAsync } from 'react-use';
import { useNavigate } from 'react-router-dom';
import { useNavigate, generatePath } from 'react-router-dom';
import { Grid } from '@material-ui/core';
import { ItemCard, Progress, useApi } from '@backstage/core';
import { TechDocsPageWrapper } from './TechDocsPageWrapper';
import { catalogApiRef } from '@backstage/plugin-catalog';
import { rootDocsRouteRef } from '../../plugin';
export const TechDocsHome = () => {
const catalogApi = useApi(catalogApiRef);
@@ -67,9 +68,11 @@ export const TechDocsHome = () => {
<ItemCard
onClick={() =>
navigate(
`/docs/${entity.kind}:${
entity.metadata.namespace ?? ''
}:${entity.metadata.name}`,
generatePath(rootDocsRouteRef.path, {
entityId: `${entity.kind}:${
entity.metadata.namespace ?? ''
}:${entity.metadata.name}`,
}),
)
}
title={entity.metadata.name}
+1
View File
@@ -1,4 +1,5 @@
# Title
Welcome to the welcome plugin!
## Sub-section 1