feat(slo): scaffold slo details page (#146919)

This commit is contained in:
Kevin Delemme 2022-12-07 12:39:59 -05:00 committed by GitHub
parent 9998b328fc
commit a0b08dae24
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 361 additions and 1 deletions

View file

@ -0,0 +1,24 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiLoadingSpinner } from '@elastic/eui';
import React from 'react';
import { SLO } from '../../../typings';
export interface Props {
slo: SLO | undefined;
isLoading: boolean;
}
export function PageTitle(props: Props) {
const { isLoading, slo } = props;
if (isLoading) {
return <EuiLoadingSpinner data-test-subj="loadingTitle" />;
}
return <>{slo && slo.name}</>;
}

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { ComponentStory } from '@storybook/react';
import { SloDetails as Component, Props } from './slo_details';
import { anSLO } from '../mocks/slo';
export default {
component: Component,
title: 'app/SLO/DetailsPage/SloDetails',
argTypes: {},
};
const Template: ComponentStory<typeof Component> = (props: Props) => <Component {...props} />;
const defaultProps: Props = {
slo: anSLO,
};
export const SloDetails = Template.bind({});
SloDetails.args = defaultProps;

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { SLO } from '../../../typings';
export interface Props {
slo: SLO;
}
export function SloDetails(props: Props) {
const { slo } = props;
return <pre data-test-subj="sloDetails">{JSON.stringify(slo, null, 2)}</pre>;
}

View file

@ -0,0 +1,73 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { HttpSetup } from '@kbn/core-http-browser';
import { useCallback, useMemo } from 'react';
import { useDataFetcher } from '../../../hooks/use_data_fetcher';
import { SLO } from '../../../typings';
interface UseFetchSloDetailsResponse {
loading: boolean;
slo: SLO | undefined;
}
function useFetchSloDetails(sloId: string): UseFetchSloDetailsResponse {
const params = useMemo(() => ({ sloId }), [sloId]);
const shouldExecuteApiCall = useCallback(
(apiCallParams: { sloId: string }) => params.sloId === apiCallParams.sloId,
[params]
);
const { loading, data: slo } = useDataFetcher<{ sloId: string }, SLO | undefined>({
paramsForApiCall: params,
initialDataState: undefined,
executeApiCall: fetchSlo,
shouldExecuteApiCall,
});
return { loading, slo };
}
const fetchSlo = async (
params: { sloId: string },
abortController: AbortController,
http: HttpSetup
): Promise<SLO | undefined> => {
try {
const response = await http.get<Record<string, unknown>>(
`/api/observability/slos/${params.sloId}`,
{
query: {},
signal: abortController.signal,
}
);
if (response !== undefined) {
return toSLO(response);
}
} catch (error) {
// ignore error for retrieving slos
}
return undefined;
};
function toSLO(result: any): SLO {
return {
id: String(result.id),
name: String(result.name),
objective: { target: Number(result.objective.target) },
summary: {
sliValue: Number(result.summary.sli_value),
errorBudget: {
remaining: Number(result.summary.error_budget.remaining),
},
},
};
}
export type { UseFetchSloDetailsResponse };
export { useFetchSloDetails };

View file

@ -0,0 +1,92 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { screen } from '@testing-library/react';
import { ConfigSchema } from '../../plugin';
import { Subset } from '../../typings';
import { useKibana } from '../../utils/kibana_react';
import { kibanaStartMock } from '../../utils/kibana_react.mock';
import { render } from '../../utils/test_helper';
import { SloDetailsPage } from '.';
import { useFetchSloDetails } from './hooks/use_fetch_slo_details';
import { anSLO } from './mocks/slo';
import { useParams } from 'react-router-dom';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: jest.fn(),
}));
jest.mock('./hooks/use_fetch_slo_details');
jest.mock('../../utils/kibana_react');
jest.mock('../../hooks/use_breadcrumbs');
const useFetchSloDetailsMock = useFetchSloDetails as jest.Mock;
const useParamsMock = useParams as jest.Mock;
const useKibanaMock = useKibana as jest.Mock;
const mockKibana = () => {
useKibanaMock.mockReturnValue({
services: {
...kibanaStartMock.startContract(),
http: {
basePath: {
prepend: jest.fn(),
},
},
},
});
};
const config: Subset<ConfigSchema> = {
unsafe: {
slo: { enabled: true },
},
};
describe('SLO Details Page', () => {
beforeEach(() => {
jest.clearAllMocks();
mockKibana();
});
it('renders the not found page when the feature flag is not enabled', async () => {
useParamsMock.mockReturnValue(anSLO.id);
useFetchSloDetailsMock.mockReturnValue({ loading: false, slo: anSLO });
render(<SloDetailsPage />, { unsafe: { slo: { enabled: false } } });
expect(screen.queryByTestId('pageNotFound')).toBeTruthy();
});
it('renders the not found page when the SLO cannot be found', async () => {
useParamsMock.mockReturnValue('inexistant');
useFetchSloDetailsMock.mockReturnValue({ loading: false, slo: undefined });
render(<SloDetailsPage />, config);
expect(screen.queryByTestId('pageNotFound')).toBeTruthy();
});
it('renders the loading spiner when fetching the SLO', async () => {
useParamsMock.mockReturnValue(anSLO.id);
useFetchSloDetailsMock.mockReturnValue({ loading: true, slo: undefined });
render(<SloDetailsPage />, config);
expect(screen.queryByTestId('pageNotFound')).toBeFalsy();
expect(screen.queryByTestId('loadingTitle')).toBeTruthy();
expect(screen.queryByTestId('loadingDetails')).toBeTruthy();
});
it('renders the SLO details page when the feature flag is enabled', async () => {
useParamsMock.mockReturnValue(anSLO.id);
useFetchSloDetailsMock.mockReturnValue({ loading: false, slo: anSLO });
render(<SloDetailsPage />, config);
expect(screen.queryByTestId('sloDetailsPage')).toBeTruthy();
expect(screen.queryByTestId('sloDetails')).toBeTruthy();
});
});

View file

@ -0,0 +1,67 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { useParams } from 'react-router-dom';
import { IBasePath } from '@kbn/core-http-browser';
import { EuiBreadcrumbProps } from '@elastic/eui/src/components/breadcrumbs/breadcrumb';
import { EuiLoadingSpinner } from '@elastic/eui';
import { ObservabilityAppServices } from '../../application/types';
import { paths } from '../../config';
import { usePluginContext } from '../../hooks/use_plugin_context';
import { useBreadcrumbs } from '../../hooks/use_breadcrumbs';
import { useKibana } from '../../utils/kibana_react';
import PageNotFound from '../404';
import { isSloFeatureEnabled } from '../slos/helpers';
import { SLOS_BREADCRUMB_TEXT } from '../slos/translations';
import { SloDetailsPathParams } from './types';
import { useFetchSloDetails } from './hooks/use_fetch_slo_details';
import { SLO } from '../../typings';
import { SloDetails } from './components/slo_details';
import { SLO_DETAILS_BREADCRUMB_TEXT } from './translations';
import { PageTitle } from './components/page_title';
export function SloDetailsPage() {
const { http } = useKibana<ObservabilityAppServices>().services;
const { ObservabilityPageTemplate, config } = usePluginContext();
const { sloId } = useParams<SloDetailsPathParams>();
const { loading, slo } = useFetchSloDetails(sloId);
useBreadcrumbs(getBreadcrumbs(http.basePath, slo));
const isSloNotFound = !loading && slo === undefined;
if (!isSloFeatureEnabled(config) || isSloNotFound) {
return <PageNotFound />;
}
return (
<ObservabilityPageTemplate
pageHeader={{
pageTitle: <PageTitle isLoading={loading} slo={slo} />,
rightSideItems: [],
bottomBorder: true,
}}
data-test-subj="sloDetailsPage"
>
{loading && <EuiLoadingSpinner data-test-subj="loadingDetails" />}
{!loading && <SloDetails slo={slo!} />}
</ObservabilityPageTemplate>
);
}
function getBreadcrumbs(basePath: IBasePath, slo: SLO | undefined): EuiBreadcrumbProps[] {
return [
{
href: basePath.prepend(paths.observability.slos),
text: SLOS_BREADCRUMB_TEXT,
},
{
text: slo?.name ?? SLO_DETAILS_BREADCRUMB_TEXT,
},
];
}

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { SLO } from '../../../typings';
export const anSLO: SLO = {
id: '2f17deb0-725a-11ed-ab7c-4bb641cfc57e',
name: 'SLO latency service log',
objective: {
target: 0.98,
},
summary: {
sliValue: 0.990097,
errorBudget: {
remaining: 0.504831,
},
},
};

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const SLO_DETAILS_PAGE_TITLE = i18n.translate('xpack.observability.sloDetailsPageTitle', {
defaultMessage: 'SLO Details',
});
export const SLO_DETAILS_BREADCRUMB_TEXT = i18n.translate(
'xpack.observability.breadcrumbs.sloDetailsLinkText',
{
defaultMessage: 'Details',
}
);

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export interface SloDetailsPathParams {
sloId: string;
}

View file

@ -12,7 +12,7 @@ import { SloList as Component } from './slo_list';
export default {
component: Component,
title: 'app/SLOs/SloList',
title: 'app/SLO/ListPage/SloList',
argTypes: {},
};

View file

@ -21,6 +21,7 @@ import { AlertingPages } from '../config';
import { AlertDetails } from '../pages/alert_details';
import { DatePickerContextProvider } from '../context/date_picker_context';
import { SlosPage } from '../pages/slos';
import { SloDetailsPage } from '../pages/slo_details';
export type RouteParams<T extends keyof typeof routes> = DecodeParams<typeof routes[T]['params']>;
@ -137,4 +138,11 @@ export const routes = {
params: {},
exact: true,
},
'/slos/:sloId': {
handler: () => {
return <SloDetailsPage />;
},
params: {},
exact: true,
},
};