[8.9] chore(slo): improve error handling (#160081) (#160438)

# Backport

This will backport the following commits from `main` to `8.9`:
- [chore(slo): improve error handling
(#160081)](https://github.com/elastic/kibana/pull/160081)

<!--- Backport version: 8.9.7 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Kevin
Delemme","email":"kevin.delemme@elastic.co"},"sourceCommit":{"committedDate":"2023-06-23T16:41:24Z","message":"chore(slo):
improve error handling (#160081)\n\nResolves
https://github.com/elastic/kibana/issues/156145\nResolves
https://github.com/elastic/kibana/issues/160293\n\n## 📝 Summary\n\nThis
PR fixes a bug related to `shouldUnregister` used on controlled\nfields
which removes part of the form state while submitting the form,\ndue to
the components unmounting. This is a weird issue between React\nHook
Form and React Query, as if we were not using optimistic update\nwith
RQ, we won't notice the problem in the first place.\n\nI have done a few
things in this PR:\n1. I've introduced a `CreateSLOForm` type to
decouple what the API is\nexpecting (CreateSLOInput) and how we
structure the form and store the\nform state. This is particularly
useful when building a partial\n`CreateSLOForm` from a partial url state
`_a`.\n2. I've introduced a hook that handles the change of
indicator\ncorrectly, resetting the default value as we change. The
default values\nare typed with each indicator schema type, and the hook
will throw when\na new indicator is introduced but not handled
there.\n3. I've removed the custom metric manual useEffect and instead
rely on\nhidden registered inputs.\n4. The time window type handles
correctly the initial state, and reload\nthe default values when we
change from rolling <-> calendar aligned.\n5. I've grouped some code
from the main form component into their own\nhook to 1. add a layer of
abstraction and 2. make the code more cohesive\n6. When the create SLO
call fails, we redirect the user to the form with\nthe previously
entered values.\n\n\n## 🧪 Testing\n\nI would suggest to create and edit
some SLOs, playing with the different\ntime window, budgeting method,
indicators.\nThe main thing to look for are: \n1. Switching indicator
reset the form as expected\n2. When editing an SLO, all the form fields
are populated correctly with\nthe initial SLO values.\n3. Creating an
SLO with a bad indicator, for example with an invalid KQL\nquery, will
redirect to the form with the previous value
filled.\n\n\nhttps://www.loom.com/share/516c3d5a1fa74db6bf839cfa0cf41f5d?sid=f0a1a33f-eb29-4b8f-b65f-ffce2313bad8\n\n---------\n\nCo-authored-by:
Coen Warmer
<coen.warmer@gmail.com>","sha":"f5a111eaabaea6fdeac8ef5793f3e84af6a4a00d","branchLabelMapping":{"^v8.10.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","Team:
Actionable
Observability","backport:prev-minor","v8.10.0"],"number":160081,"url":"https://github.com/elastic/kibana/pull/160081","mergeCommit":{"message":"chore(slo):
improve error handling (#160081)\n\nResolves
https://github.com/elastic/kibana/issues/156145\nResolves
https://github.com/elastic/kibana/issues/160293\n\n## 📝 Summary\n\nThis
PR fixes a bug related to `shouldUnregister` used on controlled\nfields
which removes part of the form state while submitting the form,\ndue to
the components unmounting. This is a weird issue between React\nHook
Form and React Query, as if we were not using optimistic update\nwith
RQ, we won't notice the problem in the first place.\n\nI have done a few
things in this PR:\n1. I've introduced a `CreateSLOForm` type to
decouple what the API is\nexpecting (CreateSLOInput) and how we
structure the form and store the\nform state. This is particularly
useful when building a partial\n`CreateSLOForm` from a partial url state
`_a`.\n2. I've introduced a hook that handles the change of
indicator\ncorrectly, resetting the default value as we change. The
default values\nare typed with each indicator schema type, and the hook
will throw when\na new indicator is introduced but not handled
there.\n3. I've removed the custom metric manual useEffect and instead
rely on\nhidden registered inputs.\n4. The time window type handles
correctly the initial state, and reload\nthe default values when we
change from rolling <-> calendar aligned.\n5. I've grouped some code
from the main form component into their own\nhook to 1. add a layer of
abstraction and 2. make the code more cohesive\n6. When the create SLO
call fails, we redirect the user to the form with\nthe previously
entered values.\n\n\n## 🧪 Testing\n\nI would suggest to create and edit
some SLOs, playing with the different\ntime window, budgeting method,
indicators.\nThe main thing to look for are: \n1. Switching indicator
reset the form as expected\n2. When editing an SLO, all the form fields
are populated correctly with\nthe initial SLO values.\n3. Creating an
SLO with a bad indicator, for example with an invalid KQL\nquery, will
redirect to the form with the previous value
filled.\n\n\nhttps://www.loom.com/share/516c3d5a1fa74db6bf839cfa0cf41f5d?sid=f0a1a33f-eb29-4b8f-b65f-ffce2313bad8\n\n---------\n\nCo-authored-by:
Coen Warmer
<coen.warmer@gmail.com>","sha":"f5a111eaabaea6fdeac8ef5793f3e84af6a4a00d"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v8.10.0","labelRegex":"^v8.10.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/160081","number":160081,"mergeCommit":{"message":"chore(slo):
improve error handling (#160081)\n\nResolves
https://github.com/elastic/kibana/issues/156145\nResolves
https://github.com/elastic/kibana/issues/160293\n\n## 📝 Summary\n\nThis
PR fixes a bug related to `shouldUnregister` used on controlled\nfields
which removes part of the form state while submitting the form,\ndue to
the components unmounting. This is a weird issue between React\nHook
Form and React Query, as if we were not using optimistic update\nwith
RQ, we won't notice the problem in the first place.\n\nI have done a few
things in this PR:\n1. I've introduced a `CreateSLOForm` type to
decouple what the API is\nexpecting (CreateSLOInput) and how we
structure the form and store the\nform state. This is particularly
useful when building a partial\n`CreateSLOForm` from a partial url state
`_a`.\n2. I've introduced a hook that handles the change of
indicator\ncorrectly, resetting the default value as we change. The
default values\nare typed with each indicator schema type, and the hook
will throw when\na new indicator is introduced but not handled
there.\n3. I've removed the custom metric manual useEffect and instead
rely on\nhidden registered inputs.\n4. The time window type handles
correctly the initial state, and reload\nthe default values when we
change from rolling <-> calendar aligned.\n5. I've grouped some code
from the main form component into their own\nhook to 1. add a layer of
abstraction and 2. make the code more cohesive\n6. When the create SLO
call fails, we redirect the user to the form with\nthe previously
entered values.\n\n\n## 🧪 Testing\n\nI would suggest to create and edit
some SLOs, playing with the different\ntime window, budgeting method,
indicators.\nThe main thing to look for are: \n1. Switching indicator
reset the form as expected\n2. When editing an SLO, all the form fields
are populated correctly with\nthe initial SLO values.\n3. Creating an
SLO with a bad indicator, for example with an invalid KQL\nquery, will
redirect to the form with the previous value
filled.\n\n\nhttps://www.loom.com/share/516c3d5a1fa74db6bf839cfa0cf41f5d?sid=f0a1a33f-eb29-4b8f-b65f-ffce2313bad8\n\n---------\n\nCo-authored-by:
Coen Warmer
<coen.warmer@gmail.com>","sha":"f5a111eaabaea6fdeac8ef5793f3e84af6a4a00d"}}]}]
BACKPORT-->

Co-authored-by: Kevin Delemme <kevin.delemme@elastic.co>
This commit is contained in:
Kibana Machine 2023-06-23 13:51:31 -04:00 committed by GitHub
parent fb7c3e9497
commit c61a4d83f8
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 });