[SLO] Create SLO Edit Form - Custom KQL (#147843)

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Closes https://github.com/elastic/kibana/issues/147753
This commit is contained in:
Coen Warmer 2023-01-03 19:48:29 +01:00 committed by GitHub
parent 46d689220b
commit c2c9cabfb7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 1661 additions and 146 deletions

View file

@ -51,16 +51,16 @@ const getSLOParamsSchema = t.type({
});
const sortDirectionSchema = t.union([t.literal('asc'), t.literal('desc')]);
const sortBySchema = t.union([t.literal('name'), t.literal('indicator_type')]);
const sortBySchema = t.union([t.literal('name'), t.literal('indicatorType')]);
const findSLOParamsSchema = t.partial({
query: t.partial({
name: t.string,
indicator_types: indicatorTypesArraySchema,
indicatorTypes: indicatorTypesArraySchema,
page: t.string,
per_page: t.string,
sort_by: sortBySchema,
sort_direction: sortDirectionSchema,
perPage: t.string,
sortBy: sortBySchema,
sortDirection: sortDirectionSchema,
}),
});
@ -123,6 +123,8 @@ type UpdateSLOResponse = t.OutputOf<typeof updateSLOResponseSchema>;
type FindSLOParams = t.TypeOf<typeof findSLOParamsSchema.props.query>;
type FindSLOResponse = t.OutputOf<typeof findSLOResponseSchema>;
type BudgetingMethod = t.TypeOf<typeof budgetingMethodSchema>;
export {
createSLOParamsSchema,
deleteSLOParamsSchema,
@ -136,6 +138,7 @@ export {
updateSLOResponseSchema,
};
export type {
BudgetingMethod,
CreateSLOParams,
CreateSLOResponse,
FindSLOParams,

View file

@ -9,7 +9,7 @@
import * as t from 'io-ts';
import { allOrAnyString, dateRangeSchema } from './common';
const apmTransactionDurationIndicatorTypeSchema = t.literal('sli.apm.transaction_duration');
const apmTransactionDurationIndicatorTypeSchema = t.literal('sli.apm.transactionDuration');
const apmTransactionDurationIndicatorSchema = t.type({
type: apmTransactionDurationIndicatorTypeSchema,
params: t.intersection([
@ -26,7 +26,7 @@ const apmTransactionDurationIndicatorSchema = t.type({
]),
});
const apmTransactionErrorRateIndicatorTypeSchema = t.literal('sli.apm.transaction_error_rate');
const apmTransactionErrorRateIndicatorTypeSchema = t.literal('sli.apm.transactionErrorRate');
const apmTransactionErrorRateIndicatorSchema = t.type({
type: apmTransactionErrorRateIndicatorTypeSchema,
params: t.intersection([
@ -69,7 +69,7 @@ const indicatorTypesSchema = t.union([
]);
// Validate that a string is a comma separated list of indicator types,
// e.g. sli.kql.custom,sli.apm.transaction_duration
// e.g. sli.kql.custom,sli.apm.transactionDuration
// Transform to an array of indicator type
const indicatorTypesArraySchema = new t.Type<string[], string, unknown>(
'indicatorTypesArray',

View file

@ -12,8 +12,8 @@ import { durationType } from './duration';
import { indicatorSchema } from './indicators';
import { timeWindowSchema } from './time_window';
const occurrencesBudgetingMethodSchema = t.literal<string>('occurrences');
const timeslicesBudgetingMethodSchema = t.literal<string>('timeslices');
const occurrencesBudgetingMethodSchema = t.literal('occurrences');
const timeslicesBudgetingMethodSchema = t.literal('timeslices');
const budgetingMethodSchema = t.union([
occurrencesBudgetingMethodSchema,

View file

@ -1,10 +1,11 @@
# SLO
# SLO
Add the feature flag: `xpack.observability.unsafe.slo.enabled: true` in your Kibana config to enable the various SLO APIs.
## Supported SLI
We currently support the following SLI:
- APM Transaction Error Rate (Availability)
- APM Transaction Duration (Latency)
- Custom KQL
@ -19,13 +20,13 @@ The **custom KQL** SLI requires an index pattern, an optional filter query, a nu
We support **calendar aligned** and **rolling** time windows. Any duration greater than 1 day can be used: days, weeks, months, quarters, years.
**Rolling time window:** Requires a duration, e.g. `1w` for one week, and `is_rolling: true`. SLOs defined with such time window, will only considere the SLI data from the last duration period as a moving window.
**Rolling time window:** Requires a duration, e.g. `1w` for one week, and `isRolling: true`. SLOs defined with such time window, will only considere the SLI data from the last duration period as a moving window.
**Calendar aligned time window:** Requires a duration, e.g. `1M` for one month, and a `calendar.start_time` date in ISO 8601 in UTC, which marks the beginning of the first period. From start time and the duration, the system will compute the different time windows. For example, starting the calendar on the **01/01/2022** with a monthly duration, if today is the **24/10/2022**, the window associated is: `[2022-10-01T00:00:00Z, 2022-11-01T00:00:00Z]`
**Calendar aligned time window:** Requires a duration, e.g. `1M` for one month, and a `calendar.startTime` date in ISO 8601 in UTC, which marks the beginning of the first period. From start time and the duration, the system will compute the different time windows. For example, starting the calendar on the **01/01/2022** with a monthly duration, if today is the **24/10/2022**, the window associated is: `[2022-10-01T00:00:00Z, 2022-11-01T00:00:00Z]`
### Budgeting method
An SLO can be configured with an **occurrences** or **timeslices** budgeting method.
An SLO can be configured with an **occurrences** or **timeslices** budgeting method.
An **occurrences** budgeting method uses the number of **good** and **total** events during the time window.
@ -33,16 +34,17 @@ A **timeslices** budgeting method uses the number of **good slices** and **total
For example, defining a **timeslices** budgeting method with a `95%` slice threshold and `5m` slice window means that a 1 week SLO is split in 2,016 slices (`7*24*60 / 5`); for a 99% SLO target there will be approximately 20 minutes of available error budget. Each bucket is either good or bad depending on the ratio of good over total events during that bucket, compared to the slice threshold of 95%.
### Objective
### Objective
The target objective is the value the SLO needs to meet during the time window.
If a **timeslices** budgeting method is used, we also need to define the **timeslice_target** which can be different than the overall SLO target.
If a **timeslices** budgeting method is used, we also need to define the **timesliceTarget** which can be different than the overall SLO target.
### Optional settings
The default settings should be sufficient for most users, but if needed, the following properties can be overwritten:
- timestamp_field: The date time field to use from the source index
- sync_delay: The ingest delay in the source data
- timestampField: The date time field to use from the source index
- syncDelay: The ingest delay in the source data
- frequency: How often do we query the source data
## Example
@ -62,25 +64,26 @@ curl --request POST \
"name": "My SLO Name",
"description": "My SLO Description",
"indicator": {
"type": "sli.apm.transaction_error_rate",
"type": "sli.apm.transactionErrorRate",
"params": {
"environment": "production",
"service": "o11y-app",
"transaction_type": "request",
"transaction_name": "GET /api",
"good_status_codes": ["2xx", "3xx", "4xx"]
"transactionType": "request",
"transactionName": "GET /api",
"goodStatusCodes": ["2xx", "3xx", "4xx"]
}
},
"time_window": {
"timeWindow": {
"duration": "30d",
"is_rolling": true
"isRolling": true
},
"budgeting_method": "occurrences",
"budgetingMethod": "occurrences",
"objective": {
"target": 0.99
}
}'
```
</details>
<details>
@ -96,27 +99,28 @@ curl --request POST \
"name": "My SLO Name",
"description": "My SLO Description",
"indicator": {
"type": "sli.apm.transaction_error_rate",
"type": "sli.apm.transactionErrorRate",
"params": {
"environment": "production",
"service": "o11y-app",
"transaction_type": "request",
"transaction_name": "GET /api",
"good_status_codes": ["2xx", "3xx", "4xx"]
"transactionType": "request",
"transactionName": "GET /api",
"goodStatusCodes": ["2xx", "3xx", "4xx"]
}
},
"time_window": {
"timeWindow": {
"duration": "1q",
"calendar": {
"start_time": "2022-06-01T00:00:00.000Z"
"startTime": "2022-06-01T00:00:00.000Z"
}
},
"budgeting_method": "occurrences",
"budgetingMethod": "occurrences",
"objective": {
"target": 0.95
}
}'
```
</details>
<details>
@ -132,27 +136,28 @@ curl --request POST \
"name": "My SLO Name",
"description": "My SLO Description",
"indicator": {
"type": "sli.apm.transaction_error_rate",
"type": "sli.apm.transactionErrorRate",
"params": {
"environment": "production",
"service": "o11y-app",
"transaction_type": "request",
"transaction_name": "GET /api",
"good_status_codes": ["2xx", "3xx", "4xx"]
"transactionType": "request",
"transactionName": "GET /api",
"goodStatusCodes": ["2xx", "3xx", "4xx"]
}
},
"time_window": {
"timeWindow": {
"duration": "1w",
"is_rolling": true
"isRolling": true
},
"budgeting_method": "timeslices",
"budgetingMethod": "timeslices",
"objective": {
"target": 0.90,
"timeslice_target": 0.86,
"timeslice_window": "5m"
"timesliceTarget": 0.86,
"timesliceWindow": "5m"
}
}'
```
</details>
### Latency
@ -170,25 +175,26 @@ curl --request POST \
"name": "My SLO Name",
"description": "My SLO Description",
"indicator": {
"type": "sli.apm.transaction_duration",
"type": "sli.apm.transactionDuration",
"params": {
"environment": "production",
"service": "o11y-app",
"transaction_type": "request",
"transaction_name": "GET /api",
"transactionType": "request",
"transactionName": "GET /api",
"threshold.us": 500000
}
},
"time_window": {
"timeWindow": {
"duration": "7d",
"is_rolling": true
"isRolling": true
},
"budgeting_method": "occurrences",
"budgetingMethod": "occurrences",
"objective": {
"target": 0.99
}
}'
```
</details>
<details>
@ -204,29 +210,29 @@ curl --request POST \
"name": "My SLO Name",
"description": "My SLO Description",
"indicator": {
"type": "sli.apm.transaction_duration",
"type": "sli.apm.transactionDuration",
"params": {
"environment": "production",
"service": "o11y-app",
"transaction_type": "request",
"transaction_name": "GET /api",
"transactionType": "request",
"transactionName": "GET /api",
"threshold.us": 500000
}
},
"time_window": {
"timeWindow": {
"duration": "7d",
"is_rolling": true
"isRolling": true
},
"budgeting_method": "timeslices",
"budgetingMethod": "timeslices",
"objective": {
"target": 0.95,
"timeslice_target": 0.90,
"timeslice_window": "1m"
"timesliceTarget": 0.90,
"timesliceWindow": "1m"
}
}'
```
</details>
</details>
<details>
<summary>99.9% of GET /api under 500ms weekly aligned (5m timeslices)</summary>
@ -241,35 +247,34 @@ curl --request POST \
"name": "My SLO Name",
"description": "My SLO Description",
"indicator": {
"type": "sli.apm.transaction_duration",
"type": "sli.apm.transactionDuration",
"params": {
"environment": "production",
"service": "o11y-app",
"transaction_type": "request",
"transaction_name": "GET /api",
"transactionType": "request",
"transactionName": "GET /api",
"threshold.us": 500000
}
},
"time_window": {
"timeWindow": {
"duration": "7d",
"calendar": {
"start_time": "2022-01-01T00:00:00.000Z"
"calendar": {
"startTime": "2022-01-01T00:00:00.000Z"
}
},
"budgeting_method": "timeslices",
"budgetingMethod": "timeslices",
"objective": {
"target": 0.999,
"timeslice_target": 0.95,
"timeslice_window": "5m"
"timesliceTarget": 0.95,
"timesliceWindow": "5m"
}
}'
```
</details>
### Custom
<details>
<summary>98.5% of 'logs lantency < 300ms' for 'groupId: group-0' over the last 7 days</summary>
@ -291,14 +296,15 @@ curl --request POST \
"filter": "labels.groupId: group-0"
}
},
"time_window": {
"timeWindow": {
"duration": "7d",
"is_rolling": true
"isRolling": true
},
"budgeting_method": "occurrences",
"budgetingMethod": "occurrences",
"objective": {
"target": 0.985
}
}'
```
</details>
</details>

View file

@ -18,6 +18,8 @@ export const paths = {
ruleDetails: (ruleId?: string | null) =>
ruleId ? `${RULES_PAGE_LINK}/${encodeURI(ruleId)}` : RULES_PAGE_LINK,
slos: SLOS_PAGE_LINK,
sloCreate: `${SLOS_PAGE_LINK}/create`,
sloEdit: (sloId: string) => `${SLOS_PAGE_LINK}/edit/${encodeURI(sloId)}`,
sloDetails: (sloId: string) => `${SLOS_PAGE_LINK}/${encodeURI(sloId)}`,
},
management: {

View file

@ -25,7 +25,7 @@ const baseSlo: Omit<SLOWithSummaryResponse, 'id'> = {
index: 'some-index',
filter: 'baz: foo and bar > 2',
good: 'http_status: 2xx',
total: '',
total: 'a query',
},
},
timeWindow: {

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 { Index, UseFetchIndicesResponse } from '../use_fetch_indices';
export const useFetchIndices = (): UseFetchIndicesResponse => {
return {
loading: false,
error: false,
indices: Array.from({ length: 5 }, (_, i) => ({
name: `.index${i}`,
})) as Index[],
};
};

View file

@ -0,0 +1,49 @@
/*
* 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 { useCallback, useState } from 'react';
import type { CreateSLOParams, CreateSLOResponse } from '@kbn/slo-schema';
import { useKibana } from '../../utils/kibana_react';
interface UseCreateSlo {
loading: boolean;
success: boolean;
error: string | undefined;
createSlo: (slo: CreateSLOParams) => void;
}
export function useCreateSlo(): UseCreateSlo {
const { http } = useKibana().services;
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
const createSlo = useCallback(
async (slo: CreateSLOParams) => {
setLoading(true);
setError('');
setSuccess(false);
const body = JSON.stringify(slo);
try {
await http.post<CreateSLOResponse>(`/api/observability/slos`, { body });
setSuccess(true);
} catch (e) {
setError(e);
}
},
[http]
);
return {
loading,
error,
success,
createSlo,
};
}

View file

@ -10,12 +10,12 @@ import { useCallback, useMemo } from 'react';
import { GetSLOResponse, SLOWithSummaryResponse } from '@kbn/slo-schema';
import { useDataFetcher } from '../use_data_fetcher';
interface UseFetchSloDetailsResponse {
export interface UseFetchSloDetailsResponse {
loading: boolean;
slo: SLOWithSummaryResponse | undefined;
}
function useFetchSloDetails(sloId?: string): UseFetchSloDetailsResponse {
export function useFetchSloDetails(sloId?: string): UseFetchSloDetailsResponse {
const params = useMemo(() => ({ sloId }), [sloId]);
const shouldExecuteApiCall = useCallback(
(apiCallParams: { sloId?: string }) => params.sloId === apiCallParams.sloId,
@ -57,6 +57,3 @@ const fetchSlo = async (
return undefined;
};
export type { UseFetchSloDetailsResponse };
export { useFetchSloDetails };

View file

@ -37,12 +37,8 @@ export function useFetchSloList({
refetch,
sortBy,
indicatorTypes,
}: {
}: SLOListParams & {
refetch: boolean;
name?: string;
page?: number;
sortBy?: string;
indicatorTypes?: string[];
}): UseFetchSloListResponse {
const [sloList, setSloList] = useState(EMPTY_LIST);
@ -84,9 +80,9 @@ const fetchSloList = async (
query: {
...(params.page && { page: params.page }),
...(params.name && { name: params.name }),
...(params.sortBy && { sort_by: params.sortBy }),
...(params.sortBy && { sortBy: params.sortBy }),
...(params.indicatorTypes &&
params.indicatorTypes.length > 0 && { indicator_types: params.indicatorTypes.join(',') }),
params.indicatorTypes.length > 0 && { indicatorTypes: params.indicatorTypes.join(',') }),
},
signal: abortController.signal,
});

View file

@ -0,0 +1,56 @@
/*
* 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/public';
import { useRef } from 'react';
import { useDataFetcher } from './use_data_fetcher';
export interface UseFetchIndicesResponse {
indices: Index[];
loading: boolean;
error: boolean;
}
export interface Index {
name: string;
}
export function useFetchIndices(): UseFetchIndicesResponse {
const hasFetched = useRef<boolean>(false);
const {
data: indices,
loading,
error,
} = useDataFetcher({
paramsForApiCall: {},
initialDataState: undefined,
executeApiCall: async (
_: any,
abortController: AbortController,
http: HttpSetup
): Promise<any> => {
try {
const response = await http.get<Index[]>(`/api/index_management/indices`, {
signal: abortController.signal,
});
if (response !== undefined) {
hasFetched.current = true;
return response;
}
} catch (e) {
// ignore error for retrieving slos
}
return;
},
shouldExecuteApiCall: () => (hasFetched.current === false ? true : false),
});
return { indices, loading, error };
}

View file

@ -18,7 +18,7 @@ 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 { isSloFeatureEnabled } from '../slos/helpers/is_slo_feature_enabled';
import { SLOS_BREADCRUMB_TEXT } from '../slos/translations';
import { SloDetailsPathParams } from './types';
import { useFetchSloDetails } from '../../hooks/slo/use_fetch_slo_details';

View file

@ -0,0 +1,34 @@
/*
* 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 { FormProvider, useForm } from 'react-hook-form';
import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator';
import { SloEditForm as Component, Props } from './slo_edit_form';
import { SLO_EDIT_FORM_DEFAULT_VALUES } from '../constants';
export default {
component: Component,
title: 'app/SLO/EditPage/SloEditForm',
decorators: [KibanaReactStorybookDecorator],
};
const Template: ComponentStory<typeof Component> = (props: Props) => {
const methods = useForm({ defaultValues: SLO_EDIT_FORM_DEFAULT_VALUES });
return (
<FormProvider {...methods}>
<Component {...props} />
</FormProvider>
);
};
const defaultProps = {};
export const SloEditForm = Template.bind({});
SloEditForm.args = defaultProps;

View file

@ -0,0 +1,214 @@
/*
* 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 {
EuiAvatar,
EuiButton,
EuiFormLabel,
EuiPanel,
EuiSelect,
EuiSpacer,
EuiTimeline,
EuiTimelineItem,
EuiTitle,
} from '@elastic/eui';
import { euiThemeVars } from '@kbn/ui-theme';
import { i18n } from '@kbn/i18n';
import { Controller, useForm } from 'react-hook-form';
import type { SLOWithSummaryResponse } from '@kbn/slo-schema';
import { useKibana } from '../../../utils/kibana_react';
import { useCreateSlo } from '../../../hooks/slo/use_create_slo';
import { useCheckFormPartialValidities } from '../helpers/use_check_form_partial_validities';
import { SloEditFormDefinitionCustomKql } from './slo_edit_form_definition_custom_kql';
import { SloEditFormDescription } from './slo_edit_form_description';
import { SloEditFormObjectives } from './slo_edit_form_objectives';
import {
processValues,
transformGetSloToCreateSloParams,
} from '../helpers/process_slo_form_values';
import { paths } from '../../../config';
import { SLI_OPTIONS, SLO_EDIT_FORM_DEFAULT_VALUES } from '../constants';
export interface Props {
slo: SLOWithSummaryResponse | undefined;
}
const maxWidth = 775;
export function SloEditForm({ slo }: Props) {
const {
application: { navigateToUrl },
http: { basePath },
notifications: { toasts },
} = useKibana().services;
const { control, watch, getFieldState, getValues, formState, trigger } = useForm({
defaultValues: SLO_EDIT_FORM_DEFAULT_VALUES,
values: transformGetSloToCreateSloParams(slo),
mode: 'all',
});
const { isDefinitionValid, isDescriptionValid, isObjectiveValid } = useCheckFormPartialValidities(
{ getFieldState, formState }
);
const {
loading: loadingCreatingSlo,
success: successCreatingSlo,
error: errorCreatingSlo,
createSlo,
} = useCreateSlo();
const handleCreateSlo = () => {
const values = getValues();
const processedValues = processValues(values);
createSlo(processedValues);
};
if (successCreatingSlo) {
toasts.addSuccess(
i18n.translate('xpack.observability.slos.sloEdit.creation.success', {
defaultMessage: 'Successfully created {name}',
values: { name: getValues().name },
})
);
navigateToUrl(basePath.prepend(paths.observability.slos));
}
if (errorCreatingSlo) {
toasts.addError(new Error(errorCreatingSlo), {
title: i18n.translate('xpack.observability.slos.sloEdit.creation.error', {
defaultMessage: 'Something went wrong',
}),
});
}
return (
<EuiTimeline data-test-subj="sloForm">
<EuiTimelineItem
verticalAlign="top"
icon={
<EuiAvatar
name={isDefinitionValid ? 'Check' : '1'}
iconType={isDefinitionValid ? 'check' : ''}
color={isDefinitionValid ? euiThemeVars.euiColorSuccess : euiThemeVars.euiColorPrimary}
/>
}
>
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="none" style={{ maxWidth }}>
<EuiTitle>
<h2>
{i18n.translate('xpack.observability.slos.sloEdit.definition.title', {
defaultMessage: 'Define SLI',
})}
</h2>
</EuiTitle>
<EuiSpacer size="xl" />
<EuiFormLabel>
{i18n.translate('xpack.observability.slos.sloEdit.definition.sliType', {
defaultMessage: 'SLI type',
})}
</EuiFormLabel>
<Controller
name="indicator.type"
control={control}
rules={{ required: true }}
render={({ field }) => (
<EuiSelect
data-test-subj="sloFormIndicatorTypeSelect"
{...field}
options={SLI_OPTIONS}
/>
)}
/>
<EuiSpacer size="xxl" />
{watch('indicator.type') === 'sli.kql.custom' ? (
<SloEditFormDefinitionCustomKql control={control} trigger={trigger} />
) : null}
<EuiSpacer size="m" />
</EuiPanel>
</EuiTimelineItem>
<EuiTimelineItem
verticalAlign="top"
icon={
<EuiAvatar
name={isObjectiveValid ? 'Check' : '2'}
iconType={isObjectiveValid ? 'check' : ''}
color={isObjectiveValid ? euiThemeVars.euiColorSuccess : euiThemeVars.euiColorPrimary}
/>
}
>
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="none" style={{ maxWidth }}>
<EuiTitle>
<h2>
{i18n.translate('xpack.observability.slos.sloEdit.objectives.title', {
defaultMessage: 'Set objectives',
})}
</h2>
</EuiTitle>
<EuiSpacer size="xl" />
<SloEditFormObjectives control={control} watch={watch} />
<EuiSpacer size="xl" />
</EuiPanel>
</EuiTimelineItem>
<EuiTimelineItem
verticalAlign="top"
icon={
<EuiAvatar
name={isDescriptionValid ? 'Check' : '3'}
iconType={isDescriptionValid ? 'check' : ''}
color={isDescriptionValid ? euiThemeVars.euiColorSuccess : euiThemeVars.euiColorPrimary}
/>
}
>
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="none" style={{ maxWidth }}>
<EuiTitle>
<h2>
{i18n.translate('xpack.observability.slos.sloEdit.description.title', {
defaultMessage: 'Describe SLO',
})}
</h2>
</EuiTitle>
<EuiSpacer size="xl" />
<SloEditFormDescription control={control} />
<EuiSpacer size="xl" />
<EuiButton
fill
color="primary"
data-test-subj="sloFormSubmitButton"
onClick={handleCreateSlo}
disabled={!formState.isValid}
isLoading={loadingCreatingSlo && !errorCreatingSlo}
>
{i18n.translate('xpack.observability.slos.sloEdit.createSloButton', {
defaultMessage: 'Create SLO',
})}
</EuiButton>
<EuiSpacer size="xl" />
</EuiPanel>
</EuiTimelineItem>
</EuiTimeline>
);
}

View file

@ -0,0 +1,37 @@
/*
* 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 { FormProvider, useForm } from 'react-hook-form';
import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator';
import {
SloEditFormDefinitionCustomKql as Component,
Props,
} from './slo_edit_form_definition_custom_kql';
import { SLO_EDIT_FORM_DEFAULT_VALUES } from '../constants';
export default {
component: Component,
title: 'app/SLO/EditPage/SloEditFormDefinitionCustomKql',
decorators: [KibanaReactStorybookDecorator],
};
const Template: ComponentStory<typeof Component> = (props: Props) => {
const methods = useForm({ defaultValues: SLO_EDIT_FORM_DEFAULT_VALUES });
return (
<FormProvider {...methods}>
<Component {...props} control={methods.control} />
</FormProvider>
);
};
const defaultProps = {};
export const SloEditFormDefinitionCustomKql = Template.bind({});
SloEditFormDefinitionCustomKql.args = defaultProps;

View file

@ -0,0 +1,166 @@
/*
* 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, { useEffect } from 'react';
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiFormLabel, EuiSuggest } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Control, Controller, UseFormTrigger } from 'react-hook-form';
import type { CreateSLOParams } from '@kbn/slo-schema';
import { useFetchIndices } from '../../../hooks/use_fetch_indices';
export interface Props {
control: Control<CreateSLOParams>;
trigger: UseFormTrigger<CreateSLOParams>;
}
export function SloEditFormDefinitionCustomKql({ control, trigger }: Props) {
const { loading, indices = [] } = useFetchIndices();
const indicesNames = indices.map(({ name }) => ({
type: { iconType: '', color: '' },
label: name,
description: '',
}));
// Indices are loading in asynchrously, so trigger field validation
// once results are returned from API
useEffect(() => {
if (!loading && indices.length) {
trigger();
}
}, [indices.length, loading, trigger]);
return (
<EuiFlexGroup direction="column" gutterSize="l">
<EuiFlexItem>
<EuiFormLabel>
{i18n.translate('xpack.observability.slos.sloEdit.sloDefinition.customKql.index', {
defaultMessage: 'Index',
})}
</EuiFormLabel>
<Controller
name="indicator.params.index"
control={control}
rules={{
required: true,
validate: (value) => Boolean(indices.find((index) => index.name === value)),
}}
render={({ field }) => (
<EuiSuggest
fullWidth
isClearable
aria-label="Indices"
data-test-subj="sloFormCustomKqlIndexInput"
status={loading ? 'loading' : field.value ? 'unchanged' : 'unchanged'}
onItemClick={({ label }) => {
field.onChange(label);
}}
isInvalid={!Boolean(indicesNames.find((index) => index.label === field.value))}
placeholder={i18n.translate(
'xpack.observability.slos.sloEdit.sloDefinition.customKql.index.selectIndex',
{
defaultMessage: 'Select an index',
}
)}
suggestions={indicesNames}
{...field}
/>
)}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormLabel>
{i18n.translate('xpack.observability.slos.sloEdit.sloDefinition.customKql.queryFilter', {
defaultMessage: 'Query filter',
})}
</EuiFormLabel>
<Controller
name="indicator.params.filter"
control={control}
rules={{ required: true }}
render={({ field }) => (
<EuiSuggest
append={<EuiButtonEmpty>KQL</EuiButtonEmpty>}
status="unchanged"
aria-label="Filter query"
data-test-subj="sloFormCustomKqlFilterQueryInput"
placeholder={i18n.translate(
'xpack.observability.slos.sloEdit.sloDefinition.customKql.customFilter',
{
defaultMessage: 'Custom filter to apply on the index',
}
)}
suggestions={[]}
{...field}
/>
)}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormLabel>
{i18n.translate('xpack.observability.slos.sloEdit.sloDefinition.customKql.goodQuery', {
defaultMessage: 'Good query',
})}
</EuiFormLabel>
<Controller
name="indicator.params.good"
control={control}
rules={{ required: true }}
render={({ field }) => (
<EuiSuggest
append={<EuiButtonEmpty>KQL</EuiButtonEmpty>}
status="unchanged"
aria-label="Good filter"
data-test-subj="sloFormCustomKqlGoodQueryInput"
placeholder={i18n.translate(
'xpack.observability.slos.sloEdit.sloDefinition.customKql.goodQueryPlaceholder',
{
defaultMessage: 'Define the good events',
}
)}
suggestions={[]}
{...field}
/>
)}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormLabel>
{i18n.translate('xpack.observability.slos.sloEdit.sloDefinition.customKql.totalQuery', {
defaultMessage: 'Total query',
})}
</EuiFormLabel>
<Controller
name="indicator.params.total"
control={control}
rules={{ required: true }}
render={({ field }) => (
<EuiSuggest
append={<EuiButtonEmpty>KQL</EuiButtonEmpty>}
status="unchanged"
aria-label="Total filter"
data-test-subj="sloFormCustomKqlTotalQueryInput"
placeholder={i18n.translate(
'xpack.observability.slos.sloEdit.sloDefinition.customKql.totalQueryPlaceholder',
{
defaultMessage: 'Define the total events',
}
)}
suggestions={[]}
{...field}
/>
)}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,34 @@
/*
* 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 { FormProvider, useForm } from 'react-hook-form';
import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator';
import { SloEditFormDescription as Component, Props } from './slo_edit_form_description';
import { SLO_EDIT_FORM_DEFAULT_VALUES } from '../constants';
export default {
component: Component,
title: 'app/SLO/EditPage/SloEditFormDescription',
decorators: [KibanaReactStorybookDecorator],
};
const Template: ComponentStory<typeof Component> = (props: Props) => {
const methods = useForm({ defaultValues: SLO_EDIT_FORM_DEFAULT_VALUES });
return (
<FormProvider {...methods}>
<Component {...props} control={methods.control} />
</FormProvider>
);
};
const defaultProps = {};
export const SloEditFormDescription = Template.bind({});
SloEditFormDescription.args = defaultProps;

View file

@ -0,0 +1,87 @@
/*
* 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 {
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiFormLabel,
EuiTextArea,
useGeneratedHtmlId,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { Control, Controller } from 'react-hook-form';
import type { CreateSLOParams } from '@kbn/slo-schema';
export interface Props {
control: Control<CreateSLOParams>;
}
export function SloEditFormDescription({ control }: Props) {
const sloNameId = useGeneratedHtmlId({ prefix: 'sloName' });
const descriptionId = useGeneratedHtmlId({ prefix: 'sloDescription' });
return (
<EuiFlexGroup direction="column" gutterSize="l">
<EuiFlexItem>
<EuiFormLabel>
{i18n.translate('xpack.observability.slos.sloEdit.description.sloName', {
defaultMessage: 'SLO Name',
})}
</EuiFormLabel>
<Controller
name="name"
control={control}
rules={{ required: true }}
render={({ field }) => (
<EuiFieldText
fullWidth
id={sloNameId}
data-test-subj="sloFormNameInput"
placeholder={i18n.translate(
'xpack.observability.slos.sloEdit.description.sloNamePlaceholder',
{
defaultMessage: 'Name for the SLO',
}
)}
{...field}
/>
)}
/>
</EuiFlexItem>
<EuiFlexItem grow>
<EuiFormLabel>
{i18n.translate('xpack.observability.slos.sloEdit.description.sloDescription', {
defaultMessage: 'Description',
})}
</EuiFormLabel>
<Controller
name="description"
control={control}
render={({ field }) => (
<EuiTextArea
fullWidth
id={descriptionId}
data-test-subj="sloFormDescriptionTextArea"
placeholder={i18n.translate(
'xpack.observability.slos.sloEdit.description.sloDescriptionPlaceholder',
{
defaultMessage: 'A short description of the SLO',
}
)}
{...field}
/>
)}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,34 @@
/*
* 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 { FormProvider, useForm } from 'react-hook-form';
import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator';
import { SloEditFormObjectives as Component, Props } from './slo_edit_form_objectives';
import { SLO_EDIT_FORM_DEFAULT_VALUES } from '../constants';
export default {
component: Component,
title: 'app/SLO/EditPage/SloEditFormObjectives',
decorators: [KibanaReactStorybookDecorator],
};
const Template: ComponentStory<typeof Component> = (props: Props) => {
const methods = useForm({ defaultValues: SLO_EDIT_FORM_DEFAULT_VALUES });
return (
<FormProvider {...methods}>
<Component {...props} control={methods.control} watch={methods.watch} />
</FormProvider>
);
};
const defaultProps = {};
export const SloEditFormObjectives = Template.bind({});
SloEditFormObjectives.args = defaultProps;

View file

@ -0,0 +1,131 @@
/*
* 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 {
EuiFieldNumber,
EuiFlexGrid,
EuiFlexItem,
EuiFormLabel,
EuiSelect,
EuiSpacer,
useGeneratedHtmlId,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Control, Controller, UseFormWatch } from 'react-hook-form';
import type { BudgetingMethod, CreateSLOParams } from '@kbn/slo-schema';
import { SloEditFormObjectivesTimeslices } from './slo_edit_form_objectives_timeslices';
export const BUDGETING_METHOD_OPTIONS: Array<{ value: BudgetingMethod; text: string }> = [
{ value: 'occurrences', text: 'Occurences' },
{ value: 'timeslices', text: 'Timeslices' },
];
export const TIMEWINDOW_OPTIONS = [30, 7].map((number) => ({
value: `${number}d`,
text: i18n.translate('xpack.observability.slos.sloEdit.objectives.days', {
defaultMessage: '{number} days',
values: { number },
}),
}));
export interface Props {
control: Control<CreateSLOParams>;
watch: UseFormWatch<CreateSLOParams>;
}
export function SloEditFormObjectives({ control, watch }: Props) {
const budgetingSelect = useGeneratedHtmlId({ prefix: 'budgetingSelect' });
const timeWindowSelect = useGeneratedHtmlId({ prefix: 'timeWindowSelect' });
return (
<>
<EuiFlexGrid columns={3}>
<EuiFlexItem>
<EuiFormLabel>
{i18n.translate('xpack.observability.slos.sloEdit.objectives.budgetingMethod', {
defaultMessage: 'Budgeting method',
})}
</EuiFormLabel>
<Controller
name="budgetingMethod"
control={control}
rules={{ required: true }}
render={({ field }) => (
<EuiSelect
id={budgetingSelect}
data-test-subj="sloFormBudgetingMethodSelect"
options={BUDGETING_METHOD_OPTIONS}
{...field}
/>
)}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormLabel>
{i18n.translate('xpack.observability.slos.sloEdit.objectives.timeWindow', {
defaultMessage: 'Time window',
})}
</EuiFormLabel>
<Controller
name="timeWindow.duration"
control={control}
rules={{ required: true }}
render={({ field }) => (
<EuiSelect
id={timeWindowSelect}
data-test-subj="sloFormTimeWindowDurationSelect"
options={TIMEWINDOW_OPTIONS}
{...field}
value={String(field.value)}
/>
)}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormLabel>
{i18n.translate('xpack.observability.slos.sloEdit.objectives.targetSlo', {
defaultMessage: 'Target / SLO (%)',
})}
</EuiFormLabel>
<Controller
name="objective.target"
control={control}
rules={{
required: true,
min: 0.001,
max: 99.999,
}}
render={({ field }) => (
<EuiFieldNumber
data-test-subj="sloFormObjectiveTargetInput"
{...field}
min={0.001}
max={99.999}
step={0.001}
onChange={(event) => field.onChange(Number(event.target.value))}
/>
)}
/>
</EuiFlexItem>
</EuiFlexGrid>
{watch('budgetingMethod') === 'timeslices' ? (
<>
<EuiSpacer size="xl" />
<SloEditFormObjectivesTimeslices control={control} />
</>
) : null}
</>
);
}

View file

@ -0,0 +1,37 @@
/*
* 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 { FormProvider, useForm } from 'react-hook-form';
import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator';
import {
SloEditFormObjectivesTimeslices as Component,
Props,
} from './slo_edit_form_objectives_timeslices';
import { SLO_EDIT_FORM_DEFAULT_VALUES } from '../constants';
export default {
component: Component,
title: 'app/SLO/EditPage/SloEditFormObjectivesTimeslices',
decorators: [KibanaReactStorybookDecorator],
};
const Template: ComponentStory<typeof Component> = (props: Props) => {
const methods = useForm({ defaultValues: SLO_EDIT_FORM_DEFAULT_VALUES });
return (
<FormProvider {...methods}>
<Component {...props} control={methods.control} />
</FormProvider>
);
};
const defaultProps = {};
export const SloEditFormObjectivesTimeslices = Template.bind({});
SloEditFormObjectivesTimeslices.args = defaultProps;

View file

@ -0,0 +1,75 @@
/*
* 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 { EuiFieldNumber, EuiFlexGrid, EuiFlexItem, EuiFormLabel } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Control, Controller } from 'react-hook-form';
import type { CreateSLOParams } from '@kbn/slo-schema';
export interface Props {
control: Control<CreateSLOParams>;
}
export function SloEditFormObjectivesTimeslices({ control }: Props) {
return (
<EuiFlexGrid columns={3}>
<EuiFlexItem>
<EuiFormLabel>
{i18n.translate('xpack.observability.slos.sloEdit.objectives.timeSliceTarget', {
defaultMessage: 'Timeslice target (%)',
})}
</EuiFormLabel>
<Controller
name="objective.timesliceTarget"
control={control}
defaultValue={95}
rules={{
required: true,
min: 0.001,
max: 99.999,
}}
render={({ field }) => (
<EuiFieldNumber
{...field}
data-test-subj="sloFormObjectiveTimesliceTargetInput"
min={0.001}
max={99.999}
step={0.001}
onChange={(event) => field.onChange(Number(event.target.value))}
/>
)}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormLabel>
{i18n.translate('xpack.observability.slos.sloEdit.objectives.timesliceWindow', {
defaultMessage: 'Timeslice window (minutes)',
})}
</EuiFormLabel>
<Controller
name="objective.timesliceWindow"
control={control}
rules={{ required: true, min: 1, max: 120 }}
render={({ field }) => (
<EuiFieldNumber
{...field}
data-test-subj="sloFormObjectiveTimesliceWindowInput"
value={String(field.value)}
min={1}
max={120}
step={1}
onChange={(event) => field.onChange(String(event.target.value))}
/>
)}
/>
</EuiFlexItem>
</EuiFlexGrid>
);
}

View 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 { i18n } from '@kbn/i18n';
import type { CreateSLOParams } from '@kbn/slo-schema';
import {
BUDGETING_METHOD_OPTIONS,
TIMEWINDOW_OPTIONS,
} from './components/slo_edit_form_objectives';
export const SLI_OPTIONS = [
{
value: 'sli.kql.custom' as const,
text: i18n.translate('xpack.observability.slos.sloTypes.kqlCustomIndicator', {
defaultMessage: 'KQL custom indicator',
}),
},
];
export const SLO_EDIT_FORM_DEFAULT_VALUES: CreateSLOParams = {
name: '',
description: '',
indicator: {
type: SLI_OPTIONS[0].value,
params: {
index: '',
filter: '',
good: '',
total: '',
},
},
timeWindow: {
duration: TIMEWINDOW_OPTIONS[0].value as any, // Get this to be a proper Duration
isRolling: true,
},
budgetingMethod: BUDGETING_METHOD_OPTIONS[0].value,
objective: {
target: 99.5,
},
};

View file

@ -0,0 +1,36 @@
/*
* 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 { CreateSLOParams, GetSLOResponse } from '@kbn/slo-schema';
export function transformGetSloToCreateSloParams(
values: GetSLOResponse | undefined
): CreateSLOParams | undefined {
if (!values) return undefined;
return {
...values,
objective: {
target: values.objective.target * 100,
...(values.objective.timesliceTarget && {
timesliceTarget: values.objective.timesliceTarget * 100,
}),
},
} as unknown as CreateSLOParams;
}
export function processValues(values: CreateSLOParams): CreateSLOParams {
return {
...values,
objective: {
target: values.objective.target / 100,
...(values.objective.timesliceTarget && {
timesliceTarget: values.objective.timesliceTarget / 100,
}),
},
};
}

View file

@ -0,0 +1,35 @@
/*
* 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 { FormState, UseFormGetFieldState } from 'react-hook-form';
import type { CreateSLOParams } from '@kbn/slo-schema';
interface Props {
getFieldState: UseFormGetFieldState<CreateSLOParams>;
formState: FormState<CreateSLOParams>;
}
export function useCheckFormPartialValidities({ getFieldState, formState }: Props) {
const isDefinitionValid = (
[
'indicator.params.index',
'indicator.params.filter',
'indicator.params.good',
'indicator.params.total',
] as const
).every((field) => getFieldState(field, formState).error === undefined);
const isObjectiveValid = (
['budgetingMethod', 'timeWindow.duration', 'objective.target'] as const
).every((field) => getFieldState(field, formState).error === undefined);
const isDescriptionValid = (['name', 'description'] as const).every(
(field) => getFieldState(field, formState).error === undefined
);
return { isDefinitionValid, isObjectiveValid, isDescriptionValid };
}

View file

@ -0,0 +1,322 @@
/*
* 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 Router from 'react-router-dom';
import { waitFor, fireEvent, screen } from '@testing-library/dom';
import { BasePath } from '@kbn/core-http-server-internal';
import { cleanup } from '@testing-library/react';
import { render } from '../../utils/test_helper';
import { useKibana } from '../../utils/kibana_react';
import { useFetchIndices } from '../../hooks/use_fetch_indices';
import { useFetchSloDetails } from '../../hooks/slo/use_fetch_slo_details';
import { useCreateSlo } from '../../hooks/slo/use_create_slo';
import { kibanaStartMock } from '../../utils/kibana_react.mock';
import { ConfigSchema } from '../../plugin';
import { Subset } from '../../typings';
import { SLO_EDIT_FORM_DEFAULT_VALUES } from './constants';
import { anSLO } from '../../data/slo';
import { paths } from '../../config';
import { SloEditPage } from '.';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: jest.fn(),
}));
jest.mock('../../hooks/use_breadcrumbs');
jest.mock('../../hooks/use_fetch_indices');
jest.mock('../../hooks/slo/use_fetch_slo_details');
jest.mock('../../hooks/slo/use_create_slo');
const mockUseKibanaReturnValue = kibanaStartMock.startContract();
jest.mock('../../utils/kibana_react', () => ({
useKibana: jest.fn(() => mockUseKibanaReturnValue),
}));
const useFetchIndicesMock = useFetchIndices as jest.Mock;
const useFetchSloMock = useFetchSloDetails as jest.Mock;
const useCreateSloMock = useCreateSlo as jest.Mock;
const mockAddSuccess = jest.fn();
const mockAddError = jest.fn();
const mockNavigate = jest.fn();
(useKibana as jest.Mock).mockReturnValue({
services: {
application: { navigateToUrl: mockNavigate },
http: {
basePath: new BasePath('', undefined),
},
notifications: {
toasts: {
addSuccess: mockAddSuccess,
addError: mockAddError,
},
},
},
});
const config: Subset<ConfigSchema> = {
unsafe: {
slo: { enabled: true },
},
};
describe('SLO Edit Page', () => {
beforeEach(() => {
jest.clearAllMocks();
// Silence all the ref errors in Eui components.
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(cleanup);
describe('when the feature flag is not enabled', () => {
it('renders the not found page when no sloId param is passed', async () => {
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: undefined });
useFetchSloMock.mockReturnValue({ loading: false, slo: undefined });
render(<SloEditPage />, { unsafe: { slo: { enabled: false } } });
expect(screen.queryByTestId('pageNotFound')).toBeTruthy();
});
it('renders the not found page when sloId param is passed', async () => {
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '1234' });
useFetchSloMock.mockReturnValue({ loading: false, slo: undefined });
render(<SloEditPage />, { unsafe: { slo: { enabled: false } } });
expect(screen.queryByTestId('pageNotFound')).toBeTruthy();
});
});
describe('when the feature flag is enabled', () => {
it('renders the SLO Edit page in pristine state when no sloId route param is passed', async () => {
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: undefined });
useFetchSloMock.mockReturnValue({ loading: false, slo: undefined });
useFetchIndicesMock.mockReturnValue({
loading: false,
indices: [{ name: 'some-index' }],
});
useCreateSloMock.mockReturnValue({
loading: false,
success: false,
error: '',
createSlo: jest.fn(),
});
render(<SloEditPage />, config);
expect(screen.queryByTestId('slosEditPage')).toBeTruthy();
expect(screen.queryByTestId('sloForm')).toBeTruthy();
expect(screen.queryByTestId('sloFormIndicatorTypeSelect')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.indicator.type
);
expect(screen.queryByTestId('sloFormCustomKqlIndexInput')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.indicator.params.index
);
expect(screen.queryByTestId('sloFormCustomKqlFilterQueryInput')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.indicator.type === 'sli.kql.custom'
? SLO_EDIT_FORM_DEFAULT_VALUES.indicator.params.filter
: ''
);
expect(screen.queryByTestId('sloFormCustomKqlGoodQueryInput')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.indicator.type === 'sli.kql.custom'
? SLO_EDIT_FORM_DEFAULT_VALUES.indicator.params.good
: ''
);
expect(screen.queryByTestId('sloFormCustomKqlTotalQueryInput')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.indicator.type === 'sli.kql.custom'
? SLO_EDIT_FORM_DEFAULT_VALUES.indicator.params.total
: ''
);
expect(screen.queryByTestId('sloFormBudgetingMethodSelect')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.budgetingMethod
);
expect(screen.queryByTestId('sloFormTimeWindowDurationSelect')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.timeWindow.duration as any
);
expect(screen.queryByTestId('sloFormObjectiveTargetInput')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.objective.target
);
expect(screen.queryByTestId('sloFormNameInput')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.name
);
expect(screen.queryByTestId('sloFormDescriptionTextArea')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.description
);
});
it('renders the SLO Edit page with prefilled form values if sloId route param is passed', async () => {
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
useFetchSloMock.mockReturnValue({ loading: false, slo: anSLO });
useFetchIndicesMock.mockReturnValue({
loading: false,
indices: [{ name: 'some-index' }],
});
useCreateSloMock.mockReturnValue({
loading: false,
success: false,
error: '',
createSlo: jest.fn(),
});
render(<SloEditPage />, config);
expect(screen.queryByTestId('slosEditPage')).toBeTruthy();
expect(screen.queryByTestId('sloForm')).toBeTruthy();
expect(screen.queryByTestId('sloFormIndicatorTypeSelect')).toHaveValue(anSLO.indicator.type);
expect(screen.queryByTestId('sloFormCustomKqlIndexInput')).toHaveValue(
anSLO.indicator.params.index
);
expect(screen.queryByTestId('sloFormCustomKqlFilterQueryInput')).toHaveValue(
anSLO.indicator.type === 'sli.kql.custom' ? anSLO.indicator.params.filter : ''
);
expect(screen.queryByTestId('sloFormCustomKqlGoodQueryInput')).toHaveValue(
anSLO.indicator.type === 'sli.kql.custom' ? anSLO.indicator.params.good : ''
);
expect(screen.queryByTestId('sloFormCustomKqlTotalQueryInput')).toHaveValue(
anSLO.indicator.type === 'sli.kql.custom' ? anSLO.indicator.params.total : ''
);
expect(screen.queryByTestId('sloFormBudgetingMethodSelect')).toHaveValue(
anSLO.budgetingMethod
);
expect(screen.queryByTestId('sloFormTimeWindowDurationSelect')).toHaveValue(
anSLO.timeWindow.duration
);
expect(screen.queryByTestId('sloFormObjectiveTargetInput')).toHaveValue(
anSLO.objective.target * 100
);
expect(screen.queryByTestId('sloFormNameInput')).toHaveValue(anSLO.name);
expect(screen.queryByTestId('sloFormDescriptionTextArea')).toHaveValue(anSLO.description);
});
it('enables submitting if all required values are filled in', async () => {
// Note: the `anSLO` object is considered to have (at least)
// values for all required fields.
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
useFetchIndicesMock.mockReturnValue({
loading: false,
indices: [{ name: 'some-index' }],
});
useFetchSloMock.mockReturnValue({ loading: false, slo: anSLO });
const mockCreate = jest.fn();
useCreateSloMock.mockReturnValue({
loading: false,
success: false,
error: '',
createSlo: mockCreate,
});
render(<SloEditPage />, config);
await waitFor(() => expect(screen.queryByTestId('sloFormSubmitButton')).toBeEnabled());
fireEvent.click(screen.queryByTestId('sloFormSubmitButton')!);
expect(mockCreate).toBeCalledWith(anSLO);
});
it('blocks submitting if not all required values are filled in', async () => {
// Note: the `anSLO` object is considered to have (at least)
// values for all required fields.
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
useFetchIndicesMock.mockReturnValue({
loading: false,
indices: [],
});
useFetchSloMock.mockReturnValue({ loading: false, slo: { ...anSLO, name: '' } });
render(<SloEditPage />, config);
await waitFor(() => {
expect(screen.queryByTestId('sloFormSubmitButton')).toBeDisabled();
});
});
describe('if submitting has completed successfully', () => {
it('renders a success toast', async () => {
// Note: the `anSLO` object is considered to have (at least)
// values for all required fields.
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
useFetchSloMock.mockReturnValue({ loading: false, slo: anSLO });
useFetchIndicesMock.mockReturnValue({
loading: false,
indices: [{ name: 'some-index' }],
});
useCreateSloMock.mockReturnValue({
loading: false,
success: true,
error: '',
createSlo: jest.fn(),
});
render(<SloEditPage />, config);
expect(mockAddSuccess).toBeCalled();
});
it('navigates to the SLO List page', async () => {
// Note: the `anSLO` object is considered to have (at least)
// values for all required fields.
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
useFetchSloMock.mockReturnValue({ loading: false, slo: anSLO });
useFetchIndicesMock.mockReturnValue({
loading: false,
indices: [{ name: 'some-index' }],
});
useCreateSloMock.mockReturnValue({
loading: false,
success: true,
error: '',
createSlo: jest.fn(),
});
render(<SloEditPage />, config);
expect(mockNavigate).toBeCalledWith(paths.observability.slos);
});
});
describe('if submitting has not completed successfully', () => {
it('renders an error toast', async () => {
// Note: the `anSLO` object is considered to have (at least)
// values for all required fields.
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
useFetchSloMock.mockReturnValue({ loading: false, slo: anSLO });
useFetchIndicesMock.mockReturnValue({
loading: false,
indices: [{ name: 'some-index' }],
});
useCreateSloMock.mockReturnValue({
loading: false,
success: false,
error: 'Argh, API died',
createSlo: jest.fn(),
});
render(<SloEditPage />, config);
expect(mockAddError).toBeCalled();
});
});
});
});

View file

@ -0,0 +1,65 @@
/*
* 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 { i18n } from '@kbn/i18n';
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 { useFetchSloDetails } from '../../hooks/slo/use_fetch_slo_details';
import { SloEditForm } from './components/slo_edit_form';
import PageNotFound from '../404';
import { isSloFeatureEnabled } from '../slos/helpers/is_slo_feature_enabled';
export function SloEditPage() {
const { http } = useKibana<ObservabilityAppServices>().services;
const { ObservabilityPageTemplate, config } = usePluginContext();
const { sloId } = useParams<{ sloId: string | undefined }>();
useBreadcrumbs([
{
href: http.basePath.prepend(paths.observability.slos),
text: i18n.translate('xpack.observability.breadcrumbs.sloEditLinkText', {
defaultMessage: 'SLOs',
}),
},
]);
const { slo, loading } = useFetchSloDetails(sloId);
if (!isSloFeatureEnabled(config)) {
return <PageNotFound />;
}
if (loading) {
return null;
}
return (
<ObservabilityPageTemplate
pageHeader={{
pageTitle: slo
? i18n.translate('xpack.observability.sloEditPageTitle', {
defaultMessage: 'Edit SLO',
})
: i18n.translate('xpack.observability.sloCreatePageTitle', {
defaultMessage: 'Create new SLO',
}),
rightSideItems: [],
bottomBorder: false,
}}
data-test-subj="slosEditPage"
>
<SloEditForm slo={slo} />
</ObservabilityPageTemplate>
);
}

View file

@ -45,6 +45,10 @@ export function SloListItem({ slo, onDeleted, onDeleting }: SloListItemProps) {
setIsActionsPopoverOpen(!isActionsPopoverOpen);
};
const handleEdit = () => {
navigateToUrl(basePath.prepend(paths.observability.sloEdit(slo.id)));
};
const handleDelete = () => {
setDeleteConfirmationModalOpen(true);
setIsDeleting(true);
@ -112,7 +116,12 @@ export function SloListItem({ slo, onDeleted, onDeleting }: SloListItemProps) {
<EuiContextMenuPanel
size="s"
items={[
<EuiContextMenuItem key="edit" icon="trash" onClick={handleDelete}>
<EuiContextMenuItem key="edit" icon="pencil" onClick={handleEdit}>
{i18n.translate('xpack.observability.slos.slo.item.actions.edit', {
defaultMessage: 'Edit',
})}
</EuiContextMenuItem>,
<EuiContextMenuItem key="delete" icon="trash" onClick={handleDelete}>
{i18n.translate('xpack.observability.slos.slo.item.actions.delete', {
defaultMessage: 'Delete',
})}

View file

@ -8,11 +8,27 @@
import React from 'react';
import { EuiPageTemplate, EuiButton, EuiTitle, EuiLink, EuiImage } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { paths } from '../../../config';
import { useKibana } from '../../../utils/kibana_react';
import illustration from './assets/illustration.svg';
export function SloListWelcomePrompt() {
const {
application: { navigateToUrl },
http: { basePath },
} = useKibana().services;
const handleClickCreateSlo = () => {
navigateToUrl(basePath.prepend(paths.observability.sloCreate));
};
return (
<EuiPageTemplate minHeight="0" data-test-subj="slosPageWelcomePrompt">
<EuiPageTemplate
minHeight="0"
data-test-subj="slosPageWelcomePrompt"
style={{ paddingBlockStart: 0 }}
>
<EuiPageTemplate.EmptyPrompt
title={
<EuiTitle size="l">
@ -51,7 +67,7 @@ export function SloListWelcomePrompt() {
</>
}
actions={
<EuiButton color="primary" fill>
<EuiButton color="primary" fill onClick={handleClickCreateSlo}>
{i18n.translate('xpack.observability.slos.sloList.welcomePrompt.buttonLabel', {
defaultMessage: 'Create first SLO',
})}

View file

@ -1,8 +0,0 @@
/*
* 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 { isSloFeatureEnabled } from './is_slo_feature_enabled';

View file

@ -10,7 +10,6 @@ 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 { SlosPage } from '.';
@ -22,25 +21,15 @@ jest.mock('react-router-dom', () => ({
useParams: jest.fn(),
}));
jest.mock('../../hooks/slo/use_fetch_slo_list');
jest.mock('../../utils/kibana_react');
jest.mock('../../hooks/use_breadcrumbs');
jest.mock('./components/slo_list_item', () => ({ SloListItem: () => 'mocked SloListItem' }));
const mockUseKibanaReturnValue = kibanaStartMock.startContract();
jest.mock('../../utils/kibana_react', () => ({
useKibana: jest.fn(() => mockUseKibanaReturnValue),
}));
const useFetchSloListMock = useFetchSloList 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: {
@ -51,7 +40,6 @@ const config: Subset<ConfigSchema> = {
describe('SLOs Page', () => {
beforeEach(() => {
jest.clearAllMocks();
mockKibana();
});
it('renders the not found page when the feature flag is not enabled', async () => {

View file

@ -6,13 +6,15 @@
*/
import React from 'react';
import { EuiButton } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
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 { isSloFeatureEnabled } from './helpers';
import { isSloFeatureEnabled } from './helpers/is_slo_feature_enabled';
import { SLOS_BREADCRUMB_TEXT, SLOS_PAGE_TITLE } from './translations';
import { useFetchSloList } from '../../hooks/slo/use_fetch_slo_list';
import { SloList } from './components/slo_list';
@ -20,7 +22,10 @@ import { SloListWelcomePrompt } from './components/slo_list_welcome_prompt';
import PageNotFound from '../404';
export function SlosPage() {
const { http } = useKibana<ObservabilityAppServices>().services;
const {
application: { navigateToUrl },
http: { basePath },
} = useKibana<ObservabilityAppServices>().services;
const { ObservabilityPageTemplate, config } = usePluginContext();
const {
@ -30,11 +35,15 @@ export function SlosPage() {
useBreadcrumbs([
{
href: http.basePath.prepend(paths.observability.slos),
href: basePath.prepend(paths.observability.slos),
text: SLOS_BREADCRUMB_TEXT,
},
]);
const handleClickCreateSlo = () => {
navigateToUrl(basePath.prepend(paths.observability.sloCreate));
};
if (!isSloFeatureEnabled(config)) {
return <PageNotFound />;
}
@ -51,7 +60,13 @@ export function SlosPage() {
<ObservabilityPageTemplate
pageHeader={{
pageTitle: SLOS_PAGE_TITLE,
rightSideItems: [],
rightSideItems: [
<EuiButton color="primary" fill onClick={handleClickCreateSlo}>
{i18n.translate('xpack.observability.slos.sloList.pageHeader.createNewButtonLabel', {
defaultMessage: 'Create new SLO',
})}
</EuiButton>,
],
bottomBorder: false,
}}
data-test-subj="slosPage"

View file

@ -22,6 +22,7 @@ import { AlertDetails } from '../pages/alert_details';
import { DatePickerContextProvider } from '../context/date_picker_context';
import { SlosPage } from '../pages/slos';
import { SloDetailsPage } from '../pages/slo_details';
import { SloEditPage } from '../pages/slo_edit';
export type RouteParams<T extends keyof typeof routes> = DecodeParams<typeof routes[T]['params']>;
@ -138,6 +139,20 @@ export const routes = {
params: {},
exact: true,
},
'/slos/create': {
handler: () => {
return <SloEditPage />;
},
params: {},
exact: true,
},
'/slos/edit/:sloId': {
handler: () => {
return <SloEditPage />;
},
params: {},
exact: true,
},
'/slos/:sloId': {
handler: () => {
return <SloDetailsPage />;

View file

@ -33,8 +33,8 @@ import { IndicatorTypes } from '../../domain/models';
import { createObservabilityServerRoute } from '../create_observability_server_route';
const transformGenerators: Record<IndicatorTypes, TransformGenerator> = {
'sli.apm.transaction_duration': new ApmTransactionDurationTransformGenerator(),
'sli.apm.transaction_error_rate': new ApmTransactionErrorRateTransformGenerator(),
'sli.apm.transactionDuration': new ApmTransactionDurationTransformGenerator(),
'sli.apm.transactionErrorRate': new ApmTransactionErrorRateTransformGenerator(),
'sli.kql.custom': new KQLCustomTransformGenerator(),
};

View file

@ -56,7 +56,7 @@ describe('FindSLO', () => {
transactionType: 'irrelevant',
'threshold.us': 500000,
},
type: 'sli.apm.transaction_duration',
type: 'sli.apm.transactionDuration',
},
objective: {
target: 0.999,
@ -116,12 +116,12 @@ describe('FindSLO', () => {
);
});
it('calls the repository with the indicator_type filter criteria', async () => {
it('calls the repository with the indicatorType filter criteria', async () => {
const slo = createSLO();
mockRepository.find.mockResolvedValueOnce(createPaginatedSLO(slo));
mockSLIClient.fetchCurrentSLIData.mockResolvedValueOnce(someIndicatorData(slo));
await findSLO.execute({ indicator_types: ['sli.kql.custom'] });
await findSLO.execute({ indicatorTypes: ['sli.kql.custom'] });
expect(mockRepository.find).toHaveBeenCalledWith(
{ indicatorTypes: ['sli.kql.custom'] },
@ -135,7 +135,7 @@ describe('FindSLO', () => {
mockRepository.find.mockResolvedValueOnce(createPaginatedSLO(slo));
mockSLIClient.fetchCurrentSLIData.mockResolvedValueOnce(someIndicatorData(slo));
await findSLO.execute({ name: 'My SLO*', page: '2', per_page: '100' });
await findSLO.execute({ name: 'My SLO*', page: '2', perPage: '100' });
expect(mockRepository.find).toHaveBeenCalledWith(
{ name: 'My SLO*' },
@ -149,7 +149,7 @@ describe('FindSLO', () => {
mockRepository.find.mockResolvedValueOnce(createPaginatedSLO(slo));
mockSLIClient.fetchCurrentSLIData.mockResolvedValueOnce(someIndicatorData(slo));
await findSLO.execute({ page: '-1', per_page: '0' });
await findSLO.execute({ page: '-1', perPage: '0' });
expect(mockRepository.find).toHaveBeenCalledWith(
{ name: undefined },
@ -163,7 +163,7 @@ describe('FindSLO', () => {
mockRepository.find.mockResolvedValueOnce(createPaginatedSLO(slo));
mockSLIClient.fetchCurrentSLIData.mockResolvedValueOnce(someIndicatorData(slo));
await findSLO.execute({ sort_by: undefined });
await findSLO.execute({ sortBy: undefined });
expect(mockRepository.find).toHaveBeenCalledWith(
{ name: undefined },
@ -177,7 +177,7 @@ describe('FindSLO', () => {
mockRepository.find.mockResolvedValueOnce(createPaginatedSLO(slo));
mockSLIClient.fetchCurrentSLIData.mockResolvedValueOnce(someIndicatorData(slo));
await findSLO.execute({ sort_by: 'indicator_type' });
await findSLO.execute({ sortBy: 'indicatorType' });
expect(mockRepository.find).toHaveBeenCalledWith(
{ name: undefined },
@ -191,7 +191,7 @@ describe('FindSLO', () => {
mockRepository.find.mockResolvedValueOnce(createPaginatedSLO(slo));
mockSLIClient.fetchCurrentSLIData.mockResolvedValueOnce(someIndicatorData(slo));
await findSLO.execute({ sort_by: 'indicator_type', sort_direction: 'desc' });
await findSLO.execute({ sortBy: 'indicatorType', sortDirection: 'desc' });
expect(mockRepository.find).toHaveBeenCalledWith(
{ name: undefined },

View file

@ -73,7 +73,7 @@ function computeSloWithSummary(
function toPagination(params: FindSLOParams): Pagination {
const page = Number(params.page);
const perPage = Number(params.per_page);
const perPage = Number(params.perPage);
return {
page: !isNaN(page) && page >= 1 ? page : DEFAULT_PAGE,
@ -82,12 +82,12 @@ function toPagination(params: FindSLOParams): Pagination {
}
function toCriteria(params: FindSLOParams): Criteria {
return { name: params.name, indicatorTypes: params.indicator_types };
return { name: params.name, indicatorTypes: params.indicatorTypes };
}
function toSort(params: FindSLOParams): Sort {
return {
field: params.sort_by === 'indicator_type' ? SortField.IndicatorType : SortField.Name,
direction: params.sort_direction === 'desc' ? SortDirection.Desc : SortDirection.Asc,
field: params.sortBy === 'indicatorType' ? SortField.IndicatorType : SortField.Name,
direction: params.sortDirection === 'desc' ? SortDirection.Desc : SortDirection.Asc,
};
}

View file

@ -28,7 +28,7 @@ import { sevenDaysRolling } from './time_window';
export const createAPMTransactionErrorRateIndicator = (
params: Partial<APMTransactionErrorRateIndicator['params']> = {}
): Indicator => ({
type: 'sli.apm.transaction_error_rate',
type: 'sli.apm.transactionErrorRate',
params: {
environment: 'irrelevant',
service: 'irrelevant',
@ -42,7 +42,7 @@ export const createAPMTransactionErrorRateIndicator = (
export const createAPMTransactionDurationIndicator = (
params: Partial<APMTransactionDurationIndicator['params']> = {}
): Indicator => ({
type: 'sli.apm.transaction_duration',
type: 'sli.apm.transactionDuration',
params: {
environment: 'irrelevant',
service: 'irrelevant',

View file

@ -51,7 +51,7 @@ describe('GetSLO', () => {
transactionType: 'irrelevant',
goodStatusCodes: ['2xx', '3xx', '4xx'],
},
type: 'sli.apm.transaction_error_rate',
type: 'sli.apm.transactionErrorRate',
},
objective: {
target: 0.999,

View file

@ -197,7 +197,7 @@ describe('KibanaSavedObjectsSLORepository', () => {
soClientMock.find.mockResolvedValueOnce(aFindResponse(SOME_SLO));
const result = await repository.find(
{ indicatorTypes: ['sli.kql.custom', 'sli.apm.transaction_duration'] },
{ indicatorTypes: ['sli.kql.custom', 'sli.apm.transactionDuration'] },
DEFAULT_SORTING,
DEFAULT_PAGINATION
);
@ -212,7 +212,7 @@ describe('KibanaSavedObjectsSLORepository', () => {
type: SO_SLO_TYPE,
page: 1,
perPage: 25,
filter: `(slo.attributes.indicator.type: sli.kql.custom or slo.attributes.indicator.type: sli.apm.transaction_duration)`,
filter: `(slo.attributes.indicator.type: sli.kql.custom or slo.attributes.indicator.type: sli.apm.transactionDuration)`,
sortField: 'name',
sortOrder: 'asc',
});
@ -224,7 +224,7 @@ describe('KibanaSavedObjectsSLORepository', () => {
soClientMock.find.mockResolvedValueOnce(aFindResponse(SOME_SLO));
const result = await repository.find(
{ name: 'latency', indicatorTypes: ['sli.kql.custom', 'sli.apm.transaction_duration'] },
{ name: 'latency', indicatorTypes: ['sli.kql.custom', 'sli.apm.transactionDuration'] },
DEFAULT_SORTING,
DEFAULT_PAGINATION
);
@ -239,7 +239,7 @@ describe('KibanaSavedObjectsSLORepository', () => {
type: SO_SLO_TYPE,
page: 1,
perPage: 25,
filter: `(slo.attributes.name: *latency*) and (slo.attributes.indicator.type: sli.kql.custom or slo.attributes.indicator.type: sli.apm.transaction_duration)`,
filter: `(slo.attributes.name: *latency*) and (slo.attributes.indicator.type: sli.kql.custom or slo.attributes.indicator.type: sli.apm.transactionDuration)`,
sortField: 'name',
sortOrder: 'asc',
});

View file

@ -41,19 +41,19 @@ describe('TransformManager', () => {
it('throws when no generator exists for the slo indicator type', async () => {
// @ts-ignore defining only a subset of the possible SLI
const generators: Record<IndicatorTypes, TransformGenerator> = {
'sli.apm.transaction_duration': new DummyTransformGenerator(),
'sli.apm.transactionDuration': new DummyTransformGenerator(),
};
const service = new DefaultTransformManager(generators, esClientMock, loggerMock);
await expect(
service.install(createSLO({ indicator: createAPMTransactionErrorRateIndicator() }))
).rejects.toThrowError('Unsupported SLI type: sli.apm.transaction_error_rate');
).rejects.toThrowError('Unsupported SLI type: sli.apm.transactionErrorRate');
});
it('throws when transform generator fails', async () => {
// @ts-ignore defining only a subset of the possible SLI
const generators: Record<IndicatorTypes, TransformGenerator> = {
'sli.apm.transaction_duration': new FailTransformGenerator(),
'sli.apm.transactionDuration': new FailTransformGenerator(),
};
const transformManager = new DefaultTransformManager(generators, esClientMock, loggerMock);
@ -68,7 +68,7 @@ describe('TransformManager', () => {
it('installs the transform', async () => {
// @ts-ignore defining only a subset of the possible SLI
const generators: Record<IndicatorTypes, TransformGenerator> = {
'sli.apm.transaction_error_rate': new ApmTransactionErrorRateTransformGenerator(),
'sli.apm.transactionErrorRate': new ApmTransactionErrorRateTransformGenerator(),
};
const transformManager = new DefaultTransformManager(generators, esClientMock, loggerMock);
const slo = createSLO({ indicator: createAPMTransactionErrorRateIndicator() });
@ -84,7 +84,7 @@ describe('TransformManager', () => {
it('starts the transform', async () => {
// @ts-ignore defining only a subset of the possible SLI
const generators: Record<IndicatorTypes, TransformGenerator> = {
'sli.apm.transaction_error_rate': new ApmTransactionErrorRateTransformGenerator(),
'sli.apm.transactionErrorRate': new ApmTransactionErrorRateTransformGenerator(),
};
const transformManager = new DefaultTransformManager(generators, esClientMock, loggerMock);
@ -98,7 +98,7 @@ describe('TransformManager', () => {
it('stops the transform', async () => {
// @ts-ignore defining only a subset of the possible SLI
const generators: Record<IndicatorTypes, TransformGenerator> = {
'sli.apm.transaction_error_rate': new ApmTransactionErrorRateTransformGenerator(),
'sli.apm.transactionErrorRate': new ApmTransactionErrorRateTransformGenerator(),
};
const transformManager = new DefaultTransformManager(generators, esClientMock, loggerMock);
@ -112,7 +112,7 @@ describe('TransformManager', () => {
it('uninstalls the transform', async () => {
// @ts-ignore defining only a subset of the possible SLI
const generators: Record<IndicatorTypes, TransformGenerator> = {
'sli.apm.transaction_error_rate': new ApmTransactionErrorRateTransformGenerator(),
'sli.apm.transactionErrorRate': new ApmTransactionErrorRateTransformGenerator(),
};
const transformManager = new DefaultTransformManager(generators, esClientMock, loggerMock);
@ -127,7 +127,7 @@ describe('TransformManager', () => {
);
// @ts-ignore defining only a subset of the possible SLI
const generators: Record<IndicatorTypes, TransformGenerator> = {
'sli.apm.transaction_error_rate': new ApmTransactionErrorRateTransformGenerator(),
'sli.apm.transactionErrorRate': new ApmTransactionErrorRateTransformGenerator(),
};
const transformManager = new DefaultTransformManager(generators, esClientMock, loggerMock);

View file

@ -67,6 +67,7 @@
"@kbn/share-plugin",
"@kbn/core-notifications-browser",
"@kbn/slo-schema",
"@kbn/core-http-server-internal",
],
"exclude": [
"target/**/*",