Add Periskop plugin
Signed-off-by: Julio Zynger <julio.zynger@soundcloud.com>
This commit is contained in:
committed by
Fredrik Adelöw
parent
c8f5f5c1d2
commit
7ef026339d
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ConfigApi, DiscoveryApi } from '@backstage/core-plugin-api';
|
||||
import { AggregatedError, NotFoundInLocation } from '../types';
|
||||
|
||||
type Options = {
|
||||
discoveryApi: DiscoveryApi;
|
||||
configApi: ConfigApi;
|
||||
};
|
||||
|
||||
type PeriskopLocation = {
|
||||
name: string;
|
||||
host: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* API abstraction to interact with Periskop's backends.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class PeriskopApi {
|
||||
private readonly discoveryApi: DiscoveryApi;
|
||||
private readonly locations: PeriskopLocation[];
|
||||
|
||||
constructor(options: Options) {
|
||||
this.discoveryApi = options.discoveryApi;
|
||||
this.locations = options.configApi
|
||||
.getConfigArray('periskop.locations')
|
||||
.flatMap(locConf => {
|
||||
const name = locConf.getString('name');
|
||||
const host = locConf.getString('host');
|
||||
return { name: name, host: host };
|
||||
});
|
||||
}
|
||||
|
||||
private getApiUrl(locationName: string): string | undefined {
|
||||
return this.locations.find(loc => loc.name === locationName)?.host;
|
||||
}
|
||||
|
||||
getLocationNames(): string[] {
|
||||
return this.locations.map(e => e.name);
|
||||
}
|
||||
|
||||
getErrorInstanceUrl(
|
||||
locationName: string,
|
||||
serviceName: string,
|
||||
error: AggregatedError,
|
||||
): string {
|
||||
return `${this.getApiUrl(
|
||||
locationName,
|
||||
)}/#/${serviceName}/errors/${encodeURIComponent(error.aggregation_key)}`;
|
||||
}
|
||||
|
||||
async getErrors(
|
||||
locationName: string,
|
||||
serviceName: string,
|
||||
): Promise<AggregatedError[] | NotFoundInLocation> {
|
||||
const apiUrl = `${await this.discoveryApi.getBaseUrl(
|
||||
'periskop',
|
||||
)}/${locationName}/${serviceName}`;
|
||||
const response = await fetch(apiUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return {
|
||||
body: await response.text(),
|
||||
};
|
||||
}
|
||||
throw new Error(
|
||||
`failed to fetch data, status ${response.status}: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import moment from 'moment';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
Content,
|
||||
ContentHeader,
|
||||
Table,
|
||||
TableColumn,
|
||||
Progress,
|
||||
StatusWarning,
|
||||
StatusError,
|
||||
StatusPending,
|
||||
Select,
|
||||
EmptyState,
|
||||
Link,
|
||||
} from '@backstage/core-components';
|
||||
import Alert from '@material-ui/lab/Alert';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
import { periskopApiRef } from '../..';
|
||||
import { AggregatedError, NotFoundInLocation } from '../../types';
|
||||
|
||||
/**
|
||||
* Constant storing Periskop project name.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const PERISKOP_NAME_ANNOTATION = 'periskop.io/name';
|
||||
|
||||
/**
|
||||
* Returns true if Periskop annotation is present in the given entity.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const isPeriskopAvailable = (entity: Entity): boolean =>
|
||||
Boolean(entity.metadata.annotations?.[PERISKOP_NAME_ANNOTATION]);
|
||||
|
||||
const renderKey = (
|
||||
error: AggregatedError,
|
||||
linkTarget: string,
|
||||
): React.ReactNode => {
|
||||
return (
|
||||
<Link to={linkTarget} target="_blank" rel="noreferrer">
|
||||
{error.aggregation_key}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSeverity = (severity: string): React.ReactNode => {
|
||||
if (severity.toLocaleLowerCase('en-US') === 'warning') {
|
||||
return <StatusWarning>{severity}</StatusWarning>;
|
||||
} else if (severity.toLocaleLowerCase('en-US') === 'error') {
|
||||
return <StatusError>{severity}</StatusError>;
|
||||
}
|
||||
return <StatusPending>{severity}</StatusPending>;
|
||||
};
|
||||
|
||||
const renderLastOccurrence = (error: AggregatedError): React.ReactNode => {
|
||||
return moment(new Date(error.latest_errors[0].timestamp * 1000)).fromNow();
|
||||
};
|
||||
|
||||
function isNotFoundInLocation(
|
||||
apiResult: AggregatedError[] | NotFoundInLocation | undefined,
|
||||
): apiResult is NotFoundInLocation {
|
||||
return (apiResult as NotFoundInLocation)?.body !== undefined;
|
||||
}
|
||||
|
||||
export const PeriskopErrorsTable = () => {
|
||||
const { entity } = useEntity();
|
||||
const entityPeriskopName: string =
|
||||
(entity.metadata.annotations?.[PERISKOP_NAME_ANNOTATION] as
|
||||
| string
|
||||
| undefined) ?? entity.metadata.name;
|
||||
|
||||
const periskopApi = useApi(periskopApiRef);
|
||||
const locations = periskopApi.getLocationNames();
|
||||
const [locationOption, setLocationOption] = React.useState<string>(
|
||||
locations[0],
|
||||
);
|
||||
const {
|
||||
value: aggregatedErrors,
|
||||
loading,
|
||||
error,
|
||||
} = useAsync(async (): Promise<AggregatedError[] | NotFoundInLocation> => {
|
||||
return periskopApi.getErrors(locationOption, entityPeriskopName);
|
||||
}, [locationOption]);
|
||||
|
||||
if (loading) {
|
||||
return <Progress />;
|
||||
} else if (error) {
|
||||
return <Alert severity="error">{error.message}</Alert>;
|
||||
}
|
||||
|
||||
const columns: TableColumn<AggregatedError>[] = [
|
||||
{
|
||||
title: 'Key',
|
||||
field: 'aggregation_key',
|
||||
highlight: true,
|
||||
sorting: false,
|
||||
render: aggregatedError => {
|
||||
const errorUrl = periskopApi.getErrorInstanceUrl(
|
||||
locationOption,
|
||||
entityPeriskopName,
|
||||
aggregatedError,
|
||||
);
|
||||
return renderKey(aggregatedError, errorUrl);
|
||||
},
|
||||
},
|
||||
{ title: 'Occurrences', field: 'total_count', sorting: true },
|
||||
{
|
||||
title: 'Last Occurrence',
|
||||
render: aggregatedError => renderLastOccurrence(aggregatedError),
|
||||
defaultSort: 'asc',
|
||||
customSort: (a, b) =>
|
||||
b.latest_errors[0].timestamp - a.latest_errors[0].timestamp,
|
||||
type: 'datetime',
|
||||
},
|
||||
{
|
||||
title: 'Severity',
|
||||
field: 'severity',
|
||||
render: aggregatedError => renderSeverity(aggregatedError.severity),
|
||||
sorting: false,
|
||||
},
|
||||
];
|
||||
|
||||
const sortingSelect = (
|
||||
<Select
|
||||
selected={locationOption}
|
||||
label="Location"
|
||||
items={locations.map(e => ({
|
||||
label: e,
|
||||
value: e,
|
||||
}))}
|
||||
onChange={el => setLocationOption(el.toString())}
|
||||
/>
|
||||
);
|
||||
|
||||
const contentHeader = (
|
||||
<ContentHeader title="Periskop" description={entityPeriskopName}>
|
||||
{sortingSelect}
|
||||
</ContentHeader>
|
||||
);
|
||||
|
||||
if (isNotFoundInLocation(aggregatedErrors)) {
|
||||
return (
|
||||
<Content noPadding>
|
||||
{contentHeader}
|
||||
<EmptyState
|
||||
title="Periskop returned an error"
|
||||
description={aggregatedErrors.body}
|
||||
missing="data"
|
||||
/>
|
||||
</Content>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Content noPadding>
|
||||
{contentHeader}
|
||||
<Table
|
||||
options={{
|
||||
search: false,
|
||||
paging: false,
|
||||
filtering: true,
|
||||
padding: 'dense',
|
||||
toolbar: false,
|
||||
}}
|
||||
columns={columns}
|
||||
data={aggregatedErrors || []}
|
||||
/>
|
||||
</Content>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export {
|
||||
PeriskopErrorsTable,
|
||||
isPeriskopAvailable,
|
||||
PERISKOP_NAME_ANNOTATION,
|
||||
} from './PeriskopErrorsTable';
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { periskopPlugin } from './plugin';
|
||||
import { createComponentExtension } from '@backstage/core-plugin-api';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const PeriskopErrorsTable = periskopPlugin.provide(
|
||||
createComponentExtension({
|
||||
name: 'PeriskopErrorsTable',
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/PeriskopErrorsTable').then(
|
||||
m => m.PeriskopErrorsTable,
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { periskopPlugin, periskopApiRef } from './plugin';
|
||||
export { PeriskopErrorsTable } from './extensions';
|
||||
export {
|
||||
isPeriskopAvailable,
|
||||
PERISKOP_NAME_ANNOTATION,
|
||||
} from './components/PeriskopErrorsTable';
|
||||
export * from './api/index';
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { periskopPlugin } from './plugin';
|
||||
|
||||
describe('periskop', () => {
|
||||
it('should export plugin', () => {
|
||||
expect(periskopPlugin).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
discoveryApiRef,
|
||||
configApiRef,
|
||||
createApiFactory,
|
||||
createPlugin,
|
||||
createApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { PeriskopApi } from './api';
|
||||
|
||||
import { rootRouteRef } from './routes';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const periskopApiRef = createApiRef<PeriskopApi>({
|
||||
id: 'plugin.periskop.service',
|
||||
});
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const periskopPlugin = createPlugin({
|
||||
id: 'periskop',
|
||||
routes: {
|
||||
root: rootRouteRef,
|
||||
},
|
||||
apis: [
|
||||
createApiFactory({
|
||||
api: periskopApiRef,
|
||||
deps: { configApi: configApiRef, discoveryApi: discoveryApiRef },
|
||||
factory: ({ configApi, discoveryApi }) =>
|
||||
new PeriskopApi({ configApi, discoveryApi }),
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createRouteRef } from '@backstage/core-plugin-api';
|
||||
|
||||
export const rootRouteRef = createRouteRef({
|
||||
id: 'periskop',
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
import 'cross-fetch/polyfill';
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export interface AggregatedError {
|
||||
aggregation_key: string;
|
||||
total_count: number;
|
||||
severity: string;
|
||||
latest_errors: ErrorInstance[];
|
||||
}
|
||||
export interface ErrorInstance {
|
||||
error: Error;
|
||||
uuid: string;
|
||||
timestamp: number;
|
||||
severity: string;
|
||||
http_context: HttpContext;
|
||||
}
|
||||
export interface Error {
|
||||
class: string;
|
||||
message: string;
|
||||
stacktrace?: string[];
|
||||
cause?: Error | null;
|
||||
}
|
||||
export interface HttpContext {
|
||||
request_method: string;
|
||||
request_url: string;
|
||||
request_headers: RequestHeaders;
|
||||
request_body: string;
|
||||
}
|
||||
export interface RequestHeaders {
|
||||
[k: string]: string;
|
||||
}
|
||||
|
||||
export interface NotFoundInLocation {
|
||||
body: string;
|
||||
}
|
||||
Reference in New Issue
Block a user