feat(slo): allow configuration of advanced settings from UI (#200822)

This commit is contained in:
Kevin Delemme 2024-12-02 16:19:17 -05:00 committed by GitHub
parent 78842b7c1c
commit 8fe4c44192
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 1333 additions and 853 deletions

View file

@ -48663,19 +48663,23 @@ components:
properties:
frequency:
default: 1m
description: Configure how often the transform runs, default 1m
description: The interval between checks for changes in the source data. The minimum value is 1m and the maximum is 59m. The default value is 1 minute.
example: 5m
type: string
preventInitialBackfill:
default: false
description: Prevents the transform from backfilling data when it starts.
description: Start aggregating data from the time the SLO is created, instead of backfilling data from the beginning of the time window.
example: true
type: boolean
syncDelay:
default: 1m
description: The synch delay to apply to the transform. Default 1m
description: The time delay in minutes between the current time and the latest source data time. Increasing the value will delay any alerting. The default value is 1 minute. The minimum value is 1m and the maximum is 359m. It should always be greater then source index refresh interval.
example: 5m
type: string
syncField:
description: The date field that is used to identify new documents in the source. It is strongly recommended to use a field that contains the ingest timestamp. If you use a different field, you might need to set the delay such that it accounts for data transmission delays. When unspecified, we use the indicator timestamp field.
example: event.ingested
type: string
title: Settings
type: object
SLOs_slo_definition_response:

View file

@ -56371,19 +56371,23 @@ components:
properties:
frequency:
default: 1m
description: Configure how often the transform runs, default 1m
description: The interval between checks for changes in the source data. The minimum value is 1m and the maximum is 59m. The default value is 1 minute.
example: 5m
type: string
preventInitialBackfill:
default: false
description: Prevents the transform from backfilling data when it starts.
description: Start aggregating data from the time the SLO is created, instead of backfilling data from the beginning of the time window.
example: true
type: boolean
syncDelay:
default: 1m
description: The synch delay to apply to the transform. Default 1m
description: The time delay in minutes between the current time and the latest source data time. Increasing the value will delay any alerting. The default value is 1 minute. The minimum value is 1m and the maximum is 359m. It should always be greater then source index refresh interval.
example: 5m
type: string
syncField:
description: The date field that is used to identify new documents in the source. It is strongly recommended to use a field that contains the ingest timestamp. If you use a different field, you might need to set the delay such that it accounts for data transmission delays. When unspecified, we use the indicator timestamp field.
example: event.ingested
type: string
title: Settings
type: object
SLOs_slo_definition_response:

View file

@ -27,16 +27,26 @@ const objectiveSchema = t.intersection([
t.partial({ timesliceTarget: t.number, timesliceWindow: durationType }),
]);
const settingsSchema = t.type({
syncDelay: durationType,
frequency: durationType,
preventInitialBackfill: t.boolean,
});
const settingsSchema = t.intersection([
t.type({
syncDelay: durationType,
frequency: durationType,
preventInitialBackfill: t.boolean,
}),
t.partial({ syncField: t.union([t.string, t.null]) }),
]);
const groupBySchema = allOrAnyStringOrArray;
const optionalSettingsSchema = t.partial({ ...settingsSchema.props });
const optionalSettingsSchema = t.partial({
syncDelay: durationType,
frequency: durationType,
preventInitialBackfill: t.boolean,
syncField: t.union([t.string, t.null]),
});
const tagsSchema = t.array(t.string);
// id cannot contain special characters and spaces
const sloIdSchema = new t.Type<string, string, unknown>(
'sloIdSchema',

View file

@ -1647,20 +1647,25 @@
"description": "Defines properties for SLO settings.",
"type": "object",
"properties": {
"syncField": {
"description": "The date field that is used to identify new documents in the source. It is strongly recommended to use a field that contains the ingest timestamp. If you use a different field, you might need to set the delay such that it accounts for data transmission delays. When unspecified, we use the indicator timestamp field.",
"type": "string",
"example": "event.ingested"
},
"syncDelay": {
"description": "The synch delay to apply to the transform. Default 1m",
"description": "The time delay in minutes between the current time and the latest source data time. Increasing the value will delay any alerting. The default value is 1 minute. The minimum value is 1m and the maximum is 359m. It should always be greater then source index refresh interval.",
"type": "string",
"default": "1m",
"example": "5m"
},
"frequency": {
"description": "Configure how often the transform runs, default 1m",
"description": "The interval between checks for changes in the source data. The minimum value is 1m and the maximum is 59m. The default value is 1 minute.",
"type": "string",
"default": "1m",
"example": "5m"
},
"preventInitialBackfill": {
"description": "Prevents the transform from backfilling data when it starts.",
"description": "Start aggregating data from the time the SLO is created, instead of backfilling data from the beginning of the time window.",
"type": "boolean",
"default": false,
"example": true

View file

@ -1137,18 +1137,22 @@ components:
description: Defines properties for SLO settings.
type: object
properties:
syncField:
description: The date field that is used to identify new documents in the source. It is strongly recommended to use a field that contains the ingest timestamp. If you use a different field, you might need to set the delay such that it accounts for data transmission delays. When unspecified, we use the indicator timestamp field.
type: string
example: event.ingested
syncDelay:
description: The synch delay to apply to the transform. Default 1m
description: The time delay in minutes between the current time and the latest source data time. Increasing the value will delay any alerting. The default value is 1 minute. The minimum value is 1m and the maximum is 359m. It should always be greater then source index refresh interval.
type: string
default: 1m
example: 5m
frequency:
description: Configure how often the transform runs, default 1m
description: The interval between checks for changes in the source data. The minimum value is 1m and the maximum is 59m. The default value is 1 minute.
type: string
default: 1m
example: 5m
preventInitialBackfill:
description: Prevents the transform from backfilling data when it starts.
description: Start aggregating data from the time the SLO is created, instead of backfilling data from the beginning of the time window.
type: boolean
default: false
example: true

View file

@ -2,18 +2,22 @@ title: Settings
description: Defines properties for SLO settings.
type: object
properties:
syncField:
description: The date field that is used to identify new documents in the source. It is strongly recommended to use a field that contains the ingest timestamp. If you use a different field, you might need to set the delay such that it accounts for data transmission delays. When unspecified, we use the indicator timestamp field.
type: string
example: 'event.ingested'
syncDelay:
description: The synch delay to apply to the transform. Default 1m
description: The time delay in minutes between the current time and the latest source data time. Increasing the value will delay any alerting. The default value is 1 minute. The minimum value is 1m and the maximum is 359m. It should always be greater then source index refresh interval.
type: string
default: 1m
example: 5m
frequency:
description: Configure how often the transform runs, default 1m
description: The interval between checks for changes in the source data. The minimum value is 1m and the maximum is 59m. The default value is 1 minute.
type: string
default: 1m
example: 5m
preventInitialBackfill:
description: Prevents the transform from backfilling data when it starts.
description: Start aggregating data from the time the SLO is created, instead of backfilling data from the beginning of the time window.
type: boolean
default: false
example: true

View file

@ -33,17 +33,3 @@ paths:
# $ref: "paths/s@{spaceid}@api@slos@_definitions.yaml"
"/s/{spaceId}/api/observability/slos/_delete_instances":
$ref: "paths/s@{spaceid}@api@slos@_delete_instances.yaml"
# Security is defined when files are joined in oas_docs
# components:
# securitySchemes:
# basicAuth:
# type: http
# scheme: basic
# apiKeyAuth:
# type: apiKey
# in: header
# name: Authorization
# description: 'e.g. Authorization: ApiKey base64AccessApiKey'
# security:
# - basicAuth: []
# - apiKeyAuth: []

View file

@ -39,6 +39,7 @@ const baseSlo: Omit<SLOWithSummaryResponse, 'id'> = {
good: 'http_status: 2xx',
total: 'a query',
timestampField: 'custom_timestamp',
dataViewId: 'some-data-view-id',
},
},
timeWindow: {

View file

@ -11,16 +11,21 @@ import { SloEditLocatorDefinition } from './slo_edit';
describe('SloEditLocator', () => {
const locator = new SloEditLocatorDefinition();
it('should return correct url when empty params are provided', async () => {
it('returns the correct url when empty params are provided', async () => {
const location = await locator.getLocation({});
expect(location.app).toEqual('slo');
expect(location.path).toEqual('/create?_a=()');
});
it('should return correct url when slo is provided', async () => {
const location = await locator.getLocation(buildSlo({ id: 'foo' }));
it('returns the correct url when slo id is provided', async () => {
const location = await locator.getLocation({ id: 'existing-slo-id' });
expect(location.path).toEqual('/edit/existing-slo-id');
});
it('returns the correct url when partial slo input is provided', async () => {
const location = await locator.getLocation(buildSlo({ id: undefined }));
expect(location.path).toEqual(
"/edit/foo?_a=(budgetingMethod:occurrences,createdAt:'2022-12-29T10:11:12.000Z',description:'some%20description%20useful',enabled:!t,groupBy:'*',groupings:(),id:foo,indicator:(params:(filter:'baz:%20foo%20and%20bar%20%3E%202',good:'http_status:%202xx',index:some-index,timestampField:custom_timestamp,total:'a%20query'),type:sli.kql.custom),instanceId:'*',meta:(),name:'super%20important%20level%20service',objective:(target:0.98),revision:1,settings:(frequency:'1m',preventInitialBackfill:!f,syncDelay:'1m'),summary:(errorBudget:(consumed:0.064,initial:0.02,isEstimated:!f,remaining:0.936),fiveMinuteBurnRate:0,oneDayBurnRate:0,oneHourBurnRate:0,sliValue:0.99872,status:HEALTHY),tags:!(k8s,production,critical),timeWindow:(duration:'30d',type:rolling),updatedAt:'2022-12-29T10:11:12.000Z',version:2)"
"/create?_a=(budgetingMethod:occurrences,createdAt:'2022-12-29T10:11:12.000Z',description:'some%20description%20useful',enabled:!t,groupBy:'*',groupings:(),indicator:(params:(dataViewId:some-data-view-id,filter:'baz:%20foo%20and%20bar%20%3E%202',good:'http_status:%202xx',index:some-index,timestampField:custom_timestamp,total:'a%20query'),type:sli.kql.custom),instanceId:'*',meta:(),name:'super%20important%20level%20service',objective:(target:0.98),revision:1,settings:(frequency:'1m',preventInitialBackfill:!f,syncDelay:'1m'),summary:(errorBudget:(consumed:0.064,initial:0.02,isEstimated:!f,remaining:0.936),fiveMinuteBurnRate:0,oneDayBurnRate:0,oneHourBurnRate:0,sliValue:0.99872,status:HEALTHY),tags:!(k8s,production,critical),timeWindow:(duration:'30d',type:rolling),updatedAt:'2022-12-29T10:11:12.000Z',version:2)"
);
});
});

View file

@ -5,31 +5,34 @@
* 2.0.
*/
import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public';
import type { RecursivePartial } from '@elastic/charts';
import type { SerializableRecord } from '@kbn/utility-types';
import type { LocatorDefinition } from '@kbn/share-plugin/public';
import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public';
import { sloEditLocatorID } from '@kbn/observability-plugin/common';
import type { CreateSLOForm } from '../pages/slo_edit/types';
import type { LocatorDefinition } from '@kbn/share-plugin/public';
import { CreateSLOInput } from '@kbn/slo-schema';
import { SLO_CREATE_PATH } from '../../common/locators/paths';
export type SloEditParams = RecursivePartial<CreateSLOForm>;
export interface SloEditLocatorParams extends SloEditParams, SerializableRecord {}
export type SloEditLocatorParams = RecursivePartial<CreateSLOInput>;
export class SloEditLocatorDefinition implements LocatorDefinition<SloEditLocatorParams> {
public readonly id = sloEditLocatorID;
public readonly getLocation = async (slo: SloEditLocatorParams) => {
if (!!slo.id) {
return {
app: 'slo',
path: `/edit/${encodeURIComponent(slo.id)}`,
state: {},
};
}
return {
app: 'slo',
path: setStateToKbnUrl<SloEditParams>(
path: setStateToKbnUrl<RecursivePartial<CreateSLOInput>>(
'_a',
{
...slo,
},
slo,
{ useHash: false, storeInHashQuery: false },
slo.id ? `/edit/${encodeURIComponent(String(slo.id))}` : `${SLO_CREATE_PATH}`
SLO_CREATE_PATH
),
state: {},
};

View file

@ -8,15 +8,14 @@
import { EuiFlexGrid, EuiPanel, EuiText, useIsWithinBreakpoints } from '@elastic/eui';
import numeral from '@elastic/numeral';
import { i18n } from '@kbn/i18n';
import { TagsList } from '@kbn/observability-shared-plugin/public';
import {
SLOWithSummaryResponse,
occurrencesBudgetingMethodSchema,
querySchema,
rollingTimeWindowTypeSchema,
SLOWithSummaryResponse,
} from '@kbn/slo-schema';
import React from 'react';
import { TagsList } from '@kbn/observability-shared-plugin/public';
import { DisplayQuery } from './display_query';
import { useKibana } from '../../../../hooks/use_kibana';
import {
BUDGETING_METHOD_OCCURRENCES,
@ -26,9 +25,9 @@ import {
toIndicatorTypeLabel,
} from '../../../../utils/slo/labels';
import { ApmIndicatorOverview } from './apm_indicator_overview';
import { SyntheticsIndicatorOverview } from './synthetics_indicator_overview';
import { DisplayQuery } from './display_query';
import { OverviewItem } from './overview_item';
import { SyntheticsIndicatorOverview } from './synthetics_indicator_overview';
export interface Props {
slo: SLOWithSummaryResponse;
@ -170,6 +169,19 @@ export function Overview({ slo }: Props) {
}
/>
)}
<OverviewItem
title={i18n.translate('xpack.slo.sloDetails.overview.settings.syncDelay', {
defaultMessage: 'Sync delay',
})}
subtitle={slo.settings.syncDelay}
/>
<OverviewItem
title={i18n.translate('xpack.slo.sloDetails.overview.settings.frequency', {
defaultMessage: 'Frequency',
})}
subtitle={slo.settings.frequency}
/>
</EuiFlexGrid>
</EuiPanel>
);

View file

@ -0,0 +1,174 @@
/*
* 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 {
EuiAccordion,
EuiCheckbox,
EuiFieldNumber,
EuiFlexGrid,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiIcon,
EuiIconTip,
EuiTitle,
useGeneratedHtmlId,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { CreateSLOForm } from '../../../types';
import { SyncFieldSelector } from './sync_field_selector';
export function AdvancedSettings() {
const { control, getFieldState } = useFormContext<CreateSLOForm>();
const preventBackfillCheckbox = useGeneratedHtmlId({ prefix: 'preventBackfill' });
const advancedSettingsAccordion = useGeneratedHtmlId({ prefix: 'advancedSettingsAccordion' });
return (
<EuiAccordion
paddingSize="s"
id={advancedSettingsAccordion}
buttonContent={
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>
<EuiIcon type="controlsVertical" size="m" />
</EuiFlexItem>
<EuiFlexItem>
<EuiTitle size="xxs">
<h3>
{i18n.translate('xpack.slo.sloEdit.settings.advancedSettingsLabel', {
defaultMessage: 'Advanced settings',
})}
</h3>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
}
>
<EuiFlexGroup direction="column" gutterSize="m">
<EuiFlexGrid columns={3} gutterSize="m">
<EuiFlexItem>
<SyncFieldSelector />
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
isInvalid={getFieldState('settings.syncDelay').invalid}
label={
<span>
{i18n.translate('xpack.slo.sloEdit.settings.syncDelay.label', {
defaultMessage: 'Sync delay (in minutes)',
})}{' '}
<EuiIconTip
content={i18n.translate('xpack.slo.sloEdit.settings.syncDelay.tooltip', {
defaultMessage:
'The time delay in minutes between the current time and the latest source data time. Increasing the value will delay any alerting. The default value is 1 minute. The minimum value is 1m and the maximum is 359m. It should always be greater then source index refresh interval.',
})}
position="top"
/>
</span>
}
>
<Controller
name="settings.syncDelay"
defaultValue={1}
control={control}
rules={{ required: true, min: 1, max: 359 }}
render={({ field: { ref, onChange, ...field }, fieldState }) => (
<EuiFieldNumber
{...field}
data-test-subj="sloAdvancedSettingsSyncDelay"
isInvalid={fieldState.invalid}
required
value={field.value}
min={1}
max={359}
step={1}
onChange={(event) => onChange(event.target.value)}
/>
)}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
isInvalid={getFieldState('settings.frequency').invalid}
label={
<span>
{i18n.translate('xpack.slo.sloEdit.settings.frequency.label', {
defaultMessage: 'Frequency (in minutes)',
})}{' '}
<EuiIconTip
content={i18n.translate('xpack.slo.sloEdit.settings.frequency.tooltip', {
defaultMessage:
'The interval between checks for changes in the source data. The minimum value is 1m and the maximum is 59m. The default value is 1 minute.',
})}
position="top"
/>
</span>
}
>
<Controller
name="settings.frequency"
defaultValue={1}
control={control}
rules={{ required: true, min: 1, max: 59 }}
render={({ field: { ref, onChange, ...field }, fieldState }) => (
<EuiFieldNumber
{...field}
data-test-subj="sloAdvancedSettingsFrequency"
isInvalid={fieldState.invalid}
required
value={field.value}
min={1}
max={59}
step={1}
onChange={(event) => onChange(event.target.value)}
/>
)}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGrid>
<EuiFormRow isInvalid={getFieldState('settings.preventInitialBackfill').invalid}>
<Controller
name="settings.preventInitialBackfill"
control={control}
render={({ field: { ref, onChange, ...field } }) => (
<EuiCheckbox
id={preventBackfillCheckbox}
label={
<span>
{i18n.translate('xpack.slo.sloEdit.settings.preventInitialBackfill.label', {
defaultMessage: 'Prevent initial backfill of data',
})}{' '}
<EuiIconTip
content={i18n.translate(
'xpack.slo.sloEdit.settings.preventInitialBackfill.tooltip',
{
defaultMessage:
'Start aggregating data from the time the SLO is created, instead of backfilling data from the beginning of the time window.',
}
)}
position="top"
/>
</span>
}
checked={Boolean(field.value)}
onChange={(event: any) => onChange(event.target.checked)}
/>
)}
/>
</EuiFormRow>
</EuiFlexGroup>
</EuiAccordion>
);
}

View file

@ -0,0 +1,84 @@
/*
* 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, EuiFormRow, EuiIconTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { useCreateDataView } from '../../../../../hooks/use_create_data_view';
import { createOptionsFromFields } from '../../../helpers/create_options';
import { CreateSLOForm } from '../../../types';
import { OptionalText } from '../../common/optional_text';
const placeholder = i18n.translate('xpack.slo.sloEdit.settings.syncField.placeholder', {
defaultMessage: 'Select a timestamp field',
});
export function SyncFieldSelector() {
const { control, watch, getFieldState } = useFormContext<CreateSLOForm>();
const [index, dataViewId] = watch(['indicator.params.index', 'indicator.params.dataViewId']);
const { dataView, loading: isIndexFieldsLoading } = useCreateDataView({
indexPatternString: index,
dataViewId,
});
const timestampFields = dataView?.fields?.filter((field) => field.type === 'date') ?? [];
return (
<EuiFormRow
label={
<span>
{i18n.translate('xpack.slo.sloEdit.settings.syncField.label', {
defaultMessage: 'Sync field',
})}{' '}
<EuiIconTip
content={i18n.translate('xpack.slo.sloEdit.settings.syncField.tooltip', {
defaultMessage:
'The date field that is used to identify new documents in the source. It is strongly recommended to use a field that contains the ingest timestamp. If you use a different field, you might need to set the delay such that it accounts for data transmission delays. When unspecified, we use the indicator timestamp field.',
})}
position="top"
/>
</span>
}
isInvalid={getFieldState('settings.syncField').invalid}
labelAppend={<OptionalText />}
>
<Controller
name={'settings.syncField'}
defaultValue={null}
control={control}
rules={{ required: false }}
render={({ field, fieldState }) => {
return (
<EuiComboBox<string>
{...field}
placeholder={placeholder}
aria-label={placeholder}
isClearable
isDisabled={isIndexFieldsLoading}
isInvalid={fieldState.invalid}
isLoading={isIndexFieldsLoading}
onChange={(selected: EuiComboBoxOptionOption[]) => {
if (selected.length) {
return field.onChange(selected[0].value);
}
field.onChange(null);
}}
singleSelection={{ asPlainText: true }}
options={createOptionsFromFields(timestampFields)}
selectedOptions={
!!timestampFields && !!field.value
? [{ value: field.value, label: field.value }]
: []
}
/>
);
}}
/>
</EuiFormRow>
);
}

View file

@ -9,9 +9,9 @@ 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 { KibanaReactStorybookDecorator } from '../../../../../utils/kibana_react.storybook_decorator';
import { ApmAvailabilityIndicatorTypeForm as Component } from './apm_availability_indicator_type_form';
import { SLO_EDIT_FORM_DEFAULT_VALUES } from '../../constants';
import { SLO_EDIT_FORM_DEFAULT_VALUES } from '../../../constants';
export default {
component: Component,

View file

@ -12,14 +12,14 @@ import React from 'react';
import { useFormContext } from 'react-hook-form';
import { useApmDefaultValues } from '../apm_common/use_apm_default_values';
import { DATA_VIEW_FIELD } from '../custom_common/index_selection';
import { useCreateDataView } from '../../../../hooks/use_create_data_view';
import { GroupByField } from '../common/group_by_field';
import { useFetchApmIndex } from '../../../../hooks/use_fetch_apm_indices';
import { CreateSLOForm } from '../../types';
import { useCreateDataView } from '../../../../../hooks/use_create_data_view';
import { GroupByField } from '../../common/group_by_field';
import { useFetchApmIndex } from '../../../../../hooks/use_fetch_apm_indices';
import { CreateSLOForm } from '../../../types';
import { FieldSelector } from '../apm_common/field_selector';
import { DataPreviewChart } from '../common/data_preview_chart';
import { QueryBuilder } from '../common/query_builder';
import { formatAllFilters } from '../../helpers/format_filters';
import { DataPreviewChart } from '../../common/data_preview_chart';
import { QueryBuilder } from '../../common/query_builder';
import { formatAllFilters } from '../../../helpers/format_filters';
import { getGroupByCardinalityFilters } from '../apm_common/get_group_by_cardinality_filters';
export function ApmAvailabilityIndicatorTypeForm() {
@ -56,8 +56,8 @@ export function ApmAvailabilityIndicatorTypeForm() {
});
return (
<EuiFlexGroup direction="column" gutterSize="l">
<EuiFlexGroup direction="row" gutterSize="l">
<EuiFlexGroup direction="column" gutterSize="m">
<EuiFlexGroup direction="row" gutterSize="m">
<FieldSelector
allowAllOption={false}
label={i18n.translate('xpack.slo.sloEdit.apmAvailability.serviceName', {
@ -94,7 +94,7 @@ export function ApmAvailabilityIndicatorTypeForm() {
/>
</EuiFlexGroup>
<EuiFlexGroup direction="row" gutterSize="l">
<EuiFlexGroup direction="row" gutterSize="m">
<FieldSelector
label={i18n.translate('xpack.slo.sloEdit.apmAvailability.transactionType', {
defaultMessage: 'Transaction type',
@ -125,7 +125,7 @@ export function ApmAvailabilityIndicatorTypeForm() {
/>
</EuiFlexGroup>
<EuiFlexGroup direction="row" gutterSize="l">
<EuiFlexGroup direction="row" gutterSize="m">
<EuiFlexItem>
<QueryBuilder
dataTestSubj="apmLatencyFilterInput"

View file

@ -9,9 +9,9 @@ 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 { KibanaReactStorybookDecorator } from '../../../../../utils/kibana_react.storybook_decorator';
import { FieldSelector as Component, Props } from './field_selector';
import { SLO_EDIT_FORM_DEFAULT_VALUES } from '../../constants';
import { SLO_EDIT_FORM_DEFAULT_VALUES } from '../../../constants';
export default {
component: Component,

View file

@ -11,8 +11,8 @@ import { ALL_VALUE } from '@kbn/slo-schema';
import { debounce } from 'lodash';
import React, { ReactNode, useState } from 'react';
import { Controller, FieldPath, useFormContext } from 'react-hook-form';
import { Suggestion, useFetchApmSuggestions } from '../../../../hooks/use_fetch_apm_suggestions';
import { CreateSLOForm } from '../../types';
import { Suggestion, useFetchApmSuggestions } from '../../../../../hooks/use_fetch_apm_suggestions';
import { CreateSLOForm } from '../../../types';
interface Option {
label: string;

View file

@ -8,8 +8,8 @@
import { useFormContext } from 'react-hook-form';
import { ALL_VALUE, APMTransactionErrorRateIndicator } from '@kbn/slo-schema';
import { useEffect } from 'react';
import { useFetchApmIndex } from '../../../../hooks/use_fetch_apm_indices';
import { CreateSLOForm } from '../../types';
import { useFetchApmIndex } from '../../../../../hooks/use_fetch_apm_indices';
import { CreateSLOForm } from '../../../types';
export const useApmDefaultValues = () => {
const { watch, setValue } = useFormContext<CreateSLOForm<APMTransactionErrorRateIndicator>>();

View file

@ -9,9 +9,9 @@ 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 { KibanaReactStorybookDecorator } from '../../../../../utils/kibana_react.storybook_decorator';
import { ApmLatencyIndicatorTypeForm as Component } from './apm_latency_indicator_type_form';
import { SLO_EDIT_FORM_DEFAULT_VALUES } from '../../constants';
import { SLO_EDIT_FORM_DEFAULT_VALUES } from '../../../constants';
export default {
component: Component,

View file

@ -12,14 +12,14 @@ import React from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { useApmDefaultValues } from '../apm_common/use_apm_default_values';
import { DATA_VIEW_FIELD } from '../custom_common/index_selection';
import { GroupByField } from '../common/group_by_field';
import { useCreateDataView } from '../../../../hooks/use_create_data_view';
import { useFetchApmIndex } from '../../../../hooks/use_fetch_apm_indices';
import { CreateSLOForm } from '../../types';
import { GroupByField } from '../../common/group_by_field';
import { useCreateDataView } from '../../../../../hooks/use_create_data_view';
import { useFetchApmIndex } from '../../../../../hooks/use_fetch_apm_indices';
import { CreateSLOForm } from '../../../types';
import { FieldSelector } from '../apm_common/field_selector';
import { DataPreviewChart } from '../common/data_preview_chart';
import { QueryBuilder } from '../common/query_builder';
import { formatAllFilters } from '../../helpers/format_filters';
import { DataPreviewChart } from '../../common/data_preview_chart';
import { QueryBuilder } from '../../common/query_builder';
import { formatAllFilters } from '../../../helpers/format_filters';
import { getGroupByCardinalityFilters } from '../apm_common/get_group_by_cardinality_filters';
export function ApmLatencyIndicatorTypeForm() {
@ -58,8 +58,8 @@ export function ApmLatencyIndicatorTypeForm() {
});
return (
<EuiFlexGroup direction="column" gutterSize="l">
<EuiFlexGroup direction="row" gutterSize="l">
<EuiFlexGroup direction="column" gutterSize="m">
<EuiFlexGroup direction="row" gutterSize="m">
<FieldSelector
allowAllOption={false}
label={i18n.translate('xpack.slo.sloEdit.apmLatency.serviceName', {
@ -96,7 +96,7 @@ export function ApmLatencyIndicatorTypeForm() {
/>
</EuiFlexGroup>
<EuiFlexGroup direction="row" gutterSize="l">
<EuiFlexGroup direction="row" gutterSize="m">
<FieldSelector
label={i18n.translate('xpack.slo.sloEdit.apmLatency.transactionType', {
defaultMessage: 'Transaction type',

View file

@ -9,17 +9,16 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { DataView } from '@kbn/data-views-plugin/public';
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { CreateSLOForm } from '../../types';
import { TimestampFieldSelector } from '../common/timestamp_field_selector';
import { CreateSLOForm } from '../../../types';
import { TimestampFieldSelector } from '../../common/timestamp_field_selector';
import { IndexSelection } from './index_selection';
export function IndexAndTimestampField({
dataView,
isLoading,
}: {
interface Props {
dataView?: DataView;
isLoading: boolean;
}) {
}
export function IndexAndTimestampField({ dataView, isLoading }: Props) {
const { watch } = useFormContext<CreateSLOForm>();
const index = watch('indicator.params.index');

View file

@ -9,9 +9,9 @@ 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 { KibanaReactStorybookDecorator } from '../../../../../utils/kibana_react.storybook_decorator';
import { IndexSelection as Component } from './index_selection';
import { SLO_EDIT_FORM_DEFAULT_VALUES } from '../../constants';
import { SLO_EDIT_FORM_DEFAULT_VALUES } from '../../../constants';
export default {
component: Component,

View file

@ -8,37 +8,47 @@
import { EuiFormRow } from '@elastic/eui';
import { DataView } from '@kbn/data-views-plugin/public';
import { i18n } from '@kbn/i18n';
import { ALL_VALUE } from '@kbn/slo-schema';
import { DataViewPicker } from '@kbn/unified-search-plugin/public';
import React, { useEffect } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { DataViewPicker } from '@kbn/unified-search-plugin/public';
import { getDataViewPattern, useAdhocDataViews } from './use_adhoc_data_views';
import { SLOPublicPluginsStart } from '../../../..';
import { useKibana } from '../../../../hooks/use_kibana';
import { CreateSLOForm } from '../../types';
import { SLOPublicPluginsStart } from '../../../../..';
import { useKibana } from '../../../../../hooks/use_kibana';
import { CreateSLOForm } from '../../../types';
import { getDataViewPatternOrId, useAdhocDataViews } from './use_adhoc_data_views';
const BTN_MAX_WIDTH = 515;
export const DATA_VIEW_FIELD = 'indicator.params.dataViewId';
const INDEX_FIELD = 'indicator.params.index';
const TIMESTAMP_FIELD = 'indicator.params.timestampField';
const INDICATOR_TIMESTAMP_FIELD = 'indicator.params.timestampField';
const GROUP_BY_FIELD = 'groupBy';
const SETTINGS_SYNC_FIELD = 'settings.syncField';
export function IndexSelection({ selectedDataView }: { selectedDataView?: DataView }) {
const { control, getFieldState, setValue, watch } = useFormContext<CreateSLOForm>();
const { dataViews: dataViewsService, dataViewFieldEditor } = useKibana().services;
const { dataViewEditor } = useKibana<SLOPublicPluginsStart>().services;
const {
dataViews: dataViewsService,
dataViewFieldEditor,
dataViewEditor,
} = useKibana<SLOPublicPluginsStart>().services;
const currentIndexPattern = watch(INDEX_FIELD);
const currentDataViewId = watch(DATA_VIEW_FIELD);
const { dataViewsList, isDataViewsLoading, adHocDataViews, setAdHocDataViews, refetch } =
useAdhocDataViews({
currentIndexPattern,
});
const {
dataViewsList,
isDataViewsLoading,
adHocDataViews,
setAdHocDataViews,
refetchDataViewsList,
} = useAdhocDataViews({
currentIndexPattern,
});
useEffect(() => {
const indPatternId = getDataViewPattern({
byPatten: currentIndexPattern,
const indPatternId = getDataViewPatternOrId({
byPattern: currentIndexPattern,
dataViewsList,
adHocDataViews,
});
@ -54,13 +64,24 @@ export function IndexSelection({ selectedDataView }: { selectedDataView?: DataVi
setValue,
]);
const updateDataViewDependantFields = (indexPattern?: string, timestampField?: string) => {
setValue(INDEX_FIELD, indexPattern ?? '');
setValue(INDICATOR_TIMESTAMP_FIELD, timestampField ?? '');
setValue(GROUP_BY_FIELD, ALL_VALUE);
setValue(SETTINGS_SYNC_FIELD, null);
};
return (
<EuiFormRow label={INDEX_LABEL} isInvalid={getFieldState(INDEX_FIELD).invalid} fullWidth>
<EuiFormRow
label={INDEX_LABEL}
isInvalid={getFieldState(INDEX_FIELD).invalid || getFieldState(DATA_VIEW_FIELD).invalid}
fullWidth
>
<Controller
defaultValue=""
name={DATA_VIEW_FIELD}
control={control}
rules={{ required: !Boolean(currentIndexPattern) }}
rules={{ required: true }}
render={({ field, fieldState }) => (
<DataViewPicker
adHocDataViews={adHocDataViews}
@ -72,15 +93,13 @@ export function IndexSelection({ selectedDataView }: { selectedDataView?: DataVi
style: { width: '100%', maxWidth: BTN_MAX_WIDTH },
}}
onChangeDataView={(newId: string) => {
setValue(
INDEX_FIELD,
getDataViewPattern({ byId: newId, adHocDataViews, dataViewsList })!
);
field.onChange(newId);
dataViewsService.get(newId).then((dataView) => {
if (dataView.timeFieldName) {
setValue(TIMESTAMP_FIELD, dataView.timeFieldName);
}
updateDataViewDependantFields(
getDataViewPatternOrId({ byId: newId, adHocDataViews, dataViewsList })!,
dataView.timeFieldName
);
});
}}
onAddField={
@ -97,8 +116,8 @@ export function IndexSelection({ selectedDataView }: { selectedDataView?: DataVi
}
currentDataViewId={
field.value ??
getDataViewPattern({
byPatten: currentIndexPattern,
getDataViewPatternOrId({
byPattern: currentIndexPattern,
dataViewsList,
adHocDataViews,
})
@ -108,17 +127,13 @@ export function IndexSelection({ selectedDataView }: { selectedDataView?: DataVi
allowAdHocDataView: true,
onSave: (dataView: DataView) => {
if (!dataView.isPersisted()) {
setAdHocDataViews([...adHocDataViews, dataView]);
field.onChange(dataView.id);
setValue(INDEX_FIELD, dataView.getIndexPattern());
setAdHocDataViews((prev) => [...prev, dataView]);
} else {
refetch();
field.onChange(dataView.id);
setValue(INDEX_FIELD, dataView.getIndexPattern());
}
if (dataView.timeFieldName) {
setValue(TIMESTAMP_FIELD, dataView.timeFieldName);
refetchDataViewsList();
}
field.onChange(dataView.id);
updateDataViewDependantFields(dataView.getIndexPattern(), dataView.timeFieldName);
},
});
}}

View file

@ -8,16 +8,16 @@
import { useEffect, useState } from 'react';
import { DataView, DataViewListItem } from '@kbn/data-views-plugin/common';
import { useFetchDataViews } from '@kbn/observability-plugin/public';
import { useKibana } from '../../../../hooks/use_kibana';
import { useKibana } from '../../../../../hooks/use_kibana';
export const getDataViewPattern = ({
export const getDataViewPatternOrId = ({
byId,
byPatten,
byPattern,
dataViewsList,
adHocDataViews,
}: {
byId?: string;
byPatten?: string;
byPattern?: string;
dataViewsList: DataViewListItem[];
adHocDataViews: DataView[];
}) => {
@ -28,20 +28,24 @@ export const getDataViewPattern = ({
if (byId) {
return allDataViews.find((dv) => dv.id === byId)?.title;
}
if (byPatten) {
return allDataViews.find((dv) => dv.title === byPatten)?.id;
if (byPattern) {
return allDataViews.find((dv) => dv.title === byPattern)?.id;
}
};
export const useAdhocDataViews = ({ currentIndexPattern }: { currentIndexPattern: string }) => {
const { isLoading: isDataViewsLoading, data: dataViewsList = [], refetch } = useFetchDataViews();
const {
isLoading: isDataViewsLoading,
data: dataViewsList = [],
refetch: refetchDataViewsList,
} = useFetchDataViews();
const { dataViews: dataViewsService } = useKibana().services;
const [adHocDataViews, setAdHocDataViews] = useState<DataView[]>([]);
useEffect(() => {
if (!isDataViewsLoading) {
const missingDataView = getDataViewPattern({
byPatten: currentIndexPattern,
const missingDataView = getDataViewPatternOrId({
byPattern: currentIndexPattern,
dataViewsList,
adHocDataViews,
});
@ -70,6 +74,6 @@ export const useAdhocDataViews = ({ currentIndexPattern }: { currentIndexPattern
setAdHocDataViews,
dataViewsList,
isDataViewsLoading,
refetch,
refetchDataViewsList,
};
};

View file

@ -9,9 +9,9 @@ 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 { KibanaReactStorybookDecorator } from '../../../../../utils/kibana_react.storybook_decorator';
import { CustomKqlIndicatorTypeForm as Component } from './custom_kql_indicator_type_form';
import { SLO_EDIT_FORM_DEFAULT_VALUES } from '../../constants';
import { SLO_EDIT_FORM_DEFAULT_VALUES } from '../../../constants';
export default {
component: Component,

View file

@ -9,12 +9,12 @@ import { EuiFlexGroup, EuiFlexItem, EuiIconTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { useCreateDataView } from '../../../../../hooks/use_create_data_view';
import { CreateSLOForm } from '../../../types';
import { DataPreviewChart } from '../../common/data_preview_chart';
import { GroupByField } from '../../common/group_by_field';
import { QueryBuilder } from '../../common/query_builder';
import { IndexAndTimestampField } from '../custom_common/index_and_timestamp_field';
import { GroupByField } from '../common/group_by_field';
import { useCreateDataView } from '../../../../hooks/use_create_data_view';
import { CreateSLOForm } from '../../types';
import { DataPreviewChart } from '../common/data_preview_chart';
import { QueryBuilder } from '../common/query_builder';
import { DATA_VIEW_FIELD } from '../custom_common/index_selection';
export function CustomKqlIndicatorTypeForm() {
@ -28,7 +28,7 @@ export function CustomKqlIndicatorTypeForm() {
});
return (
<EuiFlexGroup direction="column" gutterSize="l">
<EuiFlexGroup direction="column" gutterSize="m">
<IndexAndTimestampField dataView={dataView} isLoading={isIndexFieldsLoading} />
<EuiFlexItem>

View file

@ -9,9 +9,9 @@ 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 { KibanaReactStorybookDecorator } from '../../../../../utils/kibana_react.storybook_decorator';
import { CustomMetricIndicatorTypeForm as Component } from './custom_metric_type_form';
import { SLO_EDIT_FORM_DEFAULT_VALUES_CUSTOM_METRIC } from '../../constants';
import { SLO_EDIT_FORM_DEFAULT_VALUES_CUSTOM_METRIC } from '../../../constants';
export default {
component: Component,

View file

@ -18,11 +18,11 @@ import { FormattedMessage } from '@kbn/i18n-react';
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { IndexAndTimestampField } from '../custom_common/index_and_timestamp_field';
import { GroupByField } from '../common/group_by_field';
import { useCreateDataView } from '../../../../hooks/use_create_data_view';
import { CreateSLOForm } from '../../types';
import { DataPreviewChart } from '../common/data_preview_chart';
import { QueryBuilder } from '../common/query_builder';
import { GroupByField } from '../../common/group_by_field';
import { useCreateDataView } from '../../../../../hooks/use_create_data_view';
import { CreateSLOForm } from '../../../types';
import { DataPreviewChart } from '../../common/data_preview_chart';
import { QueryBuilder } from '../../common/query_builder';
import { DATA_VIEW_FIELD } from '../custom_common/index_selection';
import { MetricIndicator } from './metric_indicator';
@ -55,7 +55,7 @@ export function CustomMetricIndicatorTypeForm() {
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiFlexGroup direction="column" gutterSize="l">
<EuiFlexGroup direction="column" gutterSize="m">
<IndexAndTimestampField dataView={dataView} isLoading={isIndexFieldsLoading} />
<EuiFlexItem>

View file

@ -26,10 +26,10 @@ import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
import {
aggValueToLabel,
CUSTOM_METRIC_AGGREGATION_OPTIONS,
} from '../../helpers/aggregation_options';
import { createOptionsFromFields, Option } from '../../helpers/create_options';
import { CreateSLOForm } from '../../types';
import { QueryBuilder } from '../common/query_builder';
} from '../../../helpers/aggregation_options';
import { createOptionsFromFields, Option } from '../../../helpers/create_options';
import { CreateSLOForm } from '../../../types';
import { QueryBuilder } from '../../common/query_builder';
interface MetricIndicatorProps {
type: 'good' | 'total';
@ -134,95 +134,28 @@ export function MetricIndicator({
<EuiFlexItem>
{fields?.map((metric, index, arr) => (
<div key={metric.id}>
<EuiFlexGroup alignItems="center" gutterSize="xs" key={metric.id}>
<input hidden {...register(`indicator.params.${type}.metrics.${index}.name`)} />
<EuiFlexItem>
<EuiFormRow
fullWidth
isInvalid={
getFieldState(`indicator.params.${type}.metrics.${index}.aggregation`).invalid
}
label={
<span>
{i18n.translate('xpack.slo.sloEdit.customMetric.aggregationLabel', {
defaultMessage: 'Aggregation',
})}{' '}
{metric.name}
</span>
}
>
<Controller
name={`indicator.params.${type}.metrics.${index}.aggregation`}
defaultValue="sum"
rules={{ required: true }}
control={control}
render={({ field: { ref, ...field }, fieldState }) => (
<EuiComboBox
{...field}
async
fullWidth
singleSelection={{ asPlainText: true }}
placeholder={i18n.translate(
'xpack.slo.sloEdit.sliType.customMetric.aggregation.placeholder',
{ defaultMessage: 'Select an aggregation' }
)}
aria-label={i18n.translate(
'xpack.slo.sloEdit.sliType.customMetric.aggregation.placeholder',
{ defaultMessage: 'Select an aggregation' }
)}
isClearable
isInvalid={fieldState.invalid}
isDisabled={isLoadingIndex || !indexPattern}
isLoading={!!indexPattern && isLoadingIndex}
onChange={(selected: EuiComboBoxOptionOption[]) => {
if (selected.length) {
return field.onChange(selected[0].value);
}
field.onChange('');
}}
selectedOptions={
!!indexPattern &&
!!field.value &&
CUSTOM_METRIC_AGGREGATION_OPTIONS.some((agg) => agg.value === field.value)
? [
{
value: field.value,
label: aggValueToLabel(field.value),
},
]
: []
}
onSearchChange={(searchValue: string) => {
setAggregationOptions(
CUSTOM_METRIC_AGGREGATION_OPTIONS.filter(({ value }) =>
value.includes(searchValue)
)
);
}}
options={aggregationOptions}
/>
)}
/>
</EuiFormRow>
</EuiFlexItem>
{watch(`indicator.params.${type}.metrics.${index}.aggregation`) !== 'doc_count' && (
<EuiFlexGroup direction="column" gutterSize="xs">
<EuiFlexGroup alignItems="center" gutterSize="xs" key={metric.id}>
<input hidden {...register(`indicator.params.${type}.metrics.${index}.name`)} />
<EuiFlexItem>
<EuiFormRow
fullWidth
isInvalid={
getFieldState(`indicator.params.${type}.metrics.${index}.field`).invalid
getFieldState(`indicator.params.${type}.metrics.${index}.aggregation`).invalid
}
label={
<span>
{metricLabel} {metric.name} {metricTooltip}
{i18n.translate('xpack.slo.sloEdit.customMetric.aggregationLabel', {
defaultMessage: 'Aggregation',
})}{' '}
{metric.name}
</span>
}
>
<Controller
name={`indicator.params.${type}.metrics.${index}.field`}
defaultValue=""
name={`indicator.params.${type}.metrics.${index}.aggregation`}
defaultValue="sum"
rules={{ required: true }}
shouldUnregister
control={control}
render={({ field: { ref, ...field }, fieldState }) => (
<EuiComboBox
@ -231,12 +164,12 @@ export function MetricIndicator({
fullWidth
singleSelection={{ asPlainText: true }}
placeholder={i18n.translate(
'xpack.slo.sloEdit.sliType.customMetric.metricField.placeholder',
{ defaultMessage: 'Select a metric field' }
'xpack.slo.sloEdit.sliType.customMetric.aggregation.placeholder',
{ defaultMessage: 'Select an aggregation' }
)}
aria-label={i18n.translate(
'xpack.slo.sloEdit.sliType.customMetric.metricField.placeholder',
{ defaultMessage: 'Select a metric field' }
'xpack.slo.sloEdit.sliType.customMetric.aggregation.placeholder',
{ defaultMessage: 'Select an aggregation' }
)}
isClearable
isInvalid={fieldState.invalid}
@ -251,64 +184,138 @@ export function MetricIndicator({
selectedOptions={
!!indexPattern &&
!!field.value &&
metricFields.some((metricField) => metricField.name === field.value)
CUSTOM_METRIC_AGGREGATION_OPTIONS.some(
(agg) => agg.value === field.value
)
? [
{
value: field.value,
label: field.value,
label: aggValueToLabel(field.value),
},
]
: []
}
onSearchChange={(searchValue: string) => {
setOptions(
createOptionsFromFields(metricFields, ({ value }) =>
setAggregationOptions(
CUSTOM_METRIC_AGGREGATION_OPTIONS.filter(({ value }) =>
value.includes(searchValue)
)
);
}}
options={options}
options={aggregationOptions}
/>
)}
/>
</EuiFormRow>
</EuiFlexItem>
)}
<EuiFlexItem grow={0}>
<EuiButtonIcon
data-test-subj="o11yMetricIndicatorButton"
iconType="trash"
color="danger"
style={{ marginTop: '1.5em' }}
onClick={handleDeleteMetric(index)}
disabled={disableDelete}
title={i18n.translate('xpack.slo.sloEdit.sliType.customMetric.deleteLabel', {
defaultMessage: 'Delete metric',
})}
aria-label={i18n.translate('xpack.slo.sloEdit.sliType.customMetric.deleteLabel', {
defaultMessage: 'Delete metric',
})}
/>
</EuiFlexItem>
{watch(`indicator.params.${type}.metrics.${index}.aggregation`) !== 'doc_count' && (
<EuiFlexItem>
<EuiFormRow
fullWidth
isInvalid={
getFieldState(`indicator.params.${type}.metrics.${index}.field`).invalid
}
label={
<span>
{metricLabel} {metric.name} {metricTooltip}
</span>
}
>
<Controller
name={`indicator.params.${type}.metrics.${index}.field`}
defaultValue=""
rules={{ required: true }}
shouldUnregister
control={control}
render={({ field: { ref, ...field }, fieldState }) => (
<EuiComboBox
{...field}
async
fullWidth
singleSelection={{ asPlainText: true }}
placeholder={i18n.translate(
'xpack.slo.sloEdit.sliType.customMetric.metricField.placeholder',
{ defaultMessage: 'Select a metric field' }
)}
aria-label={i18n.translate(
'xpack.slo.sloEdit.sliType.customMetric.metricField.placeholder',
{ defaultMessage: 'Select a metric field' }
)}
isClearable
isInvalid={fieldState.invalid}
isDisabled={isLoadingIndex || !indexPattern}
isLoading={!!indexPattern && isLoadingIndex}
onChange={(selected: EuiComboBoxOptionOption[]) => {
if (selected.length) {
return field.onChange(selected[0].value);
}
field.onChange('');
}}
selectedOptions={
!!indexPattern &&
!!field.value &&
metricFields.some((metricField) => metricField.name === field.value)
? [
{
value: field.value,
label: field.value,
},
]
: []
}
onSearchChange={(searchValue: string) => {
setOptions(
createOptionsFromFields(metricFields, ({ value }) =>
value.includes(searchValue)
)
);
}}
options={options}
/>
)}
/>
</EuiFormRow>
</EuiFlexItem>
)}
<EuiFlexItem grow={0}>
<EuiButtonIcon
data-test-subj="o11yMetricIndicatorButton"
iconType="trash"
color="danger"
style={{ marginTop: '1.5em' }}
onClick={handleDeleteMetric(index)}
disabled={disableDelete}
title={i18n.translate('xpack.slo.sloEdit.sliType.customMetric.deleteLabel', {
defaultMessage: 'Delete metric',
})}
aria-label={i18n.translate(
'xpack.slo.sloEdit.sliType.customMetric.deleteLabel',
{
defaultMessage: 'Delete metric',
}
)}
/>
</EuiFlexItem>
</EuiFlexGroup>
<QueryBuilder
dataTestSubj="customKqlIndicatorFormGoodQueryInput"
dataView={dataView}
label={`${filterLabel} ${metric.name}`}
name={`indicator.params.${type}.metrics.${index}.filter`}
placeholder={i18n.translate('xpack.slo.sloEdit.sliType.customMetric.placeholder', {
defaultMessage: 'KQL filter',
})}
required={false}
tooltip={
<EuiIconTip
content={i18n.translate('xpack.slo.sloEdit.sliType.customMetric.tooltip', {
defaultMessage: 'This KQL query should return a subset of events.',
})}
position="top"
/>
}
/>
</EuiFlexGroup>
<QueryBuilder
dataTestSubj="customKqlIndicatorFormGoodQueryInput"
dataView={dataView}
label={`${filterLabel} ${metric.name}`}
name={`indicator.params.${type}.metrics.${index}.filter`}
placeholder={i18n.translate('xpack.slo.sloEdit.sliType.customMetric.placeholder', {
defaultMessage: 'KQL filter',
})}
required={false}
tooltip={
<EuiIconTip
content={i18n.translate('xpack.slo.sloEdit.sliType.customMetric.tooltip', {
defaultMessage: 'This KQL query should return a subset of events.',
})}
position="top"
/>
}
/>
{index !== arr.length - 1 && <EuiHorizontalRule size="quarter" />}
</div>
))}

View file

@ -19,9 +19,9 @@ import { DataView, FieldSpec } from '@kbn/data-views-plugin/common';
import { i18n } from '@kbn/i18n';
import React, { Fragment, useEffect, useState } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { createOptionsFromFields, Option } from '../../helpers/create_options';
import { CreateSLOForm } from '../../types';
import { QueryBuilder } from '../common/query_builder';
import { createOptionsFromFields, Option } from '../../../helpers/create_options';
import { CreateSLOForm } from '../../../types';
import { QueryBuilder } from '../../common/query_builder';
interface HistogramIndicatorProps {
type: 'good' | 'total';

View file

@ -18,11 +18,11 @@ import { FormattedMessage } from '@kbn/i18n-react';
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { IndexAndTimestampField } from '../custom_common/index_and_timestamp_field';
import { useCreateDataView } from '../../../../hooks/use_create_data_view';
import { GroupByField } from '../common/group_by_field';
import { CreateSLOForm } from '../../types';
import { DataPreviewChart } from '../common/data_preview_chart';
import { QueryBuilder } from '../common/query_builder';
import { useCreateDataView } from '../../../../../hooks/use_create_data_view';
import { GroupByField } from '../../common/group_by_field';
import { CreateSLOForm } from '../../../types';
import { DataPreviewChart } from '../../common/data_preview_chart';
import { QueryBuilder } from '../../common/query_builder';
import { DATA_VIEW_FIELD } from '../custom_common/index_selection';
import { HistogramIndicator } from './histogram_indicator';
@ -49,7 +49,7 @@ export function HistogramIndicatorTypeForm() {
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiFlexGroup direction="column" gutterSize="l">
<EuiFlexGroup direction="column" gutterSize="m">
<IndexAndTimestampField dataView={dataView} isLoading={isIndexFieldsLoading} />
<EuiFlexItem>

View file

@ -17,12 +17,12 @@ import moment from 'moment';
import React, { useEffect, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { DATA_VIEW_FIELD } from '../custom_common/index_selection';
import { useCreateDataView } from '../../../../hooks/use_create_data_view';
import { formatAllFilters } from '../../helpers/format_filters';
import { CreateSLOForm } from '../../types';
import { DataPreviewChart } from '../common/data_preview_chart';
import { GroupByCardinality } from '../common/group_by_cardinality';
import { QueryBuilder } from '../common/query_builder';
import { useCreateDataView } from '../../../../../hooks/use_create_data_view';
import { formatAllFilters } from '../../../helpers/format_filters';
import { CreateSLOForm } from '../../../types';
import { DataPreviewChart } from '../../common/data_preview_chart';
import { GroupByCardinality } from '../../common/group_by_cardinality';
import { QueryBuilder } from '../../common/query_builder';
import { FieldSelector } from '../synthetics_common/field_selector';
export function SyntheticsAvailabilityIndicatorTypeForm() {
@ -74,8 +74,8 @@ export function SyntheticsAvailabilityIndicatorTypeForm() {
}, [currentMonitors, setValue]);
return (
<EuiFlexGroup direction="column" gutterSize="l">
<EuiFlexGroup direction="column" gutterSize="l">
<EuiFlexGroup direction="column" gutterSize="m">
<EuiFlexGroup direction="column" gutterSize="m">
<FieldSelector
required
allowAllOption
@ -130,7 +130,7 @@ export function SyntheticsAvailabilityIndicatorTypeForm() {
/>
</EuiFlexGroup>
<EuiFlexGroup direction="row" gutterSize="l">
<EuiFlexGroup direction="row" gutterSize="m">
<EuiFlexItem>
<QueryBuilder
dataTestSubj="syntheticsAvailabilityFilterInput"

View file

@ -11,12 +11,12 @@ import { i18n } from '@kbn/i18n';
import { ALL_VALUE, SyntheticsAvailabilityIndicator } from '@kbn/slo-schema';
import { debounce } from 'lodash';
import { Controller, FieldPath, useFormContext } from 'react-hook-form';
import { OptionalText } from '../common/optional_text';
import { OptionalText } from '../../common/optional_text';
import {
useFetchSyntheticsSuggestions,
Suggestion,
} from '../../../../hooks/use_fetch_synthetics_suggestions';
import { CreateSLOForm } from '../../types';
} from '../../../../../hooks/use_fetch_synthetics_suggestions';
import { CreateSLOForm } from '../../../types';
interface Option {
label: string;

View file

@ -24,9 +24,9 @@ import { FormattedMessage } from '@kbn/i18n-react';
import { first, range, xor } from 'lodash';
import React from 'react';
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
import { QueryBuilder } from '../common/query_builder';
import { COMPARATOR_OPTIONS } from '../../constants';
import { CreateSLOForm } from '../../types';
import { QueryBuilder } from '../../common/query_builder';
import { COMPARATOR_OPTIONS } from '../../../constants';
import { CreateSLOForm } from '../../../types';
import { MetricInput } from './metric_input';
interface MetricIndicatorProps {
@ -117,54 +117,56 @@ export function MetricIndicator({ indexFields, isLoadingIndex, dataView }: Metri
<EuiFlexItem>
{fields?.map((metric, index, arr) => (
<React.Fragment key={metric.id}>
<EuiFlexGroup alignItems="center" gutterSize="xs">
<input hidden {...register(`indicator.params.metric.metrics.${index}.name`)} />
<MetricInput
isLoadingIndex={isLoadingIndex}
metricIndex={index}
indexPattern={indexPattern}
indexFields={indexFields}
<EuiFlexGroup direction="column" gutterSize="xs">
<EuiFlexGroup alignItems="center" gutterSize="xs">
<input hidden {...register(`indicator.params.metric.metrics.${index}.name`)} />
<MetricInput
isLoadingIndex={isLoadingIndex}
metricIndex={index}
indexPattern={indexPattern}
indexFields={indexFields}
/>
<EuiFlexItem grow={0}>
<EuiButtonIcon
data-test-subj="o11yMetricIndicatorButton"
iconType="trash"
color="danger"
style={{ marginTop: '1.5em' }}
onClick={handleDeleteMetric(index)}
disabled={disableDelete}
title={i18n.translate('xpack.slo.sloEdit.sliType.timesliceMetric.deleteLabel', {
defaultMessage: 'Delete metric',
})}
aria-label={i18n.translate(
'xpack.slo.sloEdit.sliType.timesliceMetric.deleteLabel',
{ defaultMessage: 'Delete metric' }
)}
/>
</EuiFlexItem>
</EuiFlexGroup>
<QueryBuilder
dataTestSubj="timesliceMetricIndicatorFormMetricQueryInput"
dataView={dataView}
label={`${filterLabel} ${metric.name}`}
name={`indicator.params.metric.metrics.${index}.filter`}
placeholder={i18n.translate(
'xpack.slo.sloEdit.sliType.timesliceMetric.goodQuery.placeholder',
{ defaultMessage: 'KQL filter' }
)}
required={false}
tooltip={
<EuiIconTip
content={i18n.translate(
'xpack.slo.sloEdit.sliType.timesliceMetric.goodQuery.tooltip',
{
defaultMessage: 'This KQL query should return a subset of events.',
}
)}
position="top"
/>
}
/>
<EuiFlexItem grow={0}>
<EuiButtonIcon
data-test-subj="o11yMetricIndicatorButton"
iconType="trash"
color="danger"
style={{ marginTop: '1.5em' }}
onClick={handleDeleteMetric(index)}
disabled={disableDelete}
title={i18n.translate('xpack.slo.sloEdit.sliType.timesliceMetric.deleteLabel', {
defaultMessage: 'Delete metric',
})}
aria-label={i18n.translate(
'xpack.slo.sloEdit.sliType.timesliceMetric.deleteLabel',
{ defaultMessage: 'Delete metric' }
)}
/>
</EuiFlexItem>
</EuiFlexGroup>
<QueryBuilder
dataTestSubj="timesliceMetricIndicatorFormMetricQueryInput"
dataView={dataView}
label={`${filterLabel} ${metric.name}`}
name={`indicator.params.metric.metrics.${index}.filter`}
placeholder={i18n.translate(
'xpack.slo.sloEdit.sliType.timesliceMetric.goodQuery.placeholder',
{ defaultMessage: 'KQL filter' }
)}
required={false}
tooltip={
<EuiIconTip
content={i18n.translate(
'xpack.slo.sloEdit.sliType.timesliceMetric.goodQuery.tooltip',
{
defaultMessage: 'This KQL query should return a subset of events.',
}
)}
position="top"
/>
}
/>
{index !== arr.length - 1 && <EuiHorizontalRule size="quarter" />}
</React.Fragment>
))}

View file

@ -16,9 +16,9 @@ import { FieldSpec } from '@kbn/data-views-plugin/common';
import { i18n } from '@kbn/i18n';
import React, { useEffect, useState } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { AGGREGATION_OPTIONS, aggValueToLabel } from '../../helpers/aggregation_options';
import { createOptionsFromFields, Option } from '../../helpers/create_options';
import { CreateSLOForm } from '../../types';
import { AGGREGATION_OPTIONS, aggValueToLabel } from '../../../helpers/aggregation_options';
import { createOptionsFromFields, Option } from '../../../helpers/create_options';
import { CreateSLOForm } from '../../../types';
const fieldLabel = i18n.translate('xpack.slo.sloEdit.sliType.timesliceMetric.fieldLabel', {
defaultMessage: 'Field',

View file

@ -19,15 +19,15 @@ import { FormattedMessage } from '@kbn/i18n-react';
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { IndexAndTimestampField } from '../custom_common/index_and_timestamp_field';
import { useKibana } from '../../../../hooks/use_kibana';
import { GroupByField } from '../common/group_by_field';
import { CreateSLOForm } from '../../types';
import { DataPreviewChart } from '../common/data_preview_chart';
import { QueryBuilder } from '../common/query_builder';
import { useKibana } from '../../../../../hooks/use_kibana';
import { GroupByField } from '../../common/group_by_field';
import { CreateSLOForm } from '../../../types';
import { DataPreviewChart } from '../../common/data_preview_chart';
import { QueryBuilder } from '../../common/query_builder';
import { DATA_VIEW_FIELD } from '../custom_common/index_selection';
import { MetricIndicator } from './metric_indicator';
import { COMPARATOR_MAPPING } from '../../constants';
import { useCreateDataView } from '../../../../hooks/use_create_data_view';
import { COMPARATOR_MAPPING } from '../../../constants';
import { useCreateDataView } from '../../../../../hooks/use_create_data_view';
export { NEW_TIMESLICE_METRIC } from './metric_indicator';
@ -54,7 +54,7 @@ export function TimesliceMetricIndicatorTypeForm() {
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiFlexGroup direction="column" gutterSize="l">
<EuiFlexGroup direction="column" gutterSize="m">
<IndexAndTimestampField dataView={dataView} isLoading={isIndexFieldsLoading} />
<EuiFlexItem>

View file

@ -5,43 +5,56 @@
* 2.0.
*/
import { EuiFlexGroup, EuiSpacer, EuiSteps } from '@elastic/eui';
import { EuiFlexGroup, EuiSteps } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { GetSLOResponse } from '@kbn/slo-schema';
import type { CreateSLOInput, GetSLOResponse } from '@kbn/slo-schema';
import { RecursivePartial } from '@kbn/utility-types';
import React from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { RecursivePartial } from '@kbn/utility-types';
import { SloEditFormFooter } from './slo_edit_form_footer';
import { SLO_EDIT_FORM_DEFAULT_VALUES } from '../constants';
import { transformSloResponseToCreateSloForm } from '../helpers/process_slo_form_values';
import {
transformPartialSLOStateToFormState,
transformSloResponseToCreateSloForm,
} from '../helpers/process_slo_form_values';
import { useParseUrlState } from '../hooks/use_parse_url_state';
import { useSectionFormValidation } from '../hooks/use_section_form_validation';
import { useShowSections } from '../hooks/use_show_sections';
import { CreateSLOForm } from '../types';
import { SloEditFormDescriptionSection } from './slo_edit_form_description_section';
import { SloEditFormFooter } from './slo_edit_form_footer';
import { SloEditFormIndicatorSection } from './slo_edit_form_indicator_section';
import { SloEditFormObjectiveSection } from './slo_edit_form_objective_section';
export interface Props {
slo?: GetSLOResponse;
initialValues?: RecursivePartial<CreateSLOForm>;
initialValues?: RecursivePartial<CreateSLOInput>;
onSave?: () => void;
}
export const maxWidth = 900;
export function SloEditForm({ slo, initialValues, onSave }: Props) {
const isEditMode = slo !== undefined;
const isFlyoutMode = initialValues !== undefined && onSave !== undefined;
const sloFormValuesFromUrlState = useParseUrlState() ?? (initialValues as CreateSLOForm);
const sloFormValuesFromFlyoutState = isFlyoutMode
? transformPartialSLOStateToFormState(initialValues)
: undefined;
const sloFormValuesFromUrlState = useParseUrlState();
const sloFormValuesFromSloResponse = transformSloResponseToCreateSloForm(slo);
const methods = useForm<CreateSLOForm>({
defaultValues: sloFormValuesFromUrlState ?? SLO_EDIT_FORM_DEFAULT_VALUES,
values: sloFormValuesFromUrlState ? sloFormValuesFromUrlState : sloFormValuesFromSloResponse,
const form = useForm<CreateSLOForm>({
defaultValues: isFlyoutMode
? sloFormValuesFromFlyoutState
: sloFormValuesFromUrlState
? sloFormValuesFromUrlState
: sloFormValuesFromSloResponse ?? SLO_EDIT_FORM_DEFAULT_VALUES,
values: isFlyoutMode
? sloFormValuesFromFlyoutState
: sloFormValuesFromUrlState
? sloFormValuesFromUrlState
: sloFormValuesFromSloResponse,
mode: 'all',
});
const { watch, getFieldState, getValues, formState } = methods;
const { watch, getFieldState, getValues, formState } = form;
const { isIndicatorSectionValid, isObjectiveSectionValid, isDescriptionSectionValid } =
useSectionFormValidation({
@ -59,41 +72,37 @@ export function SloEditForm({ slo, initialValues, onSave }: Props) {
);
return (
<>
<FormProvider {...methods}>
<EuiFlexGroup direction="column" gutterSize="s" data-test-subj="sloForm">
<EuiSteps
steps={[
{
title: i18n.translate('xpack.slo.sloEdit.definition.title', {
defaultMessage: 'Define SLI',
}),
children: <SloEditFormIndicatorSection isEditMode={isEditMode} />,
status: isIndicatorSectionValid ? 'complete' : 'incomplete',
},
{
title: i18n.translate('xpack.slo.sloEdit.objectives.title', {
defaultMessage: 'Set objectives',
}),
children: showObjectiveSection ? <SloEditFormObjectiveSection /> : null,
status: showObjectiveSection && isObjectiveSectionValid ? 'complete' : 'incomplete',
},
{
title: i18n.translate('xpack.slo.sloEdit.description.title', {
defaultMessage: 'Describe SLO',
}),
children: showDescriptionSection ? <SloEditFormDescriptionSection /> : null,
status:
showDescriptionSection && isDescriptionSectionValid ? 'complete' : 'incomplete',
},
]}
/>
<FormProvider {...form}>
<EuiFlexGroup direction="column" gutterSize="m" data-test-subj="sloForm">
<EuiSteps
steps={[
{
title: i18n.translate('xpack.slo.sloEdit.definition.title', {
defaultMessage: 'Define SLI',
}),
children: <SloEditFormIndicatorSection isEditMode={isEditMode} />,
status: isIndicatorSectionValid ? 'complete' : 'incomplete',
},
{
title: i18n.translate('xpack.slo.sloEdit.objectives.title', {
defaultMessage: 'Set objectives',
}),
children: showObjectiveSection ? <SloEditFormObjectiveSection /> : null,
status: showObjectiveSection && isObjectiveSectionValid ? 'complete' : 'incomplete',
},
{
title: i18n.translate('xpack.slo.sloEdit.description.title', {
defaultMessage: 'Describe SLO',
}),
children: showDescriptionSection ? <SloEditFormDescriptionSection /> : null,
status:
showDescriptionSection && isDescriptionSectionValid ? 'complete' : 'incomplete',
},
]}
/>
<EuiSpacer size="m" />
<SloEditFormFooter slo={slo} onSave={onSave} />
</EuiFlexGroup>
</FormProvider>
</>
<SloEditFormFooter slo={slo} onSave={onSave} />
</EuiFlexGroup>
</FormProvider>
);
}

View file

@ -9,8 +9,6 @@ import {
EuiComboBox,
EuiComboBoxOptionOption,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiPanel,
EuiTextArea,
@ -20,9 +18,9 @@ import { i18n } from '@kbn/i18n';
import React from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { useFetchSLOSuggestions } from '../hooks/use_fetch_suggestions';
import { OptionalText } from './common/optional_text';
import { CreateSLOForm } from '../types';
import { maxWidth } from './slo_edit_form';
import { OptionalText } from './common/optional_text';
import { MAX_WIDTH } from '../constants';
export function SloEditFormDescriptionSection() {
const { control, getFieldState } = useFormContext<CreateSLOForm>();
@ -37,129 +35,117 @@ export function SloEditFormDescriptionSection() {
hasBorder={false}
hasShadow={false}
paddingSize="none"
style={{ maxWidth }}
style={{ maxWidth: MAX_WIDTH }}
data-test-subj="sloEditFormDescriptionSection"
>
<EuiFlexGroup direction="column" gutterSize="l">
<EuiFlexItem>
<EuiFormRow
fullWidth
isInvalid={getFieldState('name').invalid}
label={i18n.translate('xpack.slo.sloEdit.description.sloName', {
defaultMessage: 'SLO Name',
})}
>
<Controller
name="name"
control={control}
rules={{ required: true }}
render={({ field: { ref, ...field }, fieldState }) => (
<EuiFieldText
{...field}
fullWidth
isInvalid={fieldState.invalid}
id={sloNameId}
data-test-subj="sloFormNameInput"
placeholder={i18n.translate('xpack.slo.sloEdit.description.sloNamePlaceholder', {
defaultMessage: 'Name for the SLO',
})}
/>
<EuiFormRow
fullWidth
isInvalid={getFieldState('name').invalid}
label={i18n.translate('xpack.slo.sloEdit.description.sloName', {
defaultMessage: 'SLO Name',
})}
>
<Controller
name="name"
control={control}
rules={{ required: true }}
render={({ field: { ref, ...field }, fieldState }) => (
<EuiFieldText
{...field}
fullWidth
isInvalid={fieldState.invalid}
id={sloNameId}
data-test-subj="sloFormNameInput"
placeholder={i18n.translate('xpack.slo.sloEdit.description.sloNamePlaceholder', {
defaultMessage: 'Name for the SLO',
})}
/>
)}
/>
</EuiFormRow>
<EuiFormRow
fullWidth
label={i18n.translate('xpack.slo.sloEdit.description.sloDescription', {
defaultMessage: 'Description',
})}
labelAppend={<OptionalText />}
>
<Controller
name="description"
defaultValue=""
control={control}
rules={{ required: false }}
render={({ field: { ref, ...field } }) => (
<EuiTextArea
{...field}
fullWidth
id={descriptionId}
data-test-subj="sloFormDescriptionTextArea"
placeholder={i18n.translate(
'xpack.slo.sloEdit.description.sloDescriptionPlaceholder',
{
defaultMessage: 'A short description of the SLO',
}
)}
/>
</EuiFormRow>
</EuiFlexItem>
)}
/>
</EuiFormRow>
<EuiFlexItem grow>
<EuiFormRow
fullWidth
label={i18n.translate('xpack.slo.sloEdit.description.sloDescription', {
defaultMessage: 'Description',
})}
labelAppend={<OptionalText />}
>
<Controller
name="description"
defaultValue=""
control={control}
rules={{ required: false }}
render={({ field: { ref, ...field } }) => (
<EuiTextArea
{...field}
fullWidth
id={descriptionId}
data-test-subj="sloFormDescriptionTextArea"
placeholder={i18n.translate(
'xpack.slo.sloEdit.description.sloDescriptionPlaceholder',
{
defaultMessage: 'A short description of the SLO',
}
)}
/>
)}
<EuiFormRow
fullWidth
label={i18n.translate('xpack.slo.sloEdit.tags.label', {
defaultMessage: 'Tags',
})}
>
<Controller
name="tags"
control={control}
defaultValue={[]}
rules={{ required: false }}
render={({ field: { ref, ...field }, fieldState }) => (
<EuiComboBox
{...field}
id={tagsId}
fullWidth
aria-label={i18n.translate('xpack.slo.sloEdit.tags.placeholder', {
defaultMessage: 'Add tags',
})}
placeholder={i18n.translate('xpack.slo.sloEdit.tags.placeholder', {
defaultMessage: 'Add tags',
})}
isInvalid={fieldState.invalid}
options={suggestions?.tags ?? []}
selectedOptions={generateTagOptions(field.value)}
onChange={(selected: EuiComboBoxOptionOption[]) => {
if (selected.length) {
return field.onChange(selected.map((opts) => opts.value));
}
field.onChange([]);
}}
onCreateOption={(searchValue: string, options: EuiComboBoxOptionOption[] = []) => {
const normalizedSearchValue = searchValue.trim().toLowerCase();
if (!normalizedSearchValue) {
return;
}
const values = field.value ?? [];
if (
values.findIndex((tag) => tag.trim().toLowerCase() === normalizedSearchValue) ===
-1
) {
field.onChange([...values, searchValue]);
}
}}
isClearable
data-test-subj="sloEditTagsSelector"
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow>
<EuiFormRow
fullWidth
label={i18n.translate('xpack.slo.sloEdit.tags.label', {
defaultMessage: 'Tags',
})}
>
<Controller
name="tags"
control={control}
defaultValue={[]}
rules={{ required: false }}
render={({ field: { ref, ...field }, fieldState }) => (
<EuiComboBox
{...field}
id={tagsId}
fullWidth
aria-label={i18n.translate('xpack.slo.sloEdit.tags.placeholder', {
defaultMessage: 'Add tags',
})}
placeholder={i18n.translate('xpack.slo.sloEdit.tags.placeholder', {
defaultMessage: 'Add tags',
})}
isInvalid={fieldState.invalid}
options={suggestions?.tags ?? []}
selectedOptions={generateTagOptions(field.value)}
onChange={(selected: EuiComboBoxOptionOption[]) => {
if (selected.length) {
return field.onChange(selected.map((opts) => opts.value));
}
field.onChange([]);
}}
onCreateOption={(
searchValue: string,
options: EuiComboBoxOptionOption[] = []
) => {
const normalizedSearchValue = searchValue.trim().toLowerCase();
if (!normalizedSearchValue) {
return;
}
const values = field.value ?? [];
if (
values.findIndex(
(tag) => tag.trim().toLowerCase() === normalizedSearchValue
) === -1
) {
field.onChange([...values, searchValue]);
}
}}
isClearable
data-test-subj="sloEditTagsSelector"
/>
)}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
)}
/>
</EuiFormRow>
</EuiPanel>
);
}

View file

@ -7,19 +7,20 @@
import { EuiFormRow, EuiPanel, EuiSelect, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { assertNever } from '@kbn/std';
import React, { useMemo } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { SLI_OPTIONS } from '../constants';
import { useUnregisterFields } from '../hooks/use_unregister_fields';
import { CreateSLOForm } from '../types';
import { ApmAvailabilityIndicatorTypeForm } from './apm_availability/apm_availability_indicator_type_form';
import { ApmLatencyIndicatorTypeForm } from './apm_latency/apm_latency_indicator_type_form';
import { SyntheticsAvailabilityIndicatorTypeForm } from './synthetics_availability/synthetics_availability_indicator_type_form';
import { CustomKqlIndicatorTypeForm } from './custom_kql/custom_kql_indicator_type_form';
import { CustomMetricIndicatorTypeForm } from './custom_metric/custom_metric_type_form';
import { HistogramIndicatorTypeForm } from './histogram/histogram_indicator_type_form';
import { maxWidth } from './slo_edit_form';
import { TimesliceMetricIndicatorTypeForm } from './timeslice_metric/timeslice_metric_indicator';
import { MAX_WIDTH } from '../constants';
import { ApmAvailabilityIndicatorTypeForm } from './indicator_section/apm_availability/apm_availability_indicator_type_form';
import { ApmLatencyIndicatorTypeForm } from './indicator_section/apm_latency/apm_latency_indicator_type_form';
import { CustomKqlIndicatorTypeForm } from './indicator_section/custom_kql/custom_kql_indicator_type_form';
import { CustomMetricIndicatorTypeForm } from './indicator_section/custom_metric/custom_metric_type_form';
import { HistogramIndicatorTypeForm } from './indicator_section/histogram/histogram_indicator_type_form';
import { SyntheticsAvailabilityIndicatorTypeForm } from './indicator_section/synthetics_availability/synthetics_availability_indicator_type_form';
import { TimesliceMetricIndicatorTypeForm } from './indicator_section/timeslice_metric/timeslice_metric_indicator';
interface SloEditFormIndicatorSectionProps {
isEditMode: boolean;
@ -48,7 +49,7 @@ export function SloEditFormIndicatorSection({ isEditMode }: SloEditFormIndicator
case 'sli.metric.timeslice':
return <TimesliceMetricIndicatorTypeForm />;
default:
return null;
assertNever(indicatorType);
}
}, [indicatorType]);
@ -57,7 +58,7 @@ export function SloEditFormIndicatorSection({ isEditMode }: SloEditFormIndicator
hasBorder={false}
hasShadow={false}
paddingSize="none"
style={{ maxWidth }}
style={{ maxWidth: MAX_WIDTH }}
data-test-subj="sloEditFormIndicatorSection"
>
{!isEditMode && (
@ -78,7 +79,7 @@ export function SloEditFormIndicatorSection({ isEditMode }: SloEditFormIndicator
)}
/>
</EuiFormRow>
<EuiSpacer size="xxl" />
<EuiSpacer size="xl" />
</>
)}
{indicatorTypeForm}

View file

@ -7,15 +7,14 @@
import {
EuiCallOut,
EuiCheckbox,
EuiFieldNumber,
EuiFlexGrid,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiIconTip,
EuiPanel,
EuiSelect,
EuiSpacer,
useGeneratedHtmlId,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
@ -30,7 +29,8 @@ import {
TIMEWINDOW_TYPE_OPTIONS,
} from '../constants';
import { CreateSLOForm } from '../types';
import { maxWidth } from './slo_edit_form';
import { MAX_WIDTH } from '../constants';
import { AdvancedSettings } from './indicator_section/advanced_settings/advanced_settings';
import { SloEditFormObjectiveSectionTimeslices } from './slo_edit_form_objective_section_timeslices';
export function SloEditFormObjectiveSection() {
@ -44,7 +44,6 @@ export function SloEditFormObjectiveSection() {
const budgetingSelect = useGeneratedHtmlId({ prefix: 'budgetingSelect' });
const timeWindowTypeSelect = useGeneratedHtmlId({ prefix: 'timeWindowTypeSelect' });
const timeWindowSelect = useGeneratedHtmlId({ prefix: 'timeWindowSelect' });
const preventBackfillCheckbox = useGeneratedHtmlId({ prefix: 'preventBackfill' });
const timeWindowType = watch('timeWindow.type');
const indicator = watch('indicator.type');
@ -91,237 +90,199 @@ export function SloEditFormObjectiveSection() {
hasBorder={false}
hasShadow={false}
paddingSize="none"
style={{ maxWidth }}
style={{ maxWidth: MAX_WIDTH }}
data-test-subj="sloEditFormObjectiveSection"
>
<EuiFlexGrid columns={3}>
<EuiFlexItem>
<EuiFormRow
label={
<span>
{i18n.translate('xpack.slo.sloEdit.timeWindowType.label', {
defaultMessage: 'Time window',
})}{' '}
<EuiIconTip
content={i18n.translate('xpack.slo.sloEdit.timeWindowType.tooltip', {
defaultMessage: 'Choose between a rolling or a calendar aligned window.',
})}
position="top"
/>
</span>
}
>
<Controller
name="timeWindow.type"
control={control}
rules={{ required: true }}
render={({ field: { ref, ...field } }) => (
<EuiSelect
{...field}
required
id={timeWindowTypeSelect}
data-test-subj="sloFormTimeWindowTypeSelect"
options={TIMEWINDOW_TYPE_OPTIONS}
value={field.value}
/>
)}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
label={
<span>
{i18n.translate('xpack.slo.sloEdit.timeWindowDuration.label', {
defaultMessage: 'Duration',
})}{' '}
<EuiIconTip
content={i18n.translate('xpack.slo.sloEdit.timeWindowDuration.tooltip', {
defaultMessage: 'The time window duration used to compute the SLO over.',
})}
position="top"
/>
</span>
}
>
<Controller
name="timeWindow.duration"
control={control}
rules={{ required: true }}
render={({ field: { ref, ...field } }) => (
<EuiSelect
{...field}
required
id={timeWindowSelect}
data-test-subj="sloFormTimeWindowDurationSelect"
options={
timeWindowType === 'calendarAligned'
? CALENDARALIGNED_TIMEWINDOW_OPTIONS
: ROLLING_TIMEWINDOW_OPTIONS
}
value={field.value}
/>
)}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGrid>
<EuiSpacer size="l" />
{indicator === 'sli.metric.timeslice' && (
<EuiFlexItem>
<EuiCallOut color="warning">
<p>
<FormattedMessage
id="xpack.slo.sloEdit.sliType.timesliceMetric.objectiveMessage"
defaultMessage="The timeslice metric requires the budgeting method to be set to 'Timeslices' due to the nature of the statistical aggregations. The 'timeslice target' is also ignored in favor of the 'threshold' set in the metric definition above. The 'timeslice window' will set the size of the window the aggregation is performed on."
<EuiFlexGroup direction="column" gutterSize="m">
<EuiFlexGrid columns={3} gutterSize="m">
<EuiFlexItem>
<EuiFormRow
label={
<span>
{i18n.translate('xpack.slo.sloEdit.timeWindowType.label', {
defaultMessage: 'Time window',
})}{' '}
<EuiIconTip
content={i18n.translate('xpack.slo.sloEdit.timeWindowType.tooltip', {
defaultMessage: 'Choose between a rolling or a calendar aligned window.',
})}
position="top"
/>
</span>
}
>
<Controller
name="timeWindow.type"
control={control}
rules={{ required: true }}
render={({ field: { ref, ...field } }) => (
<EuiSelect
{...field}
required
id={timeWindowTypeSelect}
data-test-subj="sloFormTimeWindowTypeSelect"
options={TIMEWINDOW_TYPE_OPTIONS}
value={field.value}
/>
)}
/>
</p>
</EuiCallOut>
<EuiSpacer size="l" />
</EuiFlexItem>
)}
{indicator === 'sli.synthetics.availability' && (
<EuiFlexItem>
<EuiCallOut color="warning">
<p>
<FormattedMessage
id="xpack.slo.sloEdit.sliType.syntheticAvailability.objectiveMessage"
defaultMessage="The Synthetics availability indicator requires the budgeting method to be set to 'Occurrences'."
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
label={
<span>
{i18n.translate('xpack.slo.sloEdit.timeWindowDuration.label', {
defaultMessage: 'Duration',
})}{' '}
<EuiIconTip
content={i18n.translate('xpack.slo.sloEdit.timeWindowDuration.tooltip', {
defaultMessage: 'The time window duration used to compute the SLO over.',
})}
position="top"
/>
</span>
}
>
<Controller
name="timeWindow.duration"
control={control}
rules={{ required: true }}
render={({ field: { ref, ...field } }) => (
<EuiSelect
{...field}
required
id={timeWindowSelect}
data-test-subj="sloFormTimeWindowDurationSelect"
options={
timeWindowType === 'calendarAligned'
? CALENDARALIGNED_TIMEWINDOW_OPTIONS
: ROLLING_TIMEWINDOW_OPTIONS
}
value={field.value}
/>
)}
/>
</p>
</EuiCallOut>
<EuiSpacer size="l" />
</EuiFlexItem>
)}
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGrid>
<EuiFlexGrid columns={3}>
<EuiFlexItem>
<EuiFormRow
label={
<span>
{i18n.translate('xpack.slo.sloEdit.budgetingMethod.label', {
defaultMessage: 'Budgeting method',
})}{' '}
<EuiIconTip
content={i18n.translate('xpack.slo.sloEdit.budgetingMethod.tooltip', {
defaultMessage:
'Occurrences-based SLO uses the ratio of good events over the total events during the time window. Timeslices-based SLO uses the ratio of good time slices over the total time slices during the time window.',
})}
position="top"
{indicator === 'sli.metric.timeslice' && (
<EuiFlexItem>
<EuiCallOut color="warning">
<p>
<FormattedMessage
id="xpack.slo.sloEdit.sliType.timesliceMetric.objectiveMessage"
defaultMessage="The timeslice metric requires the budgeting method to be set to 'Timeslices' due to the nature of the statistical aggregations. The 'timeslice target' is also ignored in favor of the 'threshold' set in the metric definition above. The 'timeslice window' will set the size of the window the aggregation is performed on."
/>
</span>
}
>
<Controller
name="budgetingMethod"
control={control}
rules={{ required: true }}
render={({ field: { ref, ...field } }) => (
<EuiSelect
{...field}
disabled={
indicator === 'sli.metric.timeslice' ||
indicator === 'sli.synthetics.availability'
}
required
id={budgetingSelect}
data-test-subj="sloFormBudgetingMethodSelect"
options={BUDGETING_METHOD_OPTIONS}
</p>
</EuiCallOut>
</EuiFlexItem>
)}
{indicator === 'sli.synthetics.availability' && (
<EuiFlexItem>
<EuiCallOut color="warning">
<p>
<FormattedMessage
id="xpack.slo.sloEdit.sliType.syntheticAvailability.objectiveMessage"
defaultMessage="The Synthetics availability indicator requires the budgeting method to be set to 'Occurrences'."
/>
)}
/>
</EuiFormRow>
</EuiFlexItem>
</p>
</EuiCallOut>
</EuiFlexItem>
)}
{watch('budgetingMethod') === 'timeslices' ? (
<SloEditFormObjectiveSectionTimeslices />
) : null}
</EuiFlexGrid>
<EuiFlexGrid columns={3} gutterSize="m">
<EuiFlexItem>
<EuiFormRow
label={
<span>
{i18n.translate('xpack.slo.sloEdit.budgetingMethod.label', {
defaultMessage: 'Budgeting method',
})}{' '}
<EuiIconTip
content={i18n.translate('xpack.slo.sloEdit.budgetingMethod.tooltip', {
defaultMessage:
'Occurrences-based SLO uses the ratio of good events over the total events during the time window. Timeslices-based SLO uses the ratio of good time slices over the total time slices during the time window.',
})}
position="top"
/>
</span>
}
>
<Controller
name="budgetingMethod"
control={control}
rules={{ required: true }}
render={({ field: { ref, ...field } }) => (
<EuiSelect
{...field}
disabled={
indicator === 'sli.metric.timeslice' ||
indicator === 'sli.synthetics.availability'
}
required
id={budgetingSelect}
data-test-subj="sloFormBudgetingMethodSelect"
options={BUDGETING_METHOD_OPTIONS}
/>
)}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiSpacer size="l" />
{watch('budgetingMethod') === 'timeslices' ? (
<SloEditFormObjectiveSectionTimeslices />
) : null}
</EuiFlexGrid>
<EuiFlexGrid columns={3}>
<EuiFlexItem>
<EuiFormRow
isInvalid={getFieldState('objective.target').invalid}
label={
<span>
{i18n.translate('xpack.slo.sloEdit.targetSlo.label', {
defaultMessage: 'Target / SLO (%)',
})}{' '}
<EuiIconTip
content={i18n.translate('xpack.slo.sloEdit.targetSlo.tooltip', {
defaultMessage: 'The target objective in percentage for the SLO.',
})}
position="top"
/>
</span>
}
>
<Controller
name="objective.target"
control={control}
rules={{
required: true,
min: 0.001,
max: 99.999,
}}
render={({ field: { ref, onChange, ...field }, fieldState }) => (
<EuiFieldNumber
{...field}
required
isInvalid={fieldState.invalid}
data-test-subj="sloFormObjectiveTargetInput"
value={field.value}
min={0.001}
max={99.999}
step={0.001}
onChange={(event) => onChange(event.target.value)}
/>
)}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGrid>
<EuiFlexGrid columns={3} gutterSize="m">
<EuiFlexItem>
<EuiFormRow
isInvalid={getFieldState('objective.target').invalid}
label={
<span>
{i18n.translate('xpack.slo.sloEdit.targetSlo.label', {
defaultMessage: 'Target / SLO (%)',
})}{' '}
<EuiIconTip
content={i18n.translate('xpack.slo.sloEdit.targetSlo.tooltip', {
defaultMessage: 'The target objective in percentage for the SLO.',
})}
position="top"
/>
</span>
}
>
<Controller
name="objective.target"
control={control}
rules={{
required: true,
min: 0.001,
max: 99.999,
}}
render={({ field: { ref, onChange, ...field }, fieldState }) => (
<EuiFieldNumber
{...field}
required
isInvalid={fieldState.invalid}
data-test-subj="sloFormObjectiveTargetInput"
value={field.value}
min={0.001}
max={99.999}
step={0.001}
onChange={(event) => onChange(event.target.value)}
/>
)}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGrid>
<EuiSpacer size="l" />
<EuiFlexGrid columns={3}>
<EuiFlexItem>
<EuiFormRow isInvalid={getFieldState('settings.preventInitialBackfill').invalid}>
<Controller
name="settings.preventInitialBackfill"
control={control}
render={({ field: { ref, onChange, ...field } }) => (
<EuiCheckbox
id={preventBackfillCheckbox}
label={
<span>
{i18n.translate('xpack.slo.sloEdit.settings.preventInitialBackfill.label', {
defaultMessage: 'Prevent initial backfill of data',
})}
<EuiIconTip
content={i18n.translate(
'xpack.slo.sloEdit.settings.preventInitialBackfill.tooltip',
{
defaultMessage:
'Start aggregating data from the time the SLO is created, instead of backfilling data from the beginning of the time window.',
}
)}
position="top"
/>
</span>
}
checked={Boolean(field.value)}
onChange={(event: any) => onChange(event.target.checked)}
/>
)}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGrid>
<AdvancedSettings />
</EuiFlexGroup>
</EuiPanel>
);
}

View file

@ -33,6 +33,8 @@ import {
import { SYNTHETICS_DEFAULT_GROUPINGS, SYNTHETICS_INDEX_PATTERN } from '../../../common/constants';
import { CreateSLOForm } from './types';
export const MAX_WIDTH = 900;
export const SLI_OPTIONS: Array<{
value: IndicatorType;
text: string;
@ -205,6 +207,13 @@ export const SYNTHETICS_AVAILABILITY_DEFAULT_VALUES: SyntheticsAvailabilityIndic
},
};
export const SETTINGS_DEFAULT_VALUES = {
frequency: 1,
preventInitialBackfill: false,
syncDelay: 1,
syncField: null,
};
export const SLO_EDIT_FORM_DEFAULT_VALUES: CreateSLOForm = {
name: '',
description: '',
@ -219,9 +228,7 @@ export const SLO_EDIT_FORM_DEFAULT_VALUES: CreateSLOForm = {
target: 99,
},
groupBy: ALL_VALUE,
settings: {
preventInitialBackfill: false,
},
settings: SETTINGS_DEFAULT_VALUES,
};
export const SLO_EDIT_FORM_DEFAULT_VALUES_CUSTOM_METRIC: CreateSLOForm = {
@ -238,9 +245,7 @@ export const SLO_EDIT_FORM_DEFAULT_VALUES_CUSTOM_METRIC: CreateSLOForm = {
target: 99,
},
groupBy: ALL_VALUE,
settings: {
preventInitialBackfill: false,
},
settings: SETTINGS_DEFAULT_VALUES,
};
export const SLO_EDIT_FORM_DEFAULT_VALUES_SYNTHETICS_AVAILABILITY: CreateSLOForm = {
@ -257,9 +262,7 @@ export const SLO_EDIT_FORM_DEFAULT_VALUES_SYNTHETICS_AVAILABILITY: CreateSLOForm
target: 99,
},
groupBy: SYNTHETICS_DEFAULT_GROUPINGS,
settings: {
preventInitialBackfill: false,
},
settings: SETTINGS_DEFAULT_VALUES,
};
export const COMPARATOR_GT = i18n.translate('xpack.slo.sloEdit.sliType.timesliceMetric.gtLabel', {

View file

@ -26,7 +26,10 @@ Object {
"target": 99,
},
"settings": Object {
"frequency": 1,
"preventInitialBackfill": false,
"syncDelay": 1,
"syncField": null,
},
"tags": Array [],
"timeWindow": Object {
@ -74,7 +77,10 @@ Object {
"target": 99,
},
"settings": Object {
"frequency": 1,
"preventInitialBackfill": false,
"syncDelay": 1,
"syncField": null,
},
"tags": Array [],
"timeWindow": Object {
@ -104,7 +110,10 @@ Object {
"target": 99,
},
"settings": Object {
"frequency": 1,
"preventInitialBackfill": false,
"syncDelay": 1,
"syncField": null,
},
"tags": Array [],
"timeWindow": Object {
@ -146,7 +155,10 @@ Object {
"target": 99,
},
"settings": Object {
"frequency": 1,
"preventInitialBackfill": false,
"syncDelay": 1,
"syncField": null,
},
"tags": Array [],
"timeWindow": Object {
@ -178,7 +190,10 @@ Object {
"timesliceWindow": "2",
},
"settings": Object {
"frequency": 1,
"preventInitialBackfill": false,
"syncDelay": 1,
"syncField": null,
},
"tags": Array [],
"timeWindow": Object {
@ -208,7 +223,10 @@ Object {
"target": 99,
},
"settings": Object {
"frequency": 1,
"preventInitialBackfill": false,
"syncDelay": 1,
"syncField": null,
},
"tags": Array [],
"timeWindow": Object {
@ -218,6 +236,105 @@ Object {
}
`;
exports[`Transform partial URL state into form state settings handles optional 'syncField' URL state 1`] = `
Object {
"budgetingMethod": "occurrences",
"description": "",
"groupBy": "*",
"indicator": Object {
"params": Object {
"filter": "",
"good": "",
"index": "",
"timestampField": "",
"total": "",
},
"type": "sli.kql.custom",
},
"name": "",
"objective": Object {
"target": 99,
},
"settings": Object {
"frequency": 1,
"preventInitialBackfill": false,
"syncDelay": 1,
"syncField": "override-field",
},
"tags": Array [],
"timeWindow": Object {
"duration": "30d",
"type": "rolling",
},
}
`;
exports[`Transform partial URL state into form state settings handles partial 'settings' URL state 1`] = `
Object {
"budgetingMethod": "occurrences",
"description": "",
"groupBy": "*",
"indicator": Object {
"params": Object {
"filter": "",
"good": "",
"index": "",
"timestampField": "",
"total": "",
},
"type": "sli.kql.custom",
},
"name": "",
"objective": Object {
"target": 99,
},
"settings": Object {
"frequency": 1,
"preventInitialBackfill": false,
"syncDelay": 12,
"syncField": null,
},
"tags": Array [],
"timeWindow": Object {
"duration": "30d",
"type": "rolling",
},
}
`;
exports[`Transform partial URL state into form state settings handles the 'settings' URL state 1`] = `
Object {
"budgetingMethod": "occurrences",
"description": "",
"groupBy": "*",
"indicator": Object {
"params": Object {
"filter": "",
"good": "",
"index": "",
"timestampField": "",
"total": "",
},
"type": "sli.kql.custom",
},
"name": "",
"objective": Object {
"target": 99,
},
"settings": Object {
"frequency": 1,
"preventInitialBackfill": true,
"syncDelay": 180,
"syncField": null,
},
"tags": Array [],
"timeWindow": Object {
"duration": "30d",
"type": "rolling",
},
}
`;
exports[`Transform partial URL state into form state with 'indicator' in URL state handles partial APM Availability state 1`] = `
Object {
"budgetingMethod": "occurrences",
@ -239,7 +356,10 @@ Object {
"target": 99,
},
"settings": Object {
"frequency": 1,
"preventInitialBackfill": false,
"syncDelay": 1,
"syncField": null,
},
"tags": Array [],
"timeWindow": Object {
@ -271,7 +391,10 @@ Object {
"target": 99,
},
"settings": Object {
"frequency": 1,
"preventInitialBackfill": false,
"syncDelay": 1,
"syncField": null,
},
"tags": Array [],
"timeWindow": Object {
@ -301,7 +424,10 @@ Object {
"target": 99,
},
"settings": Object {
"frequency": 1,
"preventInitialBackfill": false,
"syncDelay": 1,
"syncField": null,
},
"tags": Array [],
"timeWindow": Object {
@ -331,7 +457,10 @@ Object {
"target": 99,
},
"settings": Object {
"frequency": 1,
"preventInitialBackfill": false,
"syncDelay": 1,
"syncField": null,
},
"tags": Array [],
"timeWindow": Object {

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { getGroupByCardinalityFilters } from '../components/synthetics_availability/synthetics_availability_indicator_type_form';
import { getGroupByCardinalityFilters } from '../components/indicator_section/synthetics_availability/synthetics_availability_indicator_type_form';
import { formatAllFilters } from './format_filters';
describe('formatAllFilters', () => {

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { transformPartialUrlStateToFormState as transform } from './process_slo_form_values';
import { transformPartialSLOStateToFormState as transform } from './process_slo_form_values';
describe('Transform partial URL state into form state', () => {
describe("with 'indicator' in URL state", () => {
@ -121,4 +121,20 @@ describe('Transform partial URL state into form state', () => {
})
).toMatchSnapshot();
});
describe('settings', () => {
it("handles the 'settings' URL state", () => {
expect(
transform({ settings: { preventInitialBackfill: true, syncDelay: '3h' } })
).toMatchSnapshot();
});
it("handles partial 'settings' URL state", () => {
expect(transform({ settings: { syncDelay: '12m' } })).toMatchSnapshot();
});
it("handles optional 'syncField' URL state", () => {
expect(transform({ settings: { syncField: 'override-field' } })).toMatchSnapshot();
});
});
});

View file

@ -9,13 +9,14 @@ import { CreateSLOInput, GetSLOResponse, Indicator, UpdateSLOInput } from '@kbn/
import { assertNever } from '@kbn/std';
import { RecursivePartial } from '@kbn/utility-types';
import { cloneDeep } from 'lodash';
import { toDuration } from '../../../utils/slo/duration';
import { toDuration, toMinutes } from '../../../utils/slo/duration';
import {
APM_AVAILABILITY_DEFAULT_VALUES,
APM_LATENCY_DEFAULT_VALUES,
CUSTOM_KQL_DEFAULT_VALUES,
CUSTOM_METRIC_DEFAULT_VALUES,
HISTOGRAM_DEFAULT_VALUES,
SETTINGS_DEFAULT_VALUES,
SLO_EDIT_FORM_DEFAULT_VALUES,
SLO_EDIT_FORM_DEFAULT_VALUES_SYNTHETICS_AVAILABILITY,
SYNTHETICS_AVAILABILITY_DEFAULT_VALUES,
@ -52,6 +53,13 @@ export function transformSloResponseToCreateSloForm(
tags: values.tags,
settings: {
preventInitialBackfill: values.settings?.preventInitialBackfill ?? false,
syncDelay: values.settings?.syncDelay
? toMinutes(toDuration(values.settings.syncDelay))
: SETTINGS_DEFAULT_VALUES.syncDelay,
frequency: values.settings?.frequency
? toMinutes(toDuration(values.settings.frequency))
: SETTINGS_DEFAULT_VALUES.frequency,
syncField: values.settings?.syncField ?? null,
},
};
}
@ -80,7 +88,10 @@ export function transformCreateSLOFormToCreateSLOInput(values: CreateSLOForm): C
tags: values.tags,
groupBy: [values.groupBy].flat(),
settings: {
preventInitialBackfill: values.settings?.preventInitialBackfill ?? false,
preventInitialBackfill: values.settings.preventInitialBackfill,
syncDelay: `${values.settings.syncDelay ?? SETTINGS_DEFAULT_VALUES.syncDelay}m`,
frequency: `${values.settings.frequency ?? SETTINGS_DEFAULT_VALUES.frequency}m`,
syncField: values.settings.syncField,
},
};
}
@ -109,7 +120,10 @@ export function transformValuesToUpdateSLOInput(values: CreateSLOForm): UpdateSL
tags: values.tags,
groupBy: [values.groupBy].flat(),
settings: {
preventInitialBackfill: values.settings?.preventInitialBackfill ?? false,
preventInitialBackfill: values.settings.preventInitialBackfill,
syncDelay: `${values.settings.syncDelay ?? SETTINGS_DEFAULT_VALUES.syncDelay}m`,
frequency: `${values.settings.frequency ?? SETTINGS_DEFAULT_VALUES.frequency}m`,
syncField: values.settings.syncField,
},
};
}
@ -165,7 +179,7 @@ function transformPartialIndicatorState(
}
}
export function transformPartialUrlStateToFormState(
export function transformPartialSLOStateToFormState(
values: RecursivePartial<CreateSLOInput>
): CreateSLOForm {
let state: CreateSLOForm;
@ -189,8 +203,8 @@ export function transformPartialUrlStateToFormState(
if (values.description) {
state.description = values.description;
}
if (!!values.tags) {
state.tags = values.tags as string[];
if (values.tags) {
state.tags = [values.tags].flat().filter((tag) => !!tag) as string[];
}
if (values.objective) {
@ -220,8 +234,19 @@ export function transformPartialUrlStateToFormState(
state.timeWindow = { duration: values.timeWindow.duration, type: values.timeWindow.type };
}
if (!!values.settings?.preventInitialBackfill) {
state.settings = { preventInitialBackfill: values.settings.preventInitialBackfill };
if (!!values.settings) {
if (values.settings.preventInitialBackfill) {
state.settings.preventInitialBackfill = values.settings.preventInitialBackfill;
}
if (values.settings.syncDelay) {
state.settings.syncDelay = toMinutes(toDuration(values.settings.syncDelay));
}
if (values.settings.frequency) {
state.settings.frequency = toMinutes(toDuration(values.settings.frequency));
}
if (values.settings.syncField) {
state.settings.syncField = values.settings.syncField;
}
}
return state;

View file

@ -10,7 +10,7 @@ import { CreateSLOInput } from '@kbn/slo-schema';
import { RecursivePartial } from '@kbn/utility-types';
import { useHistory } from 'react-router-dom';
import { useMemo } from 'react';
import { transformPartialUrlStateToFormState } from '../helpers/process_slo_form_values';
import { transformPartialSLOStateToFormState } from '../helpers/process_slo_form_values';
import { CreateSLOForm } from '../types';
export function useParseUrlState(): CreateSLOForm | undefined {
@ -25,6 +25,6 @@ export function useParseUrlState(): CreateSLOForm | undefined {
const urlState = urlStateStorage.get<RecursivePartial<CreateSLOInput>>('_a');
return !!urlState ? transformPartialUrlStateToFormState(urlState) : undefined;
return !!urlState ? transformPartialSLOStateToFormState(urlState) : undefined;
}, [history]);
}

View file

@ -220,8 +220,10 @@ export function useSectionFormValidation({ getFieldState, getValues, formState,
'objective.target',
'objective.timesliceTarget',
'objective.timesliceWindow',
'settings.syncDelay',
'settings.frequency',
] as const
).every((field) => getFieldState(field).error === undefined);
).every((field) => !getFieldState(field).invalid);
const isDescriptionSectionValid =
!getFieldState('name').invalid &&

View file

@ -19,8 +19,8 @@ import {
CUSTOM_METRIC_DEFAULT_VALUES,
HISTOGRAM_DEFAULT_VALUES,
SLO_EDIT_FORM_DEFAULT_VALUES,
TIMESLICE_METRIC_DEFAULT_VALUES,
SLO_EDIT_FORM_DEFAULT_VALUES_SYNTHETICS_AVAILABILITY,
TIMESLICE_METRIC_DEFAULT_VALUES,
} from '../constants';
import { CreateSLOForm } from '../types';

View file

@ -7,12 +7,11 @@
import { EuiFlyout, EuiFlyoutBody, EuiFlyoutFooter, EuiFlyoutHeader, EuiTitle } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { CreateSLOInput } from '@kbn/slo-schema';
import { RecursivePartial } from '@kbn/utility-types';
import { merge } from 'lodash';
import React from 'react';
import { OutPortal, createHtmlPortalNode } from 'react-reverse-portal';
import { SloEditForm } from '../components/slo_edit_form';
import { CreateSLOForm } from '../types';
export const sloEditFormFooterPortal = createHtmlPortalNode();
@ -22,7 +21,7 @@ export default function SloAddFormFlyout({
initialValues,
}: {
onClose: () => void;
initialValues?: RecursivePartial<CreateSLOForm>;
initialValues?: RecursivePartial<CreateSLOInput>;
}) {
return (
<EuiFlyout
@ -40,29 +39,7 @@ export default function SloAddFormFlyout({
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<SloEditForm
onSave={onClose}
initialValues={
initialValues
? merge(
{
indicator: {
type: 'sli.kql.custom',
},
objective: {
target: 99,
},
timeWindow: {
duration: '30d',
type: 'rolling',
},
budgetingMethod: 'occurrences',
},
{ ...initialValues }
)
: undefined
}
/>
<SloEditForm onSave={onClose} initialValues={initialValues} />
</EuiFlyoutBody>
<EuiFlyoutFooter>
<OutPortal node={sloEditFormFooterPortal} />

View file

@ -423,11 +423,11 @@ describe('SLO Edit Page', () => {
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
history.push(
'/slos/123/edit?_a=(name:%27updated-name%27,indicator:(params:(environment:prod,service:cartService),type:sli.apm.transactionDuration),objective:(target:0.92))'
'/slos/edit/123?_a=(name:%27updated-name%27,indicator:(params:(environment:prod,service:cartService),type:sli.apm.transactionDuration),objective:(target:0.92))'
);
jest
.spyOn(Router, 'useLocation')
.mockReturnValue({ pathname: '/slos/123/edit', search: '', state: '', hash: '' });
.mockReturnValue({ pathname: '/slos/edit/123', search: '', state: '', hash: '' });
useFetchSloMock.mockReturnValue({ isLoading: false, data: slo });
@ -463,8 +463,7 @@ describe('SLO Edit Page', () => {
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
jest
.spyOn(Router, 'useLocation')
.mockReturnValue({ pathname: '/slos/123/edit', search: '', state: '', hash: '' });
.mockReturnValue({ pathname: '/slos/edit/123', search: '', state: '', hash: '' });
useFetchSloMock.mockReturnValue({ isLoading: false, data: slo });
const { getByTestId } = render(<SloEditPage />);

View file

@ -12,10 +12,10 @@ import { useParams } from 'react-router-dom';
import { paths } from '../../../common/locators/paths';
import { HeaderMenu } from '../../components/header_menu/header_menu';
import { useFetchSloDetails } from '../../hooks/use_fetch_slo_details';
import { useKibana } from '../../hooks/use_kibana';
import { useLicense } from '../../hooks/use_license';
import { usePermissions } from '../../hooks/use_permissions';
import { usePluginContext } from '../../hooks/use_plugin_context';
import { useKibana } from '../../hooks/use_kibana';
import { SloEditForm } from './components/slo_edit_form';
export function SloEditPage() {

View file

@ -25,5 +25,8 @@ export interface CreateSLOForm<IndicatorType = Indicator> {
groupBy: string[] | string;
settings: {
preventInitialBackfill: boolean;
syncDelay: number; // in minutes
frequency: number; // in minutes
syncField: string | null;
};
}

View file

@ -51,7 +51,7 @@ describe('remote SLO URLs Utils', () => {
`"https://cloud.elast.co/app/slos/edit/fixed-id"`
);
expect(createRemoteSloCloneUrl(remoteSlo)).toMatchInlineSnapshot(
`"https://cloud.elast.co/app/slos/create?_a=(budgetingMethod:occurrences,createdAt:%272022-12-29T10:11:12.000Z%27,description:%27some%20description%20useful%27,enabled:!t,groupBy:%27*%27,groupings:(),indicator:(params:(filter:%27baz:%20foo%20and%20bar%20%3E%202%27,good:%27http_status:%202xx%27,index:some-index,timestampField:custom_timestamp,total:%27a%20query%27),type:sli.kql.custom),instanceId:%27*%27,meta:(),name:%27[Copy]%20super%20important%20level%20service%27,objective:(target:0.98),remote:(kibanaUrl:%27https:/cloud.elast.co/kibana%27,remoteName:remote_cluster),revision:1,settings:(frequency:%271m%27,preventInitialBackfill:!f,syncDelay:%271m%27),summary:(errorBudget:(consumed:0.064,initial:0.02,isEstimated:!f,remaining:0.936),fiveMinuteBurnRate:0,oneDayBurnRate:0,oneHourBurnRate:0,sliValue:0.99872,status:HEALTHY),tags:!(k8s,production,critical),timeWindow:(duration:%2730d%27,type:rolling),updatedAt:%272022-12-29T10:11:12.000Z%27,version:2)"`
`"https://cloud.elast.co/app/slos/create?_a=(budgetingMethod:occurrences,createdAt:%272022-12-29T10:11:12.000Z%27,description:%27some%20description%20useful%27,enabled:!t,groupBy:%27*%27,groupings:(),indicator:(params:(dataViewId:some-data-view-id,filter:%27baz:%20foo%20and%20bar%20%3E%202%27,good:%27http_status:%202xx%27,index:some-index,timestampField:custom_timestamp,total:%27a%20query%27),type:sli.kql.custom),instanceId:%27*%27,meta:(),name:%27[Copy]%20super%20important%20level%20service%27,objective:(target:0.98),remote:(kibanaUrl:%27https:/cloud.elast.co/kibana%27,remoteName:remote_cluster),revision:1,settings:(frequency:%271m%27,preventInitialBackfill:!f,syncDelay:%271m%27),summary:(errorBudget:(consumed:0.064,initial:0.02,isEstimated:!f,remaining:0.936),fiveMinuteBurnRate:0,oneDayBurnRate:0,oneHourBurnRate:0,sliValue:0.99872,status:HEALTHY),tags:!(k8s,production,critical),timeWindow:(duration:%2730d%27,type:rolling),updatedAt:%272022-12-29T10:11:12.000Z%27,version:2)"`
);
});
@ -71,7 +71,7 @@ describe('remote SLO URLs Utils', () => {
`"https://cloud.elast.co/s/my-custom-space/app/slos/edit/fixed-id"`
);
expect(createRemoteSloCloneUrl(remoteSlo, 'my-custom-space')).toMatchInlineSnapshot(
`"https://cloud.elast.co/s/my-custom-space/app/slos/create?_a=(budgetingMethod:occurrences,createdAt:%272022-12-29T10:11:12.000Z%27,description:%27some%20description%20useful%27,enabled:!t,groupBy:%27*%27,groupings:(),indicator:(params:(filter:%27baz:%20foo%20and%20bar%20%3E%202%27,good:%27http_status:%202xx%27,index:some-index,timestampField:custom_timestamp,total:%27a%20query%27),type:sli.kql.custom),instanceId:%27*%27,meta:(),name:%27[Copy]%20super%20important%20level%20service%27,objective:(target:0.98),remote:(kibanaUrl:%27https:/cloud.elast.co/kibana%27,remoteName:remote_cluster),revision:1,settings:(frequency:%271m%27,preventInitialBackfill:!f,syncDelay:%271m%27),summary:(errorBudget:(consumed:0.064,initial:0.02,isEstimated:!f,remaining:0.936),fiveMinuteBurnRate:0,oneDayBurnRate:0,oneHourBurnRate:0,sliValue:0.99872,status:HEALTHY),tags:!(k8s,production,critical),timeWindow:(duration:%2730d%27,type:rolling),updatedAt:%272022-12-29T10:11:12.000Z%27,version:2)"`
`"https://cloud.elast.co/s/my-custom-space/app/slos/create?_a=(budgetingMethod:occurrences,createdAt:%272022-12-29T10:11:12.000Z%27,description:%27some%20description%20useful%27,enabled:!t,groupBy:%27*%27,groupings:(),indicator:(params:(dataViewId:some-data-view-id,filter:%27baz:%20foo%20and%20bar%20%3E%202%27,good:%27http_status:%202xx%27,index:some-index,timestampField:custom_timestamp,total:%27a%20query%27),type:sli.kql.custom),instanceId:%27*%27,meta:(),name:%27[Copy]%20super%20important%20level%20service%27,objective:(target:0.98),remote:(kibanaUrl:%27https:/cloud.elast.co/kibana%27,remoteName:remote_cluster),revision:1,settings:(frequency:%271m%27,preventInitialBackfill:!f,syncDelay:%271m%27),summary:(errorBudget:(consumed:0.064,initial:0.02,isEstimated:!f,remaining:0.936),fiveMinuteBurnRate:0,oneDayBurnRate:0,oneHourBurnRate:0,sliValue:0.99872,status:HEALTHY),tags:!(k8s,production,critical),timeWindow:(duration:%2730d%27,type:rolling),updatedAt:%272022-12-29T10:11:12.000Z%27,version:2)"`
);
});
});

View file

@ -9,6 +9,7 @@ import { TransformPutTransformRequest } from '@elastic/elasticsearch/lib/api/typ
import { ElasticsearchClient, IBasePath, IScopedClusterClient, Logger } from '@kbn/core/server';
import { ALL_VALUE, CreateSLOParams, CreateSLOResponse } from '@kbn/slo-schema';
import { asyncForEach } from '@kbn/std';
import { merge } from 'lodash';
import { v4 as uuidv4 } from 'uuid';
import {
SLO_MODEL_VERSION,
@ -46,8 +47,10 @@ export class CreateSLO {
const slo = this.toSLO(params);
validateSLO(slo);
await this.assertSLOInexistant(slo);
await assertExpectedIndicatorSourceIndexPrivileges(slo, this.esClient);
await Promise.all([
this.assertSLOInexistant(slo),
assertExpectedIndicatorSourceIndexPrivileges(slo, this.esClient),
]);
const rollbackOperations = [];
const createPromise = this.repository.create(slo);
@ -201,11 +204,14 @@ export class CreateSLO {
return {
...params,
id: params.id ?? uuidv4(),
settings: {
syncDelay: params.settings?.syncDelay ?? new Duration(1, DurationUnit.Minute),
frequency: params.settings?.frequency ?? new Duration(1, DurationUnit.Minute),
preventInitialBackfill: params.settings?.preventInitialBackfill ?? false,
},
settings: merge(
{
syncDelay: new Duration(1, DurationUnit.Minute),
frequency: new Duration(1, DurationUnit.Minute),
preventInitialBackfill: false,
},
params.settings
),
revision: params.revision ?? 1,
enabled: true,
tags: params.tags ?? [],

View file

@ -9,6 +9,7 @@ import { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
import { Logger } from '@kbn/core/server';
import { ALL_VALUE, Paginated, Pagination, sloDefinitionSchema } from '@kbn/slo-schema';
import { isLeft } from 'fp-ts/lib/Either';
import { merge } from 'lodash';
import { SLO_MODEL_VERSION } from '../../common/constants';
import { SLODefinition, StoredSLODefinition } from '../domain/models';
import { SLONotFound } from '../errors';
@ -155,10 +156,10 @@ export class KibanaSavedObjectsSLORepository implements SLORepository {
// We would need to call the _reset api on this SLO.
version: storedSLO.version ?? 1,
// settings.preventInitialBackfill was added in 8.15.0
settings: {
...storedSLO.settings,
preventInitialBackfill: storedSLO.settings?.preventInitialBackfill ?? false,
},
settings: merge(
{ preventInitialBackfill: false, syncDelay: '1m', frequency: '1m' },
storedSLO.settings
),
});
if (isLeft(result)) {

View file

@ -63,3 +63,11 @@ Object {
},
}
`;
exports[`Transform Generator settings builds the transform settings 1`] = `
Object {
"frequency": "2m",
"sync_delay": "10m",
"sync_field": "my_timestamp_sync_field",
}
`;

View file

@ -42,7 +42,7 @@ export class ApmTransactionDurationTransformGenerator extends TransformGenerator
this.buildDestination(slo),
this.buildGroupBy(slo, slo.indicator),
this.buildAggregations(slo, slo.indicator),
this.buildSettings(slo),
this.buildSettings(slo, '@timestamp'),
slo
);
}

View file

@ -41,7 +41,7 @@ export class ApmTransactionErrorRateTransformGenerator extends TransformGenerato
this.buildDestination(slo),
this.buildGroupBy(slo, slo.indicator),
this.buildAggregations(slo),
this.buildSettings(slo),
this.buildSettings(slo, '@timestamp'),
slo
);
}

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { Duration, DurationUnit } from '../../domain/models';
import { createAPMTransactionErrorRateIndicator, createSLO } from '../fixtures/slo';
import { ApmTransactionErrorRateTransformGenerator } from './apm_transaction_error_rate';
import { dataViewsService } from '@kbn/data-views-plugin/server/mocks';
@ -45,4 +46,46 @@ describe('Transform Generator', () => {
expect(runtimeMappings).toEqual({});
});
});
describe('settings', () => {
const defaultSettings = {
syncDelay: new Duration(10, DurationUnit.Minute),
frequency: new Duration(2, DurationUnit.Minute),
preventInitialBackfill: true,
};
it('builds the transform settings', async () => {
const slo = createSLO({
settings: {
...defaultSettings,
syncField: 'my_timestamp_sync_field',
},
});
const settings = generator.buildSettings(slo);
expect(settings).toMatchSnapshot();
});
it('builds the transform settings using the provided settings.syncField', async () => {
const slo = createSLO({
settings: {
...defaultSettings,
syncField: 'my_timestamp_sync_field',
},
});
const settings = generator.buildSettings(slo, '@timestamp');
expect(settings.sync_field).toEqual('my_timestamp_sync_field');
});
it('builds the transform settings using provided fallback when no settings.syncField is configured', async () => {
const slo = createSLO({ settings: defaultSettings });
const settings = generator.buildSettings(slo, '@timestamp2');
expect(settings.sync_field).toEqual('@timestamp2');
});
it("builds the transform settings using '@timestamp' default fallback when no settings.syncField is configured", async () => {
const slo = createSLO({ settings: defaultSettings });
const settings = generator.buildSettings(slo);
expect(settings.sync_field).toEqual('@timestamp');
});
});
});

View file

@ -88,8 +88,9 @@ export abstract class TransformGenerator {
): TransformSettings {
return {
frequency: slo.settings.frequency.format(),
sync_field: sourceIndexTimestampField, // timestamp field defined in the source index
sync_delay: slo.settings.syncDelay.format(),
// 8.17: use settings.syncField if truthy or default to sourceIndexTimestampField which is the indicator timestampField
sync_field: !!slo.settings.syncField ? slo.settings.syncField : sourceIndexTimestampField,
};
}
}

View file

@ -43,9 +43,10 @@ export class UpdateSLO {
public async execute(sloId: string, params: UpdateSLOParams): Promise<UpdateSLOResponse> {
const originalSlo = await this.repository.findById(sloId);
let updatedSlo: SLODefinition = Object.assign({}, originalSlo, params, {
let updatedSlo: SLODefinition = Object.assign({}, originalSlo, {
...params,
groupBy: !!params.groupBy ? params.groupBy : originalSlo.groupBy,
settings: mergePartialSettings(originalSlo.settings, params.settings),
settings: Object.assign({}, originalSlo.settings, params.settings),
});
if (isEqual(originalSlo, updatedSlo)) {
@ -263,13 +264,3 @@ export class UpdateSLO {
return updateSLOResponseSchema.encode(slo);
}
}
/**
* Settings are merged by overwriting the original settings with the optional new partial settings.
*/
function mergePartialSettings(
originalSettings: SLODefinition['settings'],
newPartialSettings: UpdateSLOParams['settings']
) {
return Object.assign({}, originalSettings, newPartialSettings);
}

View file

@ -41,10 +41,6 @@ export function useCreateSLO({
tags: [],
},
},
budgetingMethod: 'occurrences',
objective: {
target: 0.99,
},
tags: tags || [],
groupBy: ['monitor.name', 'observer.geo.name', 'monitor.id'],
},