mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
feat(slo): SLO selector component (#147010)
This commit is contained in:
parent
925666e04b
commit
f179279e5c
12 changed files with 339 additions and 20 deletions
|
@ -7,12 +7,16 @@
|
|||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import fs from 'fs';
|
||||
import type { StorybookConfig } from '@storybook/core-common';
|
||||
import { Configuration } from 'webpack';
|
||||
import webpack, { Configuration } from 'webpack';
|
||||
import webpackMerge from 'webpack-merge';
|
||||
import { REPO_ROOT } from './constants';
|
||||
import { default as WebpackConfig } from '../webpack.config';
|
||||
|
||||
const MOCKS_DIRECTORY = '__storybook_mocks__';
|
||||
const EXTENSIONS = ['.ts', '.js'];
|
||||
|
||||
export type { StorybookConfig };
|
||||
|
||||
const toPath = (_path: string) => path.join(REPO_ROOT, _path);
|
||||
|
@ -52,6 +56,48 @@ export const defaultConfig: StorybookConfig = {
|
|||
config.cache = true;
|
||||
}
|
||||
|
||||
// This will go over every component which is imported and check its import statements.
|
||||
// For every import which starts with ./ it will do a check to see if a file with the same name
|
||||
// exists in the __storybook_mocks__ folder. If it does, use that import instead.
|
||||
// This allows you to mock hooks and functions when rendering components in Storybook.
|
||||
// It is akin to Jest's manual mocks (__mocks__).
|
||||
config.plugins?.push(
|
||||
new webpack.NormalModuleReplacementPlugin(/^\.\//, async (resource: any) => {
|
||||
if (!resource.contextInfo.issuer?.includes('node_modules')) {
|
||||
const mockedPath = path.resolve(resource.context, MOCKS_DIRECTORY, resource.request);
|
||||
|
||||
EXTENSIONS.forEach((ext) => {
|
||||
const isReplacementPathExists = fs.existsSync(mockedPath + ext);
|
||||
|
||||
if (isReplacementPathExists) {
|
||||
const newImportPath = './' + path.join(MOCKS_DIRECTORY, resource.request);
|
||||
resource.request = newImportPath;
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Same, but for imports statements which import modules outside of the directory (../)
|
||||
config.plugins?.push(
|
||||
new webpack.NormalModuleReplacementPlugin(/^\.\.\//, async (resource: any) => {
|
||||
if (!resource.contextInfo.issuer?.includes('node_modules')) {
|
||||
const prs = path.parse(resource.request);
|
||||
|
||||
const mockedPath = path.resolve(resource.context, prs.dir, MOCKS_DIRECTORY, prs.base);
|
||||
|
||||
EXTENSIONS.forEach((ext) => {
|
||||
const isReplacementPathExists = fs.existsSync(mockedPath + ext);
|
||||
|
||||
if (isReplacementPathExists) {
|
||||
const newImportPath = prs.dir + '/' + path.join(MOCKS_DIRECTORY, prs.base);
|
||||
resource.request = newImportPath;
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
config.node = { fs: 'empty' };
|
||||
config.watch = true;
|
||||
config.watchOptions = {
|
||||
|
|
45
x-pack/plugins/observability/common/data/sli_list.ts
Normal file
45
x-pack/plugins/observability/common/data/sli_list.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* 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 type { SLOList } from '../../public';
|
||||
|
||||
export const emptySloList: SLOList = {
|
||||
results: [],
|
||||
page: 1,
|
||||
perPage: 25,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
export const sloList: SLOList = {
|
||||
results: [
|
||||
{
|
||||
id: '1f1c6ee7-433f-4b56-b727-5682262e0d7d',
|
||||
name: 'latency',
|
||||
objective: { target: 0.98 },
|
||||
summary: {
|
||||
sliValue: 0.99872,
|
||||
errorBudget: {
|
||||
remaining: 0.936,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c0f8d669-9177-4706-9098-f397a88173a6',
|
||||
name: 'availability',
|
||||
objective: { target: 0.98 },
|
||||
summary: {
|
||||
sliValue: 0.97,
|
||||
errorBudget: {
|
||||
remaining: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
page: 1,
|
||||
perPage: 25,
|
||||
total: 2,
|
||||
};
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* 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 { SLO } from '../../../../typings';
|
||||
import { SloSelector as Component } from './slo_selector';
|
||||
|
||||
export default {
|
||||
component: Component,
|
||||
title: 'app/SLOs/Shared/SloSelector',
|
||||
};
|
||||
|
||||
const Template: ComponentStory<typeof Component> = () => (
|
||||
<Component onSelected={(slo: SLO) => console.log(slo)} />
|
||||
);
|
||||
const defaultProps = {};
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = defaultProps;
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* 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 { act, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { render } from '../../../../utils/test_helper';
|
||||
import { SloSelector } from './slo_selector';
|
||||
import { useFetchSloList } from '../../../../hooks/slo/use_fetch_slo_list';
|
||||
import { emptySloList } from '../../../../pages/slos/mocks/slo_list';
|
||||
import { wait } from '@testing-library/user-event/dist/utils';
|
||||
|
||||
jest.mock('../../../../hooks/slo/use_fetch_slo_list');
|
||||
const useFetchSloListMock = useFetchSloList as jest.Mock;
|
||||
|
||||
describe('SLO Selector', () => {
|
||||
const onSelectedSpy = jest.fn();
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useFetchSloListMock.mockReturnValue({ loading: true, sloList: emptySloList });
|
||||
});
|
||||
|
||||
it('fetches SLOs asynchronously', async () => {
|
||||
render(<SloSelector onSelected={onSelectedSpy} />);
|
||||
|
||||
expect(screen.getByTestId('sloSelector')).toBeTruthy();
|
||||
expect(useFetchSloListMock).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('searches SLOs when typing', async () => {
|
||||
render(<SloSelector onSelected={onSelectedSpy} />);
|
||||
|
||||
const input = screen.getByTestId('comboBoxInput');
|
||||
await act(async () => {
|
||||
await userEvent.type(input, 'latency', { delay: 1 });
|
||||
await wait(310); // debounce delay
|
||||
});
|
||||
|
||||
expect(useFetchSloListMock).toHaveBeenCalledWith('latency');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* 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 { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { debounce } from 'lodash';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { SLO } from '../../../../typings';
|
||||
import { useFetchSloList } from '../../../../hooks/slo/use_fetch_slo_list';
|
||||
|
||||
interface Props {
|
||||
onSelected: (slo: SLO) => void;
|
||||
}
|
||||
|
||||
function SloSelector({ onSelected }: Props) {
|
||||
const [options, setOptions] = useState<Array<EuiComboBoxOptionOption<string>>>([]);
|
||||
const [selectedOptions, setSelected] = useState<Array<EuiComboBoxOptionOption<string>>>();
|
||||
const [searchValue, setSearchValue] = useState<string>('');
|
||||
const { loading, sloList } = useFetchSloList(searchValue);
|
||||
|
||||
useEffect(() => {
|
||||
const isLoadedWithData = !loading && sloList !== undefined;
|
||||
const opts: Array<EuiComboBoxOptionOption<string>> = isLoadedWithData
|
||||
? sloList.results.map((slo) => ({ value: slo.id, label: slo.name }))
|
||||
: [];
|
||||
setOptions(opts);
|
||||
}, [loading, sloList]);
|
||||
|
||||
const onChange = (opts: Array<EuiComboBoxOptionOption<string>>) => {
|
||||
setSelected(opts);
|
||||
if (opts.length === 1) {
|
||||
const sloId = opts[0].value;
|
||||
const selectedSlo = sloList.results.find((slo) => slo.id === sloId);
|
||||
if (selectedSlo !== undefined) {
|
||||
onSelected(selectedSlo);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onSearchChange = useMemo(() => debounce((value: string) => setSearchValue(value), 300), []);
|
||||
|
||||
return (
|
||||
<EuiComboBox
|
||||
aria-label={i18n.translate('xpack.observability.slo.sloSelector.ariaLabel', {
|
||||
defaultMessage: 'SLO Selector',
|
||||
})}
|
||||
placeholder={i18n.translate('xpack.observability.slo.sloSelector.placeholder', {
|
||||
defaultMessage: 'Select a SLO',
|
||||
})}
|
||||
data-test-subj="sloSelector"
|
||||
singleSelection={{ asPlainText: true }}
|
||||
options={options}
|
||||
selectedOptions={selectedOptions}
|
||||
async
|
||||
isLoading={loading}
|
||||
onChange={onChange}
|
||||
onSearchChange={onSearchChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { SloSelector };
|
||||
export type { Props as SloSelectorProps };
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* 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 { sloList } from '../../../../common/data/sli_list';
|
||||
import { UseFetchSloListResponse } from '../use_fetch_slo_list';
|
||||
|
||||
export const useFetchSloList = (name?: string): UseFetchSloListResponse => {
|
||||
return {
|
||||
loading: false,
|
||||
sloList,
|
||||
};
|
||||
};
|
|
@ -8,8 +8,8 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
import { HttpSetup } from '@kbn/core/public';
|
||||
|
||||
import type { SLO, SLOList } from '../../../typings/slo';
|
||||
import { useDataFetcher } from '../../../hooks/use_data_fetcher';
|
||||
import type { SLO, SLOList } from '../../typings/slo';
|
||||
import { useDataFetcher } from '../use_data_fetcher';
|
||||
|
||||
const EMPTY_LIST = {
|
||||
results: [],
|
||||
|
@ -18,28 +18,42 @@ const EMPTY_LIST = {
|
|||
perPage: 0,
|
||||
};
|
||||
|
||||
export const useFetchSloList = (): [boolean, SLOList] => {
|
||||
const params = useMemo(() => ({}), []);
|
||||
const shouldExecuteApiCall = useCallback((apiCallParams: {}) => true, []);
|
||||
interface SLOListParams {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
const { loading, data: sloList } = useDataFetcher<{}, SLOList>({
|
||||
interface UseFetchSloListResponse {
|
||||
loading: boolean;
|
||||
sloList: SLOList;
|
||||
}
|
||||
|
||||
const useFetchSloList = (name?: string): UseFetchSloListResponse => {
|
||||
const params: SLOListParams = useMemo(() => ({ name }), [name]);
|
||||
const shouldExecuteApiCall = useCallback(
|
||||
(apiCallParams: SLOListParams) => apiCallParams.name === params.name,
|
||||
[params]
|
||||
);
|
||||
|
||||
const { loading, data: sloList } = useDataFetcher<SLOListParams, SLOList>({
|
||||
paramsForApiCall: params,
|
||||
initialDataState: EMPTY_LIST,
|
||||
executeApiCall: fetchSloList,
|
||||
shouldExecuteApiCall,
|
||||
});
|
||||
|
||||
return [loading, sloList];
|
||||
return { loading, sloList };
|
||||
};
|
||||
|
||||
const fetchSloList = async (
|
||||
params: {},
|
||||
params: SLOListParams,
|
||||
abortController: AbortController,
|
||||
http: HttpSetup
|
||||
): Promise<SLOList> => {
|
||||
try {
|
||||
const response = await http.get<Record<string, unknown>>(`/api/observability/slos`, {
|
||||
query: {},
|
||||
query: {
|
||||
...(params.name && { name: params.name }),
|
||||
},
|
||||
signal: abortController.signal,
|
||||
});
|
||||
if (response !== undefined) {
|
||||
|
@ -78,3 +92,6 @@ function toSLO(result: any): SLO {
|
|||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { useFetchSloList };
|
||||
export type { UseFetchSloListResponse };
|
|
@ -8,14 +8,14 @@
|
|||
import React from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
|
||||
import { useFetchSloList } from '../hooks/use_fetch_slo_list';
|
||||
import { useFetchSloList } from '../../../hooks/slo/use_fetch_slo_list';
|
||||
|
||||
export function SloList() {
|
||||
const [isLoading, sloList] = useFetchSloList();
|
||||
const { loading, sloList } = useFetchSloList();
|
||||
|
||||
return (
|
||||
<EuiFlexGroup direction="column" gutterSize="s" data-test-subj="sloList">
|
||||
<EuiFlexItem>{!isLoading && <pre>{JSON.stringify(sloList, null, 2)}</pre>}</EuiFlexItem>
|
||||
<EuiFlexItem>{!loading && <pre>{JSON.stringify(sloList, null, 2)}</pre>}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -14,14 +14,14 @@ import { useKibana } from '../../utils/kibana_react';
|
|||
import { kibanaStartMock } from '../../utils/kibana_react.mock';
|
||||
import { render } from '../../utils/test_helper';
|
||||
import { SlosPage } from '.';
|
||||
import { useFetchSloList } from './hooks/use_fetch_slo_list';
|
||||
import { useFetchSloList } from '../../hooks/slo/use_fetch_slo_list';
|
||||
import { emptySloList } from './mocks/slo_list';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: jest.fn(),
|
||||
}));
|
||||
jest.mock('./hooks/use_fetch_slo_list');
|
||||
jest.mock('../../hooks/slo/use_fetch_slo_list');
|
||||
jest.mock('../../utils/kibana_react');
|
||||
jest.mock('../../hooks/use_breadcrumbs');
|
||||
|
||||
|
@ -50,7 +50,7 @@ describe('SLOs Page', () => {
|
|||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockKibana();
|
||||
useFetchSloListMock.mockReturnValue([false, emptySloList]);
|
||||
useFetchSloListMock.mockReturnValue({ loading: false, sloList: emptySloList });
|
||||
});
|
||||
|
||||
it('renders the not found page when the feature flag is not enabled', async () => {
|
||||
|
|
|
@ -13,3 +13,33 @@ export const emptySloList: SLOList = {
|
|||
perPage: 25,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
export const sloList: SLOList = {
|
||||
results: [
|
||||
{
|
||||
id: '1f1c6ee7-433f-4b56-b727-5682262e0d7d',
|
||||
name: 'latency',
|
||||
objective: { target: 0.98 },
|
||||
summary: {
|
||||
sliValue: 0.99872,
|
||||
errorBudget: {
|
||||
remaining: 0.936,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c0f8d669-9177-4706-9098-f397a88173a6',
|
||||
name: 'availability',
|
||||
objective: { target: 0.98 },
|
||||
summary: {
|
||||
sliValue: 0.97,
|
||||
errorBudget: {
|
||||
remaining: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
page: 1,
|
||||
perPage: 25,
|
||||
total: 2,
|
||||
};
|
||||
|
|
|
@ -101,11 +101,11 @@ describe('KibanaSavedObjectsSLORepository', () => {
|
|||
describe('find', () => {
|
||||
const DEFAULT_PAGINATION = { page: 1, perPage: 25 };
|
||||
|
||||
it('includes the filter on name when provided', async () => {
|
||||
it('includes the filter on name with wildcard when provided', async () => {
|
||||
const repository = new KibanaSavedObjectsSLORepository(soClientMock);
|
||||
soClientMock.find.mockResolvedValueOnce(aFindResponse(SOME_SLO));
|
||||
|
||||
const result = await repository.find({ name: 'availability' }, DEFAULT_PAGINATION);
|
||||
const result = await repository.find({ name: 'availability*' }, DEFAULT_PAGINATION);
|
||||
|
||||
expect(result).toEqual({
|
||||
page: 1,
|
||||
|
@ -117,7 +117,27 @@ describe('KibanaSavedObjectsSLORepository', () => {
|
|||
type: SO_SLO_TYPE,
|
||||
page: 1,
|
||||
perPage: 25,
|
||||
filter: `slo.attributes.name: availability`,
|
||||
filter: `slo.attributes.name: availability*`,
|
||||
});
|
||||
});
|
||||
|
||||
it('includes the filter on name with added wildcard when not provided', async () => {
|
||||
const repository = new KibanaSavedObjectsSLORepository(soClientMock);
|
||||
soClientMock.find.mockResolvedValueOnce(aFindResponse(SOME_SLO));
|
||||
|
||||
const result = await repository.find({ name: 'availa' }, DEFAULT_PAGINATION);
|
||||
|
||||
expect(result).toEqual({
|
||||
page: 1,
|
||||
perPage: 25,
|
||||
total: 1,
|
||||
results: [SOME_SLO],
|
||||
});
|
||||
expect(soClientMock.find).toHaveBeenCalledWith({
|
||||
type: SO_SLO_TYPE,
|
||||
page: 1,
|
||||
perPage: 25,
|
||||
filter: `slo.attributes.name: availa*`,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -96,7 +96,7 @@ export class KibanaSavedObjectsSLORepository implements SLORepository {
|
|||
function buildFilterKuery(criteria: Criteria): string | undefined {
|
||||
const filters: string[] = [];
|
||||
if (!!criteria.name) {
|
||||
filters.push(`slo.attributes.name: ${criteria.name}`);
|
||||
filters.push(`slo.attributes.name: ${addWildcardIfAbsent(criteria.name)}`);
|
||||
}
|
||||
return filters.length > 0 ? filters.join(' and ') : undefined;
|
||||
}
|
||||
|
@ -113,3 +113,9 @@ function toSLO(storedSLO: StoredSLO): SLO {
|
|||
}, t.identity)
|
||||
);
|
||||
}
|
||||
|
||||
const WILDCARD_CHAR = '*';
|
||||
function addWildcardIfAbsent(value: string): string {
|
||||
if (value.substring(value.length - 1) === WILDCARD_CHAR) return value;
|
||||
return `${value}${WILDCARD_CHAR}`;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue