chore(slo): improve error handling (#160081)

Resolves https://github.com/elastic/kibana/issues/156145
Resolves https://github.com/elastic/kibana/issues/160293

## 📝 Summary

This PR fixes a bug related to `shouldUnregister` used on controlled
fields which removes part of the form state while submitting the form,
due to the components unmounting. This is a weird issue between React
Hook Form and React Query, as if we were not using optimistic update
with RQ, we won't notice the problem in the first place.

I have done a few things in this PR:
1. I've introduced a `CreateSLOForm` type to decouple what the API is
expecting (CreateSLOInput) and how we structure the form and store the
form state. This is particularly useful when building a partial
`CreateSLOForm` from a partial url state `_a`.
2. I've introduced a hook that handles the change of indicator
correctly, resetting the default value as we change. The default values
are typed with each indicator schema type, and the hook will throw when
a new indicator is introduced but not handled there.
3. I've removed the custom metric manual useEffect and instead rely on
hidden registered inputs.
4. The time window type handles correctly the initial state, and reload
the default values when we change from rolling <-> calendar aligned.
5. I've grouped some code from the main form component into their own
hook to 1. add a layer of abstraction and 2. make the code more cohesive
6. When the create SLO call fails, we redirect the user to the form with
the previously entered values.


## 🧪 Testing

I would suggest to create and edit some SLOs, playing with the different
time window, budgeting method, indicators.
The main thing to look for are: 
1. Switching indicator reset the form as expected
2. When editing an SLO, all the form fields are populated correctly with
the initial SLO values.
3. Creating an SLO with a bad indicator, for example with an invalid KQL
query, will redirect to the form with the previous value filled.


https://www.loom.com/share/516c3d5a1fa74db6bf839cfa0cf41f5d?sid=f0a1a33f-eb29-4b8f-b65f-ffce2313bad8

---------

Co-authored-by: Coen Warmer <coen.warmer@gmail.com>
This commit is contained in:
Kevin Delemme 2023-06-23 12:41:24 -04:00 committed by GitHub
parent 78cec76d24
commit f5a111eaab
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 553 additions and 316 deletions

View file

@ -12,6 +12,7 @@ import {
historicalSummarySchema,
indicatorSchema,
indicatorTypesArraySchema,
indicatorTypesSchema,
kqlCustomIndicatorSchema,
metricCustomIndicatorSchema,
objectiveSchema,
@ -189,17 +190,15 @@ type FetchHistoricalSummaryResponse = t.OutputOf<typeof fetchHistoricalSummaryRe
type HistoricalSummaryResponse = t.OutputOf<typeof historicalSummarySchema>;
type GetPreviewDataParams = t.TypeOf<typeof getPreviewDataParamsSchema.props.body>;
type GetPreviewDataResponse = t.TypeOf<typeof getPreviewDataResponseSchema>;
type GetPreviewDataResponse = t.OutputOf<typeof getPreviewDataResponseSchema>;
type APMTransactionErrorRateIndicatorSchema = t.TypeOf<
typeof apmTransactionErrorRateIndicatorSchema
>;
type APMTransactionDurationIndicatorSchema = t.TypeOf<typeof apmTransactionDurationIndicatorSchema>;
type GetSLOBurnRatesResponse = t.OutputOf<typeof getSLOBurnRatesResponseSchema>;
type BudgetingMethod = t.TypeOf<typeof budgetingMethodSchema>;
type TimeWindow = t.TypeOf<typeof timeWindowTypeSchema>;
type BudgetingMethod = t.OutputOf<typeof budgetingMethodSchema>;
type TimeWindow = t.OutputOf<typeof timeWindowTypeSchema>;
type IndicatorType = t.OutputOf<typeof indicatorTypesSchema>;
type Indicator = t.OutputOf<typeof indicatorSchema>;
type APMTransactionErrorRateIndicator = t.OutputOf<typeof apmTransactionErrorRateIndicatorSchema>;
type APMTransactionDurationIndicator = t.OutputOf<typeof apmTransactionDurationIndicatorSchema>;
type MetricCustomIndicator = t.OutputOf<typeof metricCustomIndicatorSchema>;
type KQLCustomIndicator = t.OutputOf<typeof kqlCustomIndicatorSchema>;
@ -242,9 +241,10 @@ export type {
UpdateSLOInput,
UpdateSLOParams,
UpdateSLOResponse,
APMTransactionDurationIndicatorSchema,
APMTransactionErrorRateIndicatorSchema,
APMTransactionDurationIndicator,
APMTransactionErrorRateIndicator,
GetSLOBurnRatesResponse,
IndicatorType,
Indicator,
MetricCustomIndicator,
KQLCustomIndicator,

View file

@ -20,6 +20,8 @@ export const paths = {
slos: SLOS_PAGE_LINK,
slosWelcome: `${SLOS_PAGE_LINK}/welcome`,
sloCreate: `${SLOS_PAGE_LINK}/create`,
sloCreateWithEncodedForm: (encodedParams: string) =>
`${SLOS_PAGE_LINK}/create?_a=${encodedParams}`,
sloEdit: (sloId: string) => `${SLOS_PAGE_LINK}/edit/${encodeURI(sloId)}`,
sloDetails: (sloId: string) => `${SLOS_PAGE_LINK}/${encodeURI(sloId)}`,
},

View file

@ -54,13 +54,6 @@ export function useCloneSlo() {
// Optimistically update to the new value
queryClient.setQueryData(queryKey ?? sloKeys.lists(), optimisticUpdate);
toasts.addSuccess(
i18n.translate('xpack.observability.slo.clone.successNotification', {
defaultMessage: 'Successfully created {name}',
values: { name: slo.name },
})
);
// Return a context object with the snapshotted value
return { previousSloList: data };
},
@ -76,7 +69,13 @@ export function useCloneSlo() {
})
);
},
onSuccess: () => {
onSuccess: (_data, { slo }) => {
toasts.addSuccess(
i18n.translate('xpack.observability.slo.clone.successNotification', {
defaultMessage: 'Successfully created {name}',
values: { name: slo.name },
})
);
queryClient.invalidateQueries(sloKeys.lists());
},
}

View file

@ -5,16 +5,19 @@
* 2.0.
*/
import { v1 as uuidv1 } from 'uuid';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { i18n } from '@kbn/i18n';
import { encode } from '@kbn/rison';
import type { CreateSLOInput, CreateSLOResponse, FindSLOResponse } from '@kbn/slo-schema';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { v1 as uuidv1 } from 'uuid';
import { paths } from '../../config/paths';
import { useKibana } from '../../utils/kibana_react';
import { sloKeys } from './query_key_factory';
export function useCreateSlo() {
const {
application: { navigateToUrl },
http,
notifications: { toasts },
} = useKibana().services;
@ -27,23 +30,6 @@ export function useCreateSlo() {
},
{
mutationKey: ['createSlo'],
onSuccess: (_data, { slo: { name } }) => {
toasts.addSuccess(
i18n.translate('xpack.observability.slo.create.successNotification', {
defaultMessage: 'Successfully created {name}',
values: { name },
})
);
queryClient.invalidateQueries(sloKeys.lists());
},
onError: (error, { slo: { name } }) => {
toasts.addError(new Error(String(error)), {
title: i18n.translate('xpack.observability.slo.create.errorNotification', {
defaultMessage: 'Something went wrong while creating {name}',
values: { name },
}),
});
},
onMutate: async ({ slo }) => {
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
await queryClient.cancelQueries(sloKeys.lists());
@ -66,6 +52,27 @@ export function useCreateSlo() {
// Return a context object with the snapshotted value
return { previousSloList: data };
},
onSuccess: (_data, { slo }) => {
toasts.addSuccess(
i18n.translate('xpack.observability.slo.create.successNotification', {
defaultMessage: 'Successfully created {name}',
values: { name: slo.name },
})
);
queryClient.invalidateQueries(sloKeys.lists());
},
onError: (error, { slo }) => {
toasts.addError(new Error(String(error)), {
title: i18n.translate('xpack.observability.slo.create.errorNotification', {
defaultMessage: 'Something went wrong while creating {name}',
values: { name: slo.name },
}),
});
navigateToUrl(
http.basePath.prepend(paths.observability.sloCreateWithEncodedForm(encode(slo)))
);
},
}
);
}

View file

@ -51,18 +51,11 @@ export function useDeleteSlo() {
// Optimistically update to the new value
queryClient.setQueryData(queryKey ?? sloKeys.lists(), optimisticUpdate);
toasts.addSuccess(
i18n.translate('xpack.observability.slo.slo.delete.successNotification', {
defaultMessage: 'Deleted {name}',
values: { name: slo.name },
})
);
// Return a context object with the snapshotted value
return { previousSloList: data };
},
// If the mutation fails, use the context returned from onMutate to roll back
onError: (_err, slo, context) => {
onError: (_err, { name }, context) => {
if (context?.previousSloList) {
queryClient.setQueryData(sloKeys.lists(), context.previousSloList);
}
@ -70,11 +63,17 @@ export function useDeleteSlo() {
toasts.addDanger(
i18n.translate('xpack.observability.slo.slo.delete.errorNotification', {
defaultMessage: 'Failed to delete {name}',
values: { name: slo.name },
values: { name },
})
);
},
onSuccess: () => {
onSuccess: (_data, { name }) => {
toasts.addSuccess(
i18n.translate('xpack.observability.slo.slo.delete.successNotification', {
defaultMessage: 'Deleted {name}',
values: { name },
})
);
if (
// @ts-ignore
queryClient.getQueryCache().find(sloKeys.lists())?.options.refetchInterval === undefined

View file

@ -20,8 +20,8 @@ import { SLO_BURN_RATE_RULE_TYPE_ID } from '../../../../common/constants';
import { rulesLocatorID, sloFeatureId } from '../../../../common';
import { paths } from '../../../config/paths';
import {
transformSloResponseToCreateSloInput,
transformValuesToCreateSLOInput,
transformSloResponseToCreateSloForm,
transformCreateSLOFormToCreateSLOInput,
} from '../../slo_edit/helpers/process_slo_form_values';
import { SloDeleteConfirmationModal } from '../../slos/components/slo_delete_confirmation_modal';
import type { RulesParams } from '../../../locators/rules';
@ -111,8 +111,8 @@ export function HeaderControl({ isLoading, slo }: Props) {
if (slo) {
setIsPopoverOpen(false);
const newSlo = transformValuesToCreateSLOInput(
transformSloResponseToCreateSloInput({ ...slo, name: `[Copy] ${slo.name}` })!
const newSlo = transformCreateSLOFormToCreateSLOInput(
transformSloResponseToCreateSloForm({ ...slo, name: `[Copy] ${slo.name}` })!
);
cloneSlo({ slo: newSlo, originalSloId: slo.id });

View file

@ -234,7 +234,7 @@ describe('SLO Details Page', () => {
fireEvent.click(button!);
const { id, createdAt, enabled, revision, summary, updatedAt, ...newSlo } = slo;
const { id, createdAt, enabled, revision, summary, settings, updatedAt, ...newSlo } = slo;
expect(mockClone).toBeCalledWith({
originalSloId: slo.id,

View file

@ -5,22 +5,16 @@
* 2.0.
*/
import React, { useEffect } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiIconTip } from '@elastic/eui';
import { useFormContext } from 'react-hook-form';
import { i18n } from '@kbn/i18n';
import type { CreateSLOInput } from '@kbn/slo-schema';
import { useFetchApmIndex } from '../../../../hooks/slo/use_fetch_apm_indices';
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { CreateSLOForm } from '../../types';
import { FieldSelector } from '../apm_common/field_selector';
import { QueryBuilder } from '../common/query_builder';
export function ApmAvailabilityIndicatorTypeForm() {
const { setValue, watch } = useFormContext<CreateSLOInput>();
const { data: apmIndex } = useFetchApmIndex();
useEffect(() => {
setValue('indicator.params.index', apmIndex);
}, [apmIndex, setValue]);
const { watch } = useFormContext<CreateSLOForm>();
return (
<EuiFlexGroup direction="column" gutterSize="l">

View file

@ -5,17 +5,16 @@
* 2.0.
*/
import React, { ReactNode, useState } from 'react';
import { EuiComboBox, EuiComboBoxOptionOption, EuiFlexItem, EuiFormRow } from '@elastic/eui';
import { Controller, FieldPath, useFormContext } from 'react-hook-form';
import { CreateSLOInput } from '@kbn/slo-schema';
import { i18n } from '@kbn/i18n';
import { debounce } from 'lodash';
import React, { ReactNode, useState } from 'react';
import { Controller, FieldPath, useFormContext } from 'react-hook-form';
import {
Suggestion,
useFetchApmSuggestions,
} from '../../../../hooks/slo/use_fetch_apm_suggestions';
import { CreateSLOForm } from '../../types';
interface Option {
label: string;
@ -27,7 +26,7 @@ export interface Props {
dataTestSubj: string;
fieldName: string;
label: string;
name: FieldPath<CreateSLOInput>;
name: FieldPath<CreateSLOForm>;
placeholder: string;
tooltip?: ReactNode;
}
@ -41,7 +40,7 @@ export function FieldSelector({
placeholder,
tooltip,
}: Props) {
const { control, watch, getFieldState } = useFormContext<CreateSLOInput>();
const { control, watch, getFieldState } = useFormContext<CreateSLOForm>();
const serviceName = watch('indicator.params.service');
const [search, setSearch] = useState<string>('');
const { suggestions, isLoading } = useFetchApmSuggestions({
@ -80,7 +79,6 @@ export function FieldSelector({
isInvalid={getFieldState(name).invalid}
>
<Controller
shouldUnregister
defaultValue=""
name={name}
control={control}

View file

@ -5,22 +5,16 @@
* 2.0.
*/
import React, { useEffect } from 'react';
import { EuiFieldNumber, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiIconTip } from '@elastic/eui';
import { Controller, useFormContext } from 'react-hook-form';
import { i18n } from '@kbn/i18n';
import type { CreateSLOInput } from '@kbn/slo-schema';
import { useFetchApmIndex } from '../../../../hooks/slo/use_fetch_apm_indices';
import React from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { CreateSLOForm } from '../../types';
import { FieldSelector } from '../apm_common/field_selector';
import { QueryBuilder } from '../common/query_builder';
export function ApmLatencyIndicatorTypeForm() {
const { control, setValue, watch, getFieldState } = useFormContext<CreateSLOInput>();
const { data: apmIndex } = useFetchApmIndex();
useEffect(() => {
setValue('indicator.params.index', apmIndex);
}, [apmIndex, setValue]);
const { control, watch, getFieldState } = useFormContext<CreateSLOForm>();
return (
<EuiFlexGroup direction="column" gutterSize="l">
@ -119,7 +113,6 @@ export function ApmLatencyIndicatorTypeForm() {
isInvalid={getFieldState('indicator.params.threshold').invalid}
>
<Controller
shouldUnregister
name="indicator.params.threshold"
control={control}
defaultValue={250}

View file

@ -9,14 +9,15 @@ import { AreaSeries, Axis, Chart, Position, ScaleType, Settings } from '@elastic
import { EuiFlexItem, EuiIcon, EuiLoadingChart, EuiPanel } from '@elastic/eui';
import numeral from '@elastic/numeral';
import { i18n } from '@kbn/i18n';
import { CreateSLOInput } from '@kbn/slo-schema';
import moment from 'moment';
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { useKibana } from '../../../../utils/kibana_react';
import { useDebouncedGetPreviewData } from '../../hooks/use_preview';
import { CreateSLOForm } from '../../types';
export function DataPreviewChart() {
const { watch, getFieldState } = useFormContext<CreateSLOInput>();
const { watch, getFieldState } = useFormContext<CreateSLOForm>();
const { charts, uiSettings } = useKibana().services;
const { data: previewData, isLoading: isPreviewLoading } = useDebouncedGetPreviewData(

View file

@ -5,19 +5,19 @@
* 2.0.
*/
import { EuiFormRow } from '@elastic/eui';
import { QueryStringInput } from '@kbn/unified-search-plugin/public';
import React, { ReactNode } from 'react';
import { Controller, FieldPath, useFormContext } from 'react-hook-form';
import { EuiFormRow } from '@elastic/eui';
import { CreateSLOInput } from '@kbn/slo-schema';
import { QueryStringInput } from '@kbn/unified-search-plugin/public';
import { useKibana } from '../../../../utils/kibana_react';
import { useCreateDataView } from '../../../../hooks/use_create_data_view';
import { useKibana } from '../../../../utils/kibana_react';
import { CreateSLOForm } from '../../types';
export interface Props {
dataTestSubj: string;
indexPatternString: string | undefined;
label: string;
name: FieldPath<CreateSLOInput>;
name: FieldPath<CreateSLOForm>;
placeholder: string;
required?: boolean;
tooltip?: ReactNode;
@ -35,7 +35,7 @@ export function QueryBuilder({
const { data, dataViews, docLinks, http, notifications, storage, uiSettings, unifiedSearch } =
useKibana().services;
const { control, getFieldState } = useFormContext<CreateSLOInput>();
const { control, getFieldState } = useFormContext<CreateSLOForm>();
const { dataView } = useCreateDataView({ indexPatternString });
@ -54,7 +54,6 @@ export function QueryBuilder({
fullWidth
>
<Controller
shouldUnregister
defaultValue=""
name={name}
control={control}

View file

@ -8,12 +8,12 @@
import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui';
import { DataView } from '@kbn/data-views-plugin/public';
import { i18n } from '@kbn/i18n';
import { CreateSLOInput } from '@kbn/slo-schema';
import { debounce } from 'lodash';
import React, { useEffect, useMemo, useState } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { useFetchDataViews } from '../../../../hooks/use_fetch_data_views';
import { useFetchIndices } from '../../../../hooks/use_fetch_indices';
import { CreateSLOForm } from '../../types';
interface Option {
label: string;
@ -21,7 +21,7 @@ interface Option {
}
export function IndexSelection() {
const { control, getFieldState } = useFormContext<CreateSLOInput>();
const { control, getFieldState } = useFormContext<CreateSLOForm>();
const [searchValue, setSearchValue] = useState<string>('');
const [dataViewOptions, setDataViewOptions] = useState<Option[]>([]);
@ -35,8 +35,10 @@ export function IndexSelection() {
});
useEffect(() => {
setDataViewOptions(createDataViewOptions(dataViews));
}, [dataViews, dataViews.length]);
if (dataViews.length > 0) {
setDataViewOptions(createDataViewOptions(dataViews));
}
}, [dataViews]);
useEffect(() => {
if (indices.length === 0) {
@ -67,7 +69,7 @@ export function IndexSelection() {
],
});
}
}, [searchValue, indices, indices.length]);
}, [indices.length, searchValue]);
const onDataViewSearchChange = useMemo(
() => debounce((value: string) => setSearchValue(value), 300),
@ -95,7 +97,6 @@ export function IndexSelection() {
isInvalid={getFieldState('indicator.params.index').invalid}
>
<Controller
shouldUnregister
defaultValue=""
name="indicator.params.index"
control={control}
@ -155,20 +156,19 @@ function createDataViewLabel(dataView: DataView) {
function createDataViewOptions(dataViews: DataView[]): Option[] {
const options = [];
if (dataViews.length > 0) {
options.push({
label: i18n.translate(
'xpack.observability.slo.sloEdit.customKql.indexSelection.dataViewOptionsLabel',
{ defaultMessage: 'Select an existing Data View' }
),
options: dataViews
.map((view) => ({
label: createDataViewLabel(view),
value: view.getIndexPattern(),
}))
.sort((a, b) => String(a.label).localeCompare(b.label)),
});
}
options.push({
label: i18n.translate(
'xpack.observability.slo.sloEdit.customKql.indexSelection.dataViewOptionsLabel',
{ defaultMessage: 'Select an existing Data View' }
),
options: dataViews
.map((view) => ({
label: createDataViewLabel(view),
value: view.getIndexPattern(),
}))
.sort((a, b) => String(a.label).localeCompare(b.label)),
});
return options;
}

View file

@ -14,13 +14,13 @@ import {
EuiIconTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { CreateSLOInput } from '@kbn/slo-schema';
import React from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import {
Field,
useFetchIndexPatternFields,
} from '../../../../hooks/slo/use_fetch_index_pattern_fields';
import { CreateSLOForm } from '../../types';
import { DataPreviewChart } from '../common/data_preview_chart';
import { QueryBuilder } from '../common/query_builder';
import { IndexSelection } from '../custom_common/index_selection';
@ -31,9 +31,9 @@ interface Option {
}
export function CustomKqlIndicatorTypeForm() {
const { control, watch, getFieldState } = useFormContext<CreateSLOInput>();
const index = watch('indicator.params.index');
const { control, watch, getFieldState } = useFormContext<CreateSLOForm>();
const index = watch('indicator.params.index');
const { isLoading, data: indexFields } = useFetchIndexPatternFields(index);
const timestampFields = (indexFields ?? []).filter((field) => field.type === 'date');
@ -53,7 +53,6 @@ export function CustomKqlIndicatorTypeForm() {
>
<Controller
name="indicator.params.timestampField"
shouldUnregister
defaultValue=""
rules={{ required: true }}
control={control}

View file

@ -5,7 +5,6 @@
* 2.0.
*/
import React from 'react';
import {
EuiComboBox,
EuiComboBoxOptionOption,
@ -16,16 +15,17 @@ import {
EuiPanel,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { CreateSLOInput } from '@kbn/slo-schema';
import {
Field,
useFetchIndexPatternFields,
} from '../../../../hooks/slo/use_fetch_index_pattern_fields';
import { IndexSelection } from '../custom_common/index_selection';
import { CreateSLOForm } from '../../types';
import { QueryBuilder } from '../common/query_builder';
import { IndexSelection } from '../custom_common/index_selection';
import { MetricIndicator } from './metric_indicator';
export { NEW_CUSTOM_METRIC } from './metric_indicator';
interface Option {
@ -34,7 +34,7 @@ interface Option {
}
export function CustomMetricIndicatorTypeForm() {
const { control, watch, getFieldState } = useFormContext<CreateSLOInput>();
const { control, watch, getFieldState } = useFormContext<CreateSLOForm>();
const { isLoading, data: indexFields } = useFetchIndexPatternFields(
watch('indicator.params.index')
@ -57,7 +57,6 @@ export function CustomMetricIndicatorTypeForm() {
>
<Controller
name="indicator.params.timestampField"
shouldUnregister
defaultValue=""
rules={{ required: true }}
control={control}

View file

@ -4,7 +4,6 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { ReactNode, useEffect } from 'react';
import {
EuiButtonEmpty,
EuiButtonIcon,
@ -17,11 +16,12 @@ import {
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Controller, useFormContext, useFieldArray } from 'react-hook-form';
import { CreateSLOInput } from '@kbn/slo-schema';
import { range, first, xor } from 'lodash';
import { FormattedMessage } from '@kbn/i18n-react';
import { first, range, xor } from 'lodash';
import React, { ReactNode } from 'react';
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
import { Field } from '../../../../hooks/slo/use_fetch_index_pattern_fields';
import { CreateSLOForm } from '../../types';
interface Option {
label: string;
@ -68,32 +68,18 @@ export function MetricIndicator({
metricTooltip,
equationTooltip,
}: MetricIndicatorProps) {
const { control, watch, setValue } = useFormContext<CreateSLOInput>();
const { control, watch, setValue, register } = useFormContext<CreateSLOForm>();
const metricFields = (indexFields ?? []).filter((field) => field.type === 'number');
const {
fields: metrics,
append,
remove,
} = useFieldArray({
const { fields, append, remove } = useFieldArray({
control,
name: `indicator.params.${type}.metrics`,
});
const equation = watch(`indicator.params.${type}.equation`);
const indexPattern = watch('indicator.params.index');
// Without this, the hidden fields for metric.name and metric.aggregation will
// not be included in the JSON when the form is submitted.
useEffect(() => {
metrics.forEach((metric, index) => {
setValue(`indicator.params.${type}.metrics.${index}.name`, metric.name);
setValue(`indicator.params.${type}.metrics.${index}.aggregation`, metric.aggregation);
});
}, [metrics, setValue, type]);
const disableAdd = metrics?.length === MAX_VARIABLES;
const disableDelete = metrics?.length === 1;
const disableAdd = fields?.length === MAX_VARIABLES;
const disableDelete = fields?.length === 1;
const setDefaultEquationIfUnchanged = (previousNames: string[], nextNames: string[]) => {
const defaultEquation = createEquationFromMetric(previousNames);
@ -103,14 +89,14 @@ export function MetricIndicator({
};
const handleDeleteMetric = (index: number) => () => {
const currentVars = metrics.map((m) => m.name) ?? ['A'];
const currentVars = fields.map((m) => m.name) ?? ['A'];
const deletedVar = currentVars[index];
setDefaultEquationIfUnchanged(currentVars, xor(currentVars, [deletedVar]));
remove(index);
};
const handleAddMetric = () => {
const currentVars = metrics.map((m) => m.name) ?? ['A'];
const currentVars = fields.map((m) => m.name) ?? ['A'];
const name = first(xor(VAR_NAMES, currentVars))!;
setDefaultEquationIfUnchanged(currentVars, [...currentVars, name]);
append({ ...NEW_CUSTOM_METRIC, name });
@ -119,7 +105,7 @@ export function MetricIndicator({
return (
<>
<EuiFlexItem>
{metrics?.map((metric, index) => (
{fields?.map((metric, index) => (
<EuiFormRow
fullWidth
label={
@ -131,9 +117,14 @@ export function MetricIndicator({
>
<EuiFlexGroup alignItems="center" gutterSize="xs">
<EuiFlexItem>
<input hidden {...register(`indicator.params.${type}.metrics.${index}.name`)} />
<input
hidden
{...register(`indicator.params.${type}.metrics.${index}.aggregation`)}
/>
<Controller
name={`indicator.params.${type}.metrics.${index}.field`}
shouldUnregister
defaultValue=""
rules={{ required: true }}
control={control}
@ -172,7 +163,6 @@ export function MetricIndicator({
{
value: field.value,
label: field.value,
'data-test-subj': `customMetricIndicatorFormMetricFieldSelectedValue`,
},
]
: []
@ -229,7 +219,6 @@ export function MetricIndicator({
<EuiFlexItem>
<Controller
name={`indicator.params.${type}.equation`}
shouldUnregister
defaultValue=""
rules={{
required: true,

View file

@ -5,9 +5,6 @@
* 2.0.
*/
import React, { useCallback, useEffect, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useLocation, useHistory } from 'react-router-dom';
import {
EuiButton,
EuiButtonEmpty,
@ -18,27 +15,34 @@ import {
EuiSteps,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { CreateSLOInput, SLOWithSummaryResponse } from '@kbn/slo-schema';
import { createKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import { useKibana } from '../../../utils/kibana_react';
import type { SLOWithSummaryResponse } from '@kbn/slo-schema';
import React, { useCallback, useEffect, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { sloFeatureId } from '../../../../common';
import { SLO_BURN_RATE_RULE_TYPE_ID } from '../../../../common/constants';
import { paths } from '../../../config/paths';
import { useCreateSlo } from '../../../hooks/slo/use_create_slo';
import { useUpdateSlo } from '../../../hooks/slo/use_update_slo';
import { useShowSections } from '../hooks/use_show_sections';
import { useFetchRulesForSlo } from '../../../hooks/slo/use_fetch_rules_for_slo';
import { useSectionFormValidation } from '../hooks/use_section_form_validation';
import { SloEditFormDescriptionSection } from './slo_edit_form_description_section';
import { SloEditFormObjectiveSection } from './slo_edit_form_objective_section';
import { SloEditFormIndicatorSection } from './slo_edit_form_indicator_section';
import { useUpdateSlo } from '../../../hooks/slo/use_update_slo';
import { useKibana } from '../../../utils/kibana_react';
import { SLO_EDIT_FORM_DEFAULT_VALUES } from '../constants';
import {
transformValuesToCreateSLOInput,
transformSloResponseToCreateSloInput,
transformCreateSLOFormToCreateSLOInput,
transformSloResponseToCreateSloForm,
transformValuesToUpdateSLOInput,
} from '../helpers/process_slo_form_values';
import { paths } from '../../../config/paths';
import { SLO_BURN_RATE_RULE_TYPE_ID } from '../../../../common/constants';
import { SLO_EDIT_FORM_DEFAULT_VALUES } from '../constants';
import { sloFeatureId } from '../../../../common';
import {
CREATE_RULE_SEARCH_PARAM,
useAddRuleFlyoutState,
} from '../hooks/use_add_rule_flyout_state';
import { useCopyToJson } from '../hooks/use_copy_to_json';
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 { SloEditFormIndicatorSection } from './slo_edit_form_indicator_section';
import { SloEditFormObjectiveSection } from './slo_edit_form_objective_section';
export interface Props {
slo: SLOWithSummaryResponse | undefined;
@ -46,54 +50,35 @@ export interface Props {
export const maxWidth = 775;
const CREATE_RULE_SEARCH_PARAM = 'create-rule';
export function SloEditForm({ slo }: Props) {
const {
notifications,
application: { navigateToUrl },
http: { basePath },
triggersActionsUi: { getAddRuleFlyout: AddRuleFlyout },
} = useKibana().services;
const history = useHistory();
const { search } = useLocation();
const isEditMode = slo !== undefined;
const { data: rules, isInitialLoading } = useFetchRulesForSlo({
sloIds: slo?.id ? [slo.id] : undefined,
});
const urlStateStorage = createKbnUrlStateStorage({
history,
useHash: false,
useHashQuery: false,
});
const urlParams = urlStateStorage.get<CreateSLOInput>('_a');
const searchParams = new URLSearchParams(search);
const isEditMode = slo !== undefined;
const [isAddRuleFlyoutOpen, setIsAddRuleFlyoutOpen] = useState(false);
const sloFormValuesUrlState = useParseUrlState();
const isAddRuleFlyoutOpen = useAddRuleFlyoutState(isEditMode);
const [isCreateRuleCheckboxChecked, setIsCreateRuleCheckboxChecked] = useState(true);
if (searchParams.has(CREATE_RULE_SEARCH_PARAM) && isEditMode && !isAddRuleFlyoutOpen) {
setIsAddRuleFlyoutOpen(true);
}
useEffect(() => {
if (isEditMode && rules && rules[slo.id].length) {
setIsCreateRuleCheckboxChecked(false);
}
}, [isEditMode, rules, slo]);
const methods = useForm({
defaultValues: { ...SLO_EDIT_FORM_DEFAULT_VALUES, ...urlParams },
values: transformSloResponseToCreateSloInput(slo),
const methods = useForm<CreateSLOForm>({
defaultValues: Object.assign({}, SLO_EDIT_FORM_DEFAULT_VALUES, sloFormValuesUrlState),
values: transformSloResponseToCreateSloForm(slo),
mode: 'all',
});
const { watch, getFieldState, getValues, formState, trigger } = methods;
const handleCopyToJson = useCopyToJson({ trigger, getValues });
const { isIndicatorSectionValid, isObjectiveSectionValid, isDescriptionSectionValid } =
useSectionFormValidation({
@ -113,35 +98,6 @@ export function SloEditForm({ slo }: Props) {
const { mutateAsync: createSlo, isLoading: isCreateSloLoading } = useCreateSlo();
const { mutateAsync: updateSlo, isLoading: isUpdateSloLoading } = useUpdateSlo();
const handleCopyToJson = async () => {
const isValid = await trigger();
if (!isValid) {
return;
}
const values = transformValuesToCreateSLOInput(getValues());
try {
await copyTextToClipboard(JSON.stringify(values, null, 2));
notifications.toasts.add({
title: i18n.translate('xpack.observability.slo.sloEdit.copyJsonNotification', {
defaultMessage: 'JSON copied to clipboard',
}),
});
} catch (e) {
notifications.toasts.add({
title: i18n.translate('xpack.observability.slo.sloEdit.copyJsonFailedNotification', {
defaultMessage: 'Could not copy JSON to clipboard',
}),
});
}
};
const copyTextToClipboard = async (text: string) => {
if (!window.navigator?.clipboard) {
throw new Error('Could not copy to clipboard!');
}
await window.navigator.clipboard.writeText(text);
};
const handleSubmit = async () => {
const isValid = await trigger();
if (!isValid) {
@ -165,7 +121,7 @@ export function SloEditForm({ slo }: Props) {
navigate(basePath.prepend(paths.observability.slos));
}
} else {
const processedValues = transformValuesToCreateSLOInput(values);
const processedValues = transformCreateSLOFormToCreateSLOInput(values);
if (isCreateRuleCheckboxChecked) {
const { id } = await createSlo({ slo: processedValues });
@ -302,7 +258,7 @@ export function SloEditForm({ slo }: Props) {
<AddRuleFlyout
canChangeTrigger={false}
consumer={sloFeatureId}
initialValues={{ name: `${slo.name} Burn Rate rule`, params: { sloId: slo.id } }}
initialValues={{ name: `${slo.name} burn rate rule`, params: { sloId: slo.id } }}
ruleTypeId={SLO_BURN_RATE_RULE_TYPE_ID}
onClose={handleCloseRuleFlyout}
onSave={handleCloseRuleFlyout}

View file

@ -19,12 +19,11 @@ import {
import { i18n } from '@kbn/i18n';
import React from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import type { CreateSLOInput } from '@kbn/slo-schema';
import { CreateSLOForm } from '../types';
import { maxWidth } from './slo_edit_form';
export function SloEditFormDescriptionSection() {
const { control, getFieldState } = useFormContext<CreateSLOInput>();
const { control, getFieldState } = useFormContext<CreateSLOForm>();
const sloNameId = useGeneratedHtmlId({ prefix: 'sloName' });
const descriptionId = useGeneratedHtmlId({ prefix: 'sloDescription' });
const tagsId = useGeneratedHtmlId({ prefix: 'tags' });
@ -107,7 +106,6 @@ export function SloEditFormDescriptionSection() {
})}
>
<Controller
shouldUnregister
name="tags"
control={control}
defaultValue={[]}

View file

@ -7,18 +7,15 @@
import { EuiFormRow, EuiPanel, EuiSelect, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { CreateSLOInput } from '@kbn/slo-schema';
import React, { useEffect } from 'react';
import React 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 { CustomKqlIndicatorTypeForm } from './custom_kql/custom_kql_indicator_type_form';
import {
CustomMetricIndicatorTypeForm,
NEW_CUSTOM_METRIC,
} from './custom_metric/custom_metric_type_form';
import { CustomMetricIndicatorTypeForm } from './custom_metric/custom_metric_type_form';
import { maxWidth } from './slo_edit_form';
interface SloEditFormIndicatorSectionProps {
@ -26,28 +23,8 @@ interface SloEditFormIndicatorSectionProps {
}
export function SloEditFormIndicatorSection({ isEditMode }: SloEditFormIndicatorSectionProps) {
const { control, watch, setValue } = useFormContext<CreateSLOInput>();
const indicator = watch('indicator.type');
useEffect(() => {
if (!isEditMode) {
if (indicator === 'sli.metric.custom') {
setValue('indicator.params.index', '');
setValue('indicator.params.timestampField', '');
setValue('indicator.params.good.equation', 'A');
setValue('indicator.params.good.metrics', [NEW_CUSTOM_METRIC]);
setValue('indicator.params.total.equation', 'A');
setValue('indicator.params.total.metrics', [NEW_CUSTOM_METRIC]);
}
if (indicator === 'sli.kql.custom') {
setValue('indicator.params.index', '');
setValue('indicator.params.timestampField', '');
setValue('indicator.params.good', '');
setValue('indicator.params.total', '');
}
}
}, [indicator, setValue, isEditMode]);
const { control, watch } = useFormContext<CreateSLOForm>();
useUnregisterFields({ isEditMode });
const getIndicatorTypeForm = () => {
switch (watch('indicator.type')) {

View file

@ -5,7 +5,6 @@
* 2.0.
*/
import React, { useEffect } from 'react';
import {
EuiFieldNumber,
EuiFlexGrid,
@ -18,33 +17,69 @@ import {
useGeneratedHtmlId,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { TimeWindow } from '@kbn/slo-schema';
import React, { useEffect, useState } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import type { CreateSLOInput } from '@kbn/slo-schema';
import { SloEditFormObjectiveSectionTimeslices } from './slo_edit_form_objective_section_timeslices';
import {
BUDGETING_METHOD_OPTIONS,
CALENDARALIGNED_TIMEWINDOW_OPTIONS,
ROLLING_TIMEWINDOW_OPTIONS,
TIMEWINDOW_TYPE_OPTIONS,
} from '../constants';
import { CreateSLOForm } from '../types';
import { maxWidth } from './slo_edit_form';
import { SloEditFormObjectiveSectionTimeslices } from './slo_edit_form_objective_section_timeslices';
export function SloEditFormObjectiveSection() {
const { control, watch, getFieldState, resetField } = useFormContext<CreateSLOInput>();
const {
control,
watch,
getFieldState,
setValue,
formState: { defaultValues },
} = useFormContext<CreateSLOForm>();
const budgetingSelect = useGeneratedHtmlId({ prefix: 'budgetingSelect' });
const timeWindowTypeSelect = useGeneratedHtmlId({ prefix: 'timeWindowTypeSelect' });
const timeWindowSelect = useGeneratedHtmlId({ prefix: 'timeWindowSelect' });
const timeWindowType = watch('timeWindow.type');
const [timeWindowTypeState, setTimeWindowTypeState] = useState<TimeWindow | undefined>(
defaultValues?.timeWindow?.type
);
/**
* Two flow to handle: Create and Edit
* On create: the default value is set to rolling & 30d (useForm)
* When we change the window type (from rolling to calendar for example), we want to select a default duration (picking item 1 in the options)
* If we don't, the select will show the option as selected, but the value is still the one from the previous window type, until the user manually changes the value
*
* On edit: the default value is set with the slo value
* When we change the window type, we want to change the selected value as we do in the create flow, but we also want to fallback on the initial default value
*
*/
useEffect(() => {
resetField('timeWindow.duration', {
defaultValue:
timeWindowType === 'calendarAligned'
? CALENDARALIGNED_TIMEWINDOW_OPTIONS[1].value
: ROLLING_TIMEWINDOW_OPTIONS[1].value,
});
}, [timeWindowType, resetField]);
if (timeWindowType === 'calendarAligned' && timeWindowTypeState !== timeWindowType) {
setTimeWindowTypeState(timeWindowType);
const exists = CALENDARALIGNED_TIMEWINDOW_OPTIONS.map((opt) => opt.value).includes(
defaultValues?.timeWindow?.duration ?? ''
);
setValue(
'timeWindow.duration',
// @ts-ignore
exists ? defaultValues?.timeWindow?.duration : CALENDARALIGNED_TIMEWINDOW_OPTIONS[1].value
);
} else if (timeWindowType === 'rolling' && timeWindowTypeState !== timeWindowType) {
const exists = ROLLING_TIMEWINDOW_OPTIONS.map((opt) => opt.value).includes(
defaultValues?.timeWindow?.duration ?? ''
);
setTimeWindowTypeState(timeWindowType);
setValue(
'timeWindow.duration',
// @ts-ignore
exists ? defaultValues?.timeWindow?.duration : ROLLING_TIMEWINDOW_OPTIONS[1].value
);
}
}, [timeWindowType, setValue, defaultValues, timeWindowTypeState]);
return (
<EuiPanel
@ -85,7 +120,7 @@ export function SloEditFormObjectiveSection() {
id={timeWindowTypeSelect}
data-test-subj="sloFormTimeWindowTypeSelect"
options={TIMEWINDOW_TYPE_OPTIONS}
value={String(field.value)}
value={field.value}
/>
)}
/>
@ -113,7 +148,6 @@ export function SloEditFormObjectiveSection() {
<Controller
name="timeWindow.duration"
control={control}
shouldUnregister
rules={{ required: true }}
render={({ field: { ref, ...field } }) => (
<EuiSelect
@ -126,7 +160,7 @@ export function SloEditFormObjectiveSection() {
? CALENDARALIGNED_TIMEWINDOW_OPTIONS
: ROLLING_TIMEWINDOW_OPTIONS
}
value={String(field.value)}
value={field.value}
/>
)}
/>

View file

@ -7,12 +7,12 @@
import { EuiFieldNumber, EuiFlexItem, EuiFormRow, EuiIconTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { CreateSLOInput } from '@kbn/slo-schema';
import React from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { CreateSLOForm } from '../types';
export function SloEditFormObjectiveSectionTimeslices() {
const { control, getFieldState } = useFormContext<CreateSLOInput>();
const { control, getFieldState } = useFormContext<CreateSLOForm>();
return (
<>

View file

@ -6,7 +6,15 @@
*/
import { i18n } from '@kbn/i18n';
import { BudgetingMethod, CreateSLOInput, TimeWindow } from '@kbn/slo-schema';
import {
APMTransactionDurationIndicator,
APMTransactionErrorRateIndicator,
BudgetingMethod,
IndicatorType,
KQLCustomIndicator,
MetricCustomIndicator,
TimeWindow,
} from '@kbn/slo-schema';
import {
BUDGETING_METHOD_OCCURRENCES,
BUDGETING_METHOD_TIMESLICES,
@ -15,9 +23,10 @@ import {
INDICATOR_CUSTOM_KQL,
INDICATOR_CUSTOM_METRIC,
} from '../../utils/slo/labels';
import { CreateSLOForm } from './types';
export const SLI_OPTIONS: Array<{
value: CreateSLOInput['indicator']['type'];
value: IndicatorType;
text: string;
}> = [
{
@ -87,19 +96,57 @@ export const ROLLING_TIMEWINDOW_OPTIONS = [90, 30, 7].map((number) => ({
}),
}));
export const SLO_EDIT_FORM_DEFAULT_VALUES: CreateSLOInput = {
export const CUSTOM_KQL_DEFAULT_VALUES: KQLCustomIndicator = {
type: 'sli.kql.custom' as const,
params: {
index: '',
filter: '',
good: '',
total: '',
timestampField: '',
},
};
export const CUSTOM_METRIC_DEFAULT_VALUES: MetricCustomIndicator = {
type: 'sli.metric.custom' as const,
params: {
index: '',
filter: '',
good: { metrics: [{ name: 'A', aggregation: 'sum' as const, field: '' }], equation: 'A' },
total: { metrics: [{ name: 'A', aggregation: 'sum' as const, field: '' }], equation: 'A' },
timestampField: '',
},
};
export const APM_LATENCY_DEFAULT_VALUES: APMTransactionDurationIndicator = {
type: 'sli.apm.transactionDuration' as const,
params: {
service: '',
environment: '',
transactionType: '',
transactionName: '',
threshold: 250,
filter: '',
index: '',
},
};
export const APM_AVAILABILITY_DEFAULT_VALUES: APMTransactionErrorRateIndicator = {
type: 'sli.apm.transactionErrorRate' as const,
params: {
service: '',
environment: '',
transactionType: '',
transactionName: '',
filter: '',
index: '',
},
};
export const SLO_EDIT_FORM_DEFAULT_VALUES: CreateSLOForm = {
name: '',
description: '',
indicator: {
type: 'sli.kql.custom',
params: {
index: '',
filter: '',
good: '',
total: '',
timestampField: '',
},
},
indicator: CUSTOM_KQL_DEFAULT_VALUES,
timeWindow: {
duration: ROLLING_TIMEWINDOW_OPTIONS[1].value,
type: 'rolling',
@ -111,19 +158,10 @@ export const SLO_EDIT_FORM_DEFAULT_VALUES: CreateSLOInput = {
},
};
export const SLO_EDIT_FORM_DEFAULT_VALUES_CUSTOM_METRIC: CreateSLOInput = {
export const SLO_EDIT_FORM_DEFAULT_VALUES_CUSTOM_METRIC: CreateSLOForm = {
name: '',
description: '',
indicator: {
type: 'sli.metric.custom',
params: {
index: '',
filter: '',
good: { metrics: [{ name: 'A', aggregation: 'sum', field: '' }], equation: 'A' },
total: { metrics: [{ name: 'A', aggregation: 'sum', field: '' }], equation: 'A' },
timestampField: '',
},
},
indicator: CUSTOM_METRIC_DEFAULT_VALUES,
timeWindow: {
duration: ROLLING_TIMEWINDOW_OPTIONS[1].value,
type: 'rolling',

View file

@ -5,18 +5,24 @@
* 2.0.
*/
import omit from 'lodash/omit';
import type { CreateSLOInput, SLOWithSummaryResponse, UpdateSLOInput } from '@kbn/slo-schema';
import { toDuration } from '../../../utils/slo/duration';
import { CreateSLOForm } from '../types';
export function transformSloResponseToCreateSloInput(
export function transformSloResponseToCreateSloForm(
values: SLOWithSummaryResponse | undefined
): CreateSLOInput | undefined {
): CreateSLOForm | undefined {
if (!values) return undefined;
return {
...omit(values, ['id', 'revision', 'createdAt', 'updatedAt', 'summary', 'enabled']),
name: values.name,
description: values.description,
indicator: values.indicator,
budgetingMethod: values.budgetingMethod,
timeWindow: {
duration: values.timeWindow.duration,
type: values.timeWindow.type,
},
objective: {
target: values.objective.target * 100,
...(values.budgetingMethod === 'timeslices' &&
@ -28,12 +34,20 @@ export function transformSloResponseToCreateSloInput(
timesliceWindow: String(toDuration(values.objective.timesliceWindow).value),
}),
},
tags: values.tags,
};
}
export function transformValuesToCreateSLOInput(values: CreateSLOInput): CreateSLOInput {
export function transformCreateSLOFormToCreateSLOInput(values: CreateSLOForm): CreateSLOInput {
return {
...values,
name: values.name,
description: values.description,
indicator: values.indicator,
budgetingMethod: values.budgetingMethod,
timeWindow: {
duration: values.timeWindow.duration,
type: values.timeWindow.type,
},
objective: {
target: values.objective.target / 100,
...(values.budgetingMethod === 'timeslices' &&
@ -45,12 +59,20 @@ export function transformValuesToCreateSLOInput(values: CreateSLOInput): CreateS
timesliceWindow: `${values.objective.timesliceWindow}m`,
}),
},
tags: values.tags,
};
}
export function transformValuesToUpdateSLOInput(values: CreateSLOInput): UpdateSLOInput {
export function transformValuesToUpdateSLOInput(values: CreateSLOForm): UpdateSLOInput {
return {
...values,
name: values.name,
description: values.description,
indicator: values.indicator,
budgetingMethod: values.budgetingMethod,
timeWindow: {
duration: values.timeWindow.duration,
type: values.timeWindow.type,
},
objective: {
target: values.objective.target / 100,
...(values.budgetingMethod === 'timeslices' &&
@ -62,5 +84,25 @@ export function transformValuesToUpdateSLOInput(values: CreateSLOInput): UpdateS
timesliceWindow: `${values.objective.timesliceWindow}m`,
}),
},
tags: values.tags,
};
}
export function transformPartialCreateSLOInputToPartialCreateSLOForm(
values: Partial<CreateSLOInput>
): Partial<CreateSLOForm> {
return {
...values,
...(values.objective && {
objective: {
target: values.objective.target * 100,
...(values.objective.timesliceTarget && {
timesliceTarget: values.objective.timesliceTarget * 100,
}),
...(values.objective.timesliceWindow && {
timesliceWindow: String(toDuration(values.objective.timesliceWindow).value),
}),
},
}),
};
}

View file

@ -0,0 +1,23 @@
/*
* 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 { useState } from 'react';
import { useLocation } from 'react-router-dom';
export const CREATE_RULE_SEARCH_PARAM = 'create-rule';
export function useAddRuleFlyoutState(isEditMode: boolean): boolean {
const [isAddRuleFlyoutOpen, setIsAddRuleFlyoutOpen] = useState(false);
const { search } = useLocation();
const searchParams = new URLSearchParams(search);
if (searchParams.has(CREATE_RULE_SEARCH_PARAM) && isEditMode && !isAddRuleFlyoutOpen) {
setIsAddRuleFlyoutOpen(true);
}
return isAddRuleFlyoutOpen;
}

View file

@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { UseFormGetValues, UseFormTrigger } from 'react-hook-form';
import { useKibana } from '../../../utils/kibana_react';
import { transformCreateSLOFormToCreateSLOInput } from '../helpers/process_slo_form_values';
import { CreateSLOForm } from '../types';
interface Props {
trigger: UseFormTrigger<CreateSLOForm>;
getValues: UseFormGetValues<CreateSLOForm>;
}
export function useCopyToJson({ trigger, getValues }: Props): () => Promise<void> {
const { notifications } = useKibana().services;
const handleCopyToJson = async () => {
const isValid = await trigger();
if (!isValid) {
return;
}
const values = transformCreateSLOFormToCreateSLOInput(getValues());
try {
await copyTextToClipboard(JSON.stringify(values, null, 2));
notifications.toasts.add({
title: i18n.translate('xpack.observability.slo.sloEdit.copyJsonNotification', {
defaultMessage: 'JSON copied to clipboard',
}),
});
} catch (e) {
notifications.toasts.add({
title: i18n.translate('xpack.observability.slo.sloEdit.copyJsonFailedNotification', {
defaultMessage: 'Could not copy JSON to clipboard',
}),
});
}
};
const copyTextToClipboard = async (text: string) => {
if (!window.navigator?.clipboard) {
throw new Error('Could not copy to clipboard!');
}
await window.navigator.clipboard.writeText(text);
};
return handleCopyToJson;
}

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { createKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import { CreateSLOInput } from '@kbn/slo-schema';
import { useHistory } from 'react-router-dom';
import { transformPartialCreateSLOInputToPartialCreateSLOForm } from '../helpers/process_slo_form_values';
import { CreateSLOForm } from '../types';
export function useParseUrlState(): Partial<CreateSLOForm> | null {
const history = useHistory();
const urlStateStorage = createKbnUrlStateStorage({
history,
useHash: false,
useHashQuery: false,
});
const urlParams = urlStateStorage.get<Partial<CreateSLOInput>>('_a');
return !!urlParams ? transformPartialCreateSLOInputToPartialCreateSLOForm(urlParams) : null;
}

View file

@ -5,15 +5,16 @@
* 2.0.
*/
import { CreateSLOInput, MetricCustomIndicator } from '@kbn/slo-schema';
import { MetricCustomIndicator } from '@kbn/slo-schema';
import { FormState, UseFormGetFieldState, UseFormGetValues, UseFormWatch } from 'react-hook-form';
import { isObject } from 'lodash';
import { CreateSLOForm } from '../types';
interface Props {
getFieldState: UseFormGetFieldState<CreateSLOInput>;
getValues: UseFormGetValues<CreateSLOInput>;
formState: FormState<CreateSLOInput>;
watch: UseFormWatch<CreateSLOInput>;
getFieldState: UseFormGetFieldState<CreateSLOForm>;
getValues: UseFormGetValues<CreateSLOForm>;
formState: FormState<CreateSLOForm>;
watch: UseFormWatch<CreateSLOForm>;
}
export function useSectionFormValidation({ getFieldState, getValues, formState, watch }: Props) {
@ -59,7 +60,6 @@ export function useSectionFormValidation({ getFieldState, getValues, formState,
[
'indicator.params.index',
'indicator.params.filter',
'indicator.params.total',
'indicator.params.timestampField',
] as const

View file

@ -0,0 +1,88 @@
/*
* 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 { IndicatorType } from '@kbn/slo-schema';
import { assertNever } from '@kbn/std';
import deepmerge from 'deepmerge';
import { useEffect, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { useFetchApmIndex } from '../../../hooks/slo/use_fetch_apm_indices';
import {
APM_AVAILABILITY_DEFAULT_VALUES,
APM_LATENCY_DEFAULT_VALUES,
CUSTOM_KQL_DEFAULT_VALUES,
CUSTOM_METRIC_DEFAULT_VALUES,
SLO_EDIT_FORM_DEFAULT_VALUES,
} from '../constants';
import { CreateSLOForm } from '../types';
/**
* This hook handles the unregistration of inputs when selecting another SLI indicator.
* We could not use shouldUnregister on the controlled form fields because of a bug when submitting the form
* which was unmounting the components and therefore unregistering the associated values.
*/
export function useUnregisterFields({ isEditMode }: { isEditMode: boolean }) {
const { data: apmIndex } = useFetchApmIndex();
const { watch, unregister, reset, resetField } = useFormContext<CreateSLOForm>();
const [indicatorTypeState, setIndicatorTypeState] = useState<IndicatorType>(
watch('indicator.type')
);
const indicatorType = watch('indicator.type');
useEffect(() => {
if (indicatorType !== indicatorTypeState && !isEditMode) {
setIndicatorTypeState(indicatorType);
unregister('indicator.params');
switch (indicatorType) {
case 'sli.metric.custom':
reset(
Object.assign({}, SLO_EDIT_FORM_DEFAULT_VALUES, {
indicator: CUSTOM_METRIC_DEFAULT_VALUES,
}),
{
keepDefaultValues: true,
}
);
break;
case 'sli.kql.custom':
reset(
Object.assign({}, SLO_EDIT_FORM_DEFAULT_VALUES, {
indicator: CUSTOM_KQL_DEFAULT_VALUES,
}),
{
keepDefaultValues: true,
}
);
break;
case 'sli.apm.transactionDuration':
reset(
Object.assign({}, SLO_EDIT_FORM_DEFAULT_VALUES, {
indicator: deepmerge(APM_LATENCY_DEFAULT_VALUES, { params: { index: apmIndex } }),
}),
{
keepDefaultValues: true,
}
);
break;
case 'sli.apm.transactionErrorRate':
reset(
Object.assign({}, SLO_EDIT_FORM_DEFAULT_VALUES, {
indicator: deepmerge(APM_AVAILABILITY_DEFAULT_VALUES, {
params: { index: apmIndex },
}),
}),
{
keepDefaultValues: true,
}
);
break;
default:
assertNever(indicatorType);
}
}
}, [isEditMode, indicatorType, indicatorTypeState, unregister, reset, resetField, apmIndex]);
}

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { BudgetingMethod, Indicator, TimeWindow } from '@kbn/slo-schema';
export interface CreateSLOForm {
name: string;
description: string;
indicator: Indicator;
timeWindow: {
duration: string;
type: TimeWindow;
};
tags: string[];
budgetingMethod: BudgetingMethod;
objective: {
target: number;
timesliceTarget?: number;
timesliceWindow?: string;
};
}

View file

@ -30,8 +30,8 @@ import { SloSummary } from './slo_summary';
import { SloDeleteConfirmationModal } from './slo_delete_confirmation_modal';
import { SloBadges } from './badges/slo_badges';
import {
transformSloResponseToCreateSloInput,
transformValuesToCreateSLOInput,
transformSloResponseToCreateSloForm,
transformCreateSLOFormToCreateSLOInput,
} from '../../slo_edit/helpers/process_slo_form_values';
import { SLO_BURN_RATE_RULE_TYPE_ID } from '../../../../common/constants';
import { rulesLocatorID, sloFeatureId } from '../../../../common';
@ -111,8 +111,8 @@ export function SloListItem({
};
const handleClone = () => {
const newSlo = transformValuesToCreateSLOInput(
transformSloResponseToCreateSloInput({ ...slo, name: `[Copy] ${slo.name}` })!
const newSlo = transformCreateSLOFormToCreateSLOInput(
transformSloResponseToCreateSloForm({ ...slo, name: `[Copy] ${slo.name}` })!
);
cloneSlo({ slo: newSlo, originalSloId: slo.id });