diff --git a/x-pack/packages/kbn-slo-schema/src/rest_specs/routes/get_instances.ts b/x-pack/packages/kbn-slo-schema/src/rest_specs/routes/get_instances.ts deleted file mode 100644 index da8f59903eebf..0000000000000 --- a/x-pack/packages/kbn-slo-schema/src/rest_specs/routes/get_instances.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import * as t from 'io-ts'; -import { allOrAnyStringOrArray } from '../../schema'; - -const getSLOInstancesParamsSchema = t.type({ - path: t.type({ id: t.string }), -}); - -const getSLOInstancesResponseSchema = t.type({ - groupBy: allOrAnyStringOrArray, - instances: t.array(t.string), -}); - -type GetSLOInstancesResponse = t.OutputOf; - -export { getSLOInstancesParamsSchema, getSLOInstancesResponseSchema }; -export type { GetSLOInstancesResponse }; diff --git a/x-pack/packages/kbn-slo-schema/src/rest_specs/routes/get_slo_groupings.ts b/x-pack/packages/kbn-slo-schema/src/rest_specs/routes/get_slo_groupings.ts new file mode 100644 index 0000000000000..e0418189e3ae2 --- /dev/null +++ b/x-pack/packages/kbn-slo-schema/src/rest_specs/routes/get_slo_groupings.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import * as t from 'io-ts'; +import { toBooleanRt } from '@kbn/io-ts-utils'; + +const getSLOGroupingsParamsSchema = t.type({ + path: t.type({ id: t.string }), + query: t.intersection([ + t.type({ + instanceId: t.string, + groupingKey: t.string, + }), + t.partial({ + search: t.string, + afterKey: t.string, + size: t.string, + excludeStale: toBooleanRt, + remoteName: t.string, + }), + ]), +}); + +const getSLOGroupingsResponseSchema = t.type({ + groupingKey: t.string, + values: t.array(t.string), + afterKey: t.union([t.string, t.undefined]), +}); + +type GetSLOGroupingsParams = t.TypeOf; +type GetSLOGroupingsResponse = t.OutputOf; + +export { getSLOGroupingsParamsSchema, getSLOGroupingsResponseSchema }; +export type { GetSLOGroupingsResponse, GetSLOGroupingsParams }; diff --git a/x-pack/packages/kbn-slo-schema/src/rest_specs/routes/index.ts b/x-pack/packages/kbn-slo-schema/src/rest_specs/routes/index.ts index 93c06929dff29..32952e649beb8 100644 --- a/x-pack/packages/kbn-slo-schema/src/rest_specs/routes/index.ts +++ b/x-pack/packages/kbn-slo-schema/src/rest_specs/routes/index.ts @@ -13,7 +13,7 @@ export * from './find_group'; export * from './find_definition'; export * from './get'; export * from './get_burn_rates'; -export * from './get_instances'; +export * from './get_slo_groupings'; export * from './get_preview_data'; export * from './reset'; export * from './manage'; diff --git a/x-pack/plugins/observability_solution/slo/public/components/loading_state.tsx b/x-pack/plugins/observability_solution/slo/public/components/loading_state.tsx new file mode 100644 index 0000000000000..6cda8bc13b58e --- /dev/null +++ b/x-pack/plugins/observability_solution/slo/public/components/loading_state.tsx @@ -0,0 +1,19 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; +import React from 'react'; + +export function LoadingState({ dataTestSubj }: { dataTestSubj?: string }) { + return ( + + + + + + ); +} diff --git a/x-pack/plugins/observability_solution/slo/public/components/slo/auto_refresh_button/auto_refresh_button.tsx b/x-pack/plugins/observability_solution/slo/public/components/slo/auto_refresh_button/auto_refresh_button.tsx index bb766f2ad4230..9304bfa9cdb76 100644 --- a/x-pack/plugins/observability_solution/slo/public/components/slo/auto_refresh_button/auto_refresh_button.tsx +++ b/x-pack/plugins/observability_solution/slo/public/components/slo/auto_refresh_button/auto_refresh_button.tsx @@ -32,7 +32,7 @@ export function AutoRefreshButton({ disabled, isAutoRefreshing, onClick }: Props data-test-subj="autoRefreshButton" disabled={disabled} iconSide="left" - iconType="play" + iconType="refresh" onClick={onClick} > {i18n.translate('xpack.slo.slosPage.autoRefreshButtonLabel', { diff --git a/x-pack/plugins/observability_solution/slo/public/hooks/query_key_factory.ts b/x-pack/plugins/observability_solution/slo/public/hooks/query_key_factory.ts index 93f8a76b71db0..fd795ad328b97 100644 --- a/x-pack/plugins/observability_solution/slo/public/hooks/query_key_factory.ts +++ b/x-pack/plugins/observability_solution/slo/public/hooks/query_key_factory.ts @@ -67,6 +67,15 @@ export const sloKeys = { groupings?: Record ) => [...sloKeys.all, 'preview', indicator, range, groupings] as const, burnRateRules: (search: string) => [...sloKeys.all, 'burnRateRules', search], + groupings: (params: { + sloId: string; + instanceId: string; + groupingKey: string; + search?: string; + afterKey?: string; + excludeStale?: boolean; + remoteName?: string; + }) => [...sloKeys.all, 'fetch_slo_groupings', params] as const, }; export type SloKeys = typeof sloKeys; diff --git a/x-pack/plugins/observability_solution/slo/public/pages/slo_details/components/groupings/slo_grouping_value_selector.tsx b/x-pack/plugins/observability_solution/slo/public/pages/slo_details/components/groupings/slo_grouping_value_selector.tsx new file mode 100644 index 0000000000000..f53acffc12625 --- /dev/null +++ b/x-pack/plugins/observability_solution/slo/public/pages/slo_details/components/groupings/slo_grouping_value_selector.tsx @@ -0,0 +1,149 @@ +/* + * 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 { + EuiButtonIcon, + EuiComboBox, + EuiComboBoxOptionOption, + EuiCopy, + EuiFlexItem, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; +import { ALL_VALUE, SLOWithSummaryResponse } from '@kbn/slo-schema'; +import React, { useEffect, useState } from 'react'; +import { useHistory, useLocation } from 'react-router-dom'; +import useDebounce from 'react-use/lib/useDebounce'; +import { SLOS_BASE_PATH } from '../../../../../common/locators/paths'; +import { useFetchSloGroupings } from '../../hooks/use_fetch_slo_instances'; +import { useGetQueryParams } from '../../hooks/use_get_query_params'; + +interface Props { + slo: SLOWithSummaryResponse; + groupingKey: string; + value?: string; +} + +interface Field { + label: string; + value: string; +} + +export function SLOGroupingValueSelector({ slo, groupingKey, value }: Props) { + const isAvailable = window.location.pathname.includes(SLOS_BASE_PATH); + const { search: searchParams } = useLocation(); + const history = useHistory(); + const { remoteName } = useGetQueryParams(); + + const [currentValue, setCurrentValue] = useState(value); + const [options, setOptions] = useState([]); + const [search, setSearch] = useState(undefined); + const [debouncedSearch, setDebouncedSearch] = useState(undefined); + useDebounce(() => setDebouncedSearch(search), 500, [search]); + + const { isLoading, isError, data } = useFetchSloGroupings({ + sloId: slo.id, + groupingKey, + instanceId: slo.instanceId ?? ALL_VALUE, + search: debouncedSearch, + remoteName, + }); + + useEffect(() => { + if (data) { + setSearch(undefined); + setDebouncedSearch(undefined); + setOptions(data.values.map(toField)); + } + }, [data]); + + const onChange = (selected: Array>) => { + const newValue = selected[0].value; + if (!newValue) return; + setCurrentValue(newValue); + + const urlSearchParams = new URLSearchParams(searchParams); + const newGroupings = { ...slo.groupings, [groupingKey]: newValue }; + urlSearchParams.set('instanceId', toInstanceId(newGroupings, slo.groupBy)); + history.replace({ + search: urlSearchParams.toString(), + }); + }; + + return ( + + + css={css` + max-width: 500px; + `} + isClearable={false} + compressed + prepend={groupingKey} + append={ + currentValue ? ( + + {(copy) => ( + + )} + + ) : ( + + ) + } + singleSelection={{ asPlainText: true }} + options={options} + isLoading={isLoading} + isDisabled={isError || !isAvailable} + placeholder={i18n.translate('xpack.slo.sLOGroupingValueSelector.placeholder', { + defaultMessage: 'Select a group value', + })} + selectedOptions={currentValue ? [toField(currentValue)] : []} + onChange={onChange} + truncationProps={{ + truncation: 'end', + }} + onSearchChange={(searchValue: string) => { + if (searchValue !== '') { + setSearch(searchValue); + } + }} + /> + + ); +} + +function toField(value: string): Field { + return { label: value, value }; +} + +function toInstanceId( + groupings: Record, + groupBy: string | string[] +): string { + const groups = [groupBy].flat(); + return groups.map((group) => groupings[group]).join(','); +} diff --git a/x-pack/plugins/observability_solution/slo/public/pages/slo_details/components/groupings/slo_groupings.tsx b/x-pack/plugins/observability_solution/slo/public/pages/slo_details/components/groupings/slo_groupings.tsx new file mode 100644 index 0000000000000..9d5a72695777c --- /dev/null +++ b/x-pack/plugins/observability_solution/slo/public/pages/slo_details/components/groupings/slo_groupings.tsx @@ -0,0 +1,44 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { SLOWithSummaryResponse } from '@kbn/slo-schema'; +import React from 'react'; +import { SLOGroupingValueSelector } from './slo_grouping_value_selector'; + +export function SLOGroupings({ slo }: { slo: SLOWithSummaryResponse }) { + const groupings = Object.entries(slo.groupings ?? {}); + + if (!groupings.length) { + return null; + } + + return ( + + + +

+ {i18n.translate('xpack.slo.sloDetails.groupings.title', { + defaultMessage: 'Instance', + })} +

+
+
+ {groupings.map(([groupingKey, groupingValue]) => { + return ( + + ); + })} +
+ ); +} diff --git a/x-pack/plugins/observability_solution/slo/public/pages/slo_details/components/header_control.stories.tsx b/x-pack/plugins/observability_solution/slo/public/pages/slo_details/components/header_control.stories.tsx index af6338d4a3977..f2c3259b601cc 100644 --- a/x-pack/plugins/observability_solution/slo/public/pages/slo_details/components/header_control.stories.tsx +++ b/x-pack/plugins/observability_solution/slo/public/pages/slo_details/components/header_control.stories.tsx @@ -5,11 +5,10 @@ * 2.0. */ -import React from 'react'; import { ComponentStory } from '@storybook/react'; - -import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator'; +import React from 'react'; import { buildSlo } from '../../../data/slo/slo'; +import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator'; import { HeaderControl as Component, Props } from './header_control'; export default { @@ -22,11 +21,10 @@ const Template: ComponentStory = (props: Props) => setIsPopoverOpen((value) => !value); const closePopover = () => setIsPopoverOpen(false); @@ -92,10 +91,6 @@ export function HeaderControl({ isLoading, slo }: Props) { }); const handleNavigateToApm = () => { - if (!slo) { - return undefined; - } - const url = convertSliApmParamsToApmAppDeeplinkUrl(slo); if (url) { navigateToUrl(basePath.prepend(url)); @@ -105,10 +100,8 @@ export function HeaderControl({ isLoading, slo }: Props) { const navigateToClone = useCloneSlo(); const handleClone = async () => { - if (slo) { - setIsPopoverOpen(false); - navigateToClone(slo); - } + setIsPopoverOpen(false); + navigateToClone(slo); }; const handleDelete = () => { @@ -140,11 +133,9 @@ export function HeaderControl({ isLoading, slo }: Props) { }; const handleResetConfirm = async () => { - if (slo) { - await resetSlo({ id: slo.id, name: slo.name }); - removeResetQueryParam(); - setResetConfirmationModalOpen(false); - } + await resetSlo({ id: slo.id, name: slo.name }); + removeResetQueryParam(); + setResetConfirmationModalOpen(false); }; const handleResetCancel = () => { @@ -182,8 +173,6 @@ export function HeaderControl({ isLoading, slo }: Props) { iconType="arrowDown" iconSize="s" onClick={handleActionsClick} - isLoading={isLoading} - disabled={isLoading} > {i18n.translate('xpack.slo.sloDetails.headerControl.actions', { defaultMessage: 'Actions', @@ -315,7 +304,7 @@ export function HeaderControl({ isLoading, slo }: Props) { refetchRules={refetchRules} /> - {slo && isRuleFlyoutVisible ? ( + {isRuleFlyoutVisible ? ( ) : null} - {slo && isDeleteConfirmationModalOpen ? ( + {isDeleteConfirmationModalOpen ? ( ) : null} - {slo && isResetConfirmationModalOpen ? ( + {isResetConfirmationModalOpen ? ( ; + return ; } return ( - - - + - + ); } diff --git a/x-pack/plugins/observability_solution/slo/public/pages/slo_details/hooks/use_fetch_slo_instances.tsx b/x-pack/plugins/observability_solution/slo/public/pages/slo_details/hooks/use_fetch_slo_instances.tsx new file mode 100644 index 0000000000000..0fc78e553e7d3 --- /dev/null +++ b/x-pack/plugins/observability_solution/slo/public/pages/slo_details/hooks/use_fetch_slo_instances.tsx @@ -0,0 +1,67 @@ +/* + * 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 { ALL_VALUE, GetSLOGroupingsResponse } from '@kbn/slo-schema'; +import { useQuery } from '@tanstack/react-query'; +import { sloKeys } from '../../../hooks/query_key_factory'; +import { usePluginContext } from '../../../hooks/use_plugin_context'; + +interface Params { + sloId: string; + groupingKey: string; + instanceId: string; + afterKey?: string; + search?: string; + remoteName?: string; +} + +interface UseFetchSloGroupingsResponse { + data: GetSLOGroupingsResponse | undefined; + isLoading: boolean; + isError: boolean; +} + +export function useFetchSloGroupings({ + sloId, + groupingKey, + instanceId, + afterKey, + search, + remoteName, +}: Params): UseFetchSloGroupingsResponse { + const { sloClient } = usePluginContext(); + + const { isLoading, isError, data } = useQuery({ + queryKey: sloKeys.groupings({ sloId, groupingKey, instanceId, afterKey, search, remoteName }), + queryFn: async ({ signal }) => { + try { + return await sloClient.fetch(`GET /internal/observability/slos/{id}/_groupings`, { + params: { + path: { id: sloId }, + query: { + search, + instanceId, + groupingKey, + afterKey, + excludeStale: true, + remoteName, + }, + }, + signal, + }); + } catch (error) { + throw new Error(`Something went wrong. Error: ${error}`); + } + }, + enabled: Boolean(!!sloId && !!groupingKey && instanceId !== ALL_VALUE), + staleTime: 60 * 1000, + retry: false, + refetchOnWindowFocus: false, + }); + + return { isLoading, isError, data }; +} diff --git a/x-pack/plugins/observability_solution/slo/public/pages/slo_details/hooks/use_slo_details_tabs.tsx b/x-pack/plugins/observability_solution/slo/public/pages/slo_details/hooks/use_slo_details_tabs.tsx index 83acc81a68716..4b7389e969f32 100644 --- a/x-pack/plugins/observability_solution/slo/public/pages/slo_details/hooks/use_slo_details_tabs.tsx +++ b/x-pack/plugins/observability_solution/slo/public/pages/slo_details/hooks/use_slo_details_tabs.tsx @@ -5,13 +5,13 @@ * 2.0. */ -import { i18n } from '@kbn/i18n'; import { EuiNotificationBadge, EuiToolTip } from '@elastic/eui'; -import React from 'react'; +import { i18n } from '@kbn/i18n'; import { ALL_VALUE, SLOWithSummaryResponse } from '@kbn/slo-schema'; +import React from 'react'; import { paths } from '../../../../common/locators/paths'; -import { useKibana } from '../../../hooks/use_kibana'; import { useFetchActiveAlerts } from '../../../hooks/use_fetch_active_alerts'; +import { useKibana } from '../../../hooks/use_kibana'; import { ALERTS_TAB_ID, HISTORY_TAB_ID, diff --git a/x-pack/plugins/observability_solution/slo/public/pages/slo_details/slo_details.tsx b/x-pack/plugins/observability_solution/slo/public/pages/slo_details/slo_details.tsx index 38f65bb341070..491c850bf03b8 100644 --- a/x-pack/plugins/observability_solution/slo/public/pages/slo_details/slo_details.tsx +++ b/x-pack/plugins/observability_solution/slo/public/pages/slo_details/slo_details.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiLoadingSpinner, EuiSkeletonText } from '@elastic/eui'; +import { EuiSkeletonText } from '@elastic/eui'; import type { ChromeBreadcrumb } from '@kbn/core-chrome-browser'; import type { IBasePath } from '@kbn/core-http-browser'; import { usePerformanceContext } from '@kbn/ebt-tools'; @@ -16,15 +16,16 @@ import { useIsMutating } from '@tanstack/react-query'; import dedent from 'dedent'; import React, { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; +import { LoadingState } from '../../components/loading_state'; import { paths } from '../../../common/locators/paths'; import { HeaderMenu } from '../../components/header_menu/header_menu'; import { AutoRefreshButton } from '../../components/slo/auto_refresh_button'; import { useAutoRefreshStorage } from '../../components/slo/auto_refresh_button/hooks/use_auto_refresh_storage'; import { useFetchSloDetails } from '../../hooks/use_fetch_slo_details'; +import { useKibana } from '../../hooks/use_kibana'; import { useLicense } from '../../hooks/use_license'; import { usePermissions } from '../../hooks/use_permissions'; import { usePluginContext } from '../../hooks/use_plugin_context'; -import { useKibana } from '../../hooks/use_kibana'; import PageNotFound from '../404'; import { HeaderControl } from './components/header_control'; import { HeaderTitle } from './components/header_title'; @@ -125,21 +126,23 @@ export function SloDetailsPage() { pageHeader={{ pageTitle: slo?.name ?? , children: , - rightSideItems: [ - , - , - ], + rightSideItems: !isLoading + ? [ + , + , + ] + : undefined, tabs, }} data-test-subj="sloDetailsPage" > - {isLoading && } - {!isLoading && ( + {isLoading ? ( + + ) : ( )} diff --git a/x-pack/plugins/observability_solution/slo/server/domain/models/common.ts b/x-pack/plugins/observability_solution/slo/server/domain/models/common.ts index 06eb5e11fd2ba..4c58c3e51624c 100644 --- a/x-pack/plugins/observability_solution/slo/server/domain/models/common.ts +++ b/x-pack/plugins/observability_solution/slo/server/domain/models/common.ts @@ -29,6 +29,7 @@ type Meta = t.TypeOf; type GroupSummary = t.TypeOf; type GroupBy = t.TypeOf; type StoredSLOSettings = t.OutputOf; +type SLOSettings = t.TypeOf; export type { Objective, @@ -41,4 +42,5 @@ export type { GroupBy, GroupSummary, StoredSLOSettings, + SLOSettings, }; diff --git a/x-pack/plugins/observability_solution/slo/server/domain/models/slo.ts b/x-pack/plugins/observability_solution/slo/server/domain/models/slo.ts index 1fbaae2b81706..953d0a621b6bd 100644 --- a/x-pack/plugins/observability_solution/slo/server/domain/models/slo.ts +++ b/x-pack/plugins/observability_solution/slo/server/domain/models/slo.ts @@ -10,7 +10,6 @@ import * as t from 'io-ts'; type SLODefinition = t.TypeOf; type StoredSLODefinition = t.OutputOf; - type SLOId = t.TypeOf; export type { SLODefinition, StoredSLODefinition, SLOId }; diff --git a/x-pack/plugins/observability_solution/slo/server/routes/slo/route.ts b/x-pack/plugins/observability_solution/slo/server/routes/slo/route.ts index ed2542bb67cb5..a7589de5d0909 100644 --- a/x-pack/plugins/observability_solution/slo/server/routes/slo/route.ts +++ b/x-pack/plugins/observability_solution/slo/server/routes/slo/route.ts @@ -20,7 +20,7 @@ import { findSloDefinitionsParamsSchema, getPreviewDataParamsSchema, getSLOBurnRatesParamsSchema, - getSLOInstancesParamsSchema, + getSLOGroupingsParamsSchema, getSLOParamsSchema, manageSLOParamsSchema, putSLOServerlessSettingsParamsSchema, @@ -49,7 +49,7 @@ import { FindSLODefinitions } from '../../services/find_slo_definitions'; import { getBurnRates } from '../../services/get_burn_rates'; import { getGlobalDiagnosis } from '../../services/get_diagnosis'; import { GetPreviewData } from '../../services/get_preview_data'; -import { GetSLOInstances } from '../../services/get_slo_instances'; +import { GetSLOGroupings } from '../../services/get_slo_groupings'; import { GetSLOSuggestions } from '../../services/get_slo_suggestions'; import { GetSLOsOverview } from '../../services/get_slos_overview'; import { DefaultHistoricalSummaryClient } from '../../services/historical_summary_client'; @@ -598,24 +598,32 @@ const fetchHistoricalSummary = createSloServerRoute({ }, }); -const getSLOInstancesRoute = createSloServerRoute({ - endpoint: 'GET /internal/observability/slos/{id}/_instances', +const getSLOGroupingsRoute = createSloServerRoute({ + endpoint: 'GET /internal/observability/slos/{id}/_groupings', options: { access: 'internal' }, security: { authz: { requiredPrivileges: ['slo_read'], }, }, - params: getSLOInstancesParamsSchema, - handler: async ({ context, params, logger, plugins }) => { + params: getSLOGroupingsParamsSchema, + handler: async ({ context, params, request, logger, plugins }) => { await assertPlatinumLicense(plugins); - const soClient = (await context.core).savedObjects.client; const esClient = (await context.core).elasticsearch.client.asCurrentUser; + const [spaceId, settings] = await Promise.all([ + getSpaceId(plugins, request), + getSloSettings(soClient), + ]); + const repository = new KibanaSavedObjectsSLORepository(soClient, logger); - const getSLOInstances = new GetSLOInstances(repository, esClient); + const definitionClient = new SloDefinitionClient(repository, esClient, logger); + + const getSLOGroupings = new GetSLOGroupings(definitionClient, esClient, settings, spaceId); - return await executeWithErrorHandler(() => getSLOInstances.execute(params.path.id)); + return await executeWithErrorHandler(() => + getSLOGroupings.execute(params.path.id, params.query) + ); }, }); @@ -819,7 +827,7 @@ export const getSloRouteRepository = (isServerless?: boolean) => { ...getDiagnosisRoute, ...getSloBurnRates, ...getPreviewData, - ...getSLOInstancesRoute, + ...getSLOGroupingsRoute, ...resetSLORoute, ...findSLOGroupsRoute, ...getSLOSuggestionsRoute, diff --git a/x-pack/plugins/observability_solution/slo/server/services/__snapshots__/get_slo_instances.test.ts.snap b/x-pack/plugins/observability_solution/slo/server/services/__snapshots__/get_slo_instances.test.ts.snap deleted file mode 100644 index 8ad9792a22b24..0000000000000 --- a/x-pack/plugins/observability_solution/slo/server/services/__snapshots__/get_slo_instances.test.ts.snap +++ /dev/null @@ -1,41 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Get SLO Instances returns all instances of a SLO defined with a 'groupBy' 1`] = ` -Array [ - Object { - "aggs": Object { - "instances": Object { - "terms": Object { - "field": "slo.instanceId", - "size": 1000, - }, - }, - }, - "index": ".slo-observability.sli-v3*", - "query": Object { - "bool": Object { - "filter": Array [ - Object { - "range": Object { - "@timestamp": Object { - "gte": "now-7d", - }, - }, - }, - Object { - "term": Object { - "slo.id": "slo-id", - }, - }, - Object { - "term": Object { - "slo.revision": 2, - }, - }, - ], - }, - }, - "size": 0, - }, -] -`; diff --git a/x-pack/plugins/observability_solution/slo/server/services/get_slo_groupings.test.ts b/x-pack/plugins/observability_solution/slo/server/services/get_slo_groupings.test.ts new file mode 100644 index 0000000000000..8884e377afefb --- /dev/null +++ b/x-pack/plugins/observability_solution/slo/server/services/get_slo_groupings.test.ts @@ -0,0 +1,93 @@ +/* + * 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 { ElasticsearchClientMock, elasticsearchServiceMock } from '@kbn/core/server/mocks'; +import { loggerMock } from '@kbn/logging-mocks'; +import { ALL_VALUE } from '@kbn/slo-schema'; +import { GetSLOGroupings, SLORepository } from '.'; +import { createSLO } from './fixtures/slo'; +import { createSLORepositoryMock } from './mocks'; +import { SloDefinitionClient } from './slo_definition_client'; + +const DEFAULT_SETTINGS = { + selectedRemoteClusters: [], + staleThresholdInHours: 1, + useAllRemoteClusters: false, +}; + +describe('Get SLO Instances', () => { + let repositoryMock: jest.Mocked; + let esClientMock: ElasticsearchClientMock; + let definitionClient: SloDefinitionClient; + + beforeEach(() => { + repositoryMock = createSLORepositoryMock(); + esClientMock = elasticsearchServiceMock.createElasticsearchClient(); + definitionClient = new SloDefinitionClient( + repositoryMock, + elasticsearchServiceMock.createElasticsearchClient(), + loggerMock.create() + ); + }); + + it('throws when the SLO is ungrouped', async () => { + const slo = createSLO({ groupBy: ALL_VALUE }); + repositoryMock.findById.mockResolvedValue(slo); + + const service = new GetSLOGroupings( + definitionClient, + esClientMock, + DEFAULT_SETTINGS, + 'default' + ); + + await expect( + service.execute(slo.id, { + instanceId: 'irrelevant', + groupingKey: 'irrelevant', + }) + ).rejects.toThrowError('Ungrouped SLO cannot be queried for available groupings'); + }); + + it('throws when the provided groupingKey is not part of the SLO groupBy field', async () => { + const slo = createSLO({ groupBy: ['abc.efg', 'host.name'] }); + repositoryMock.findById.mockResolvedValue(slo); + + const service = new GetSLOGroupings( + definitionClient, + esClientMock, + DEFAULT_SETTINGS, + 'default' + ); + + await expect( + service.execute(slo.id, { + instanceId: 'irrelevant', + groupingKey: 'not.found', + }) + ).rejects.toThrowError("Provided groupingKey doesn't match the SLO's groupBy field"); + }); + + it('throws when the provided instanceId cannot be matched against the SLO grouping keys', async () => { + const slo = createSLO({ groupBy: ['abc.efg', 'host.name'] }); + repositoryMock.findById.mockResolvedValue(slo); + + const service = new GetSLOGroupings( + definitionClient, + esClientMock, + DEFAULT_SETTINGS, + 'default' + ); + + await expect( + service.execute(slo.id, { + instanceId: 'too,many,values', + groupingKey: 'host.name', + }) + ).rejects.toThrowError('Provided instanceId does not match the number of grouping keys'); + }); +}); diff --git a/x-pack/plugins/observability_solution/slo/server/services/get_slo_groupings.ts b/x-pack/plugins/observability_solution/slo/server/services/get_slo_groupings.ts new file mode 100644 index 0000000000000..81ed1c0c7518f --- /dev/null +++ b/x-pack/plugins/observability_solution/slo/server/services/get_slo_groupings.ts @@ -0,0 +1,157 @@ +/* + * 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 { AggregationsCompositeAggregation } from '@elastic/elasticsearch/lib/api/types'; +import { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import { ALL_VALUE, GetSLOGroupingsParams, GetSLOGroupingsResponse } from '@kbn/slo-schema'; +import { SLO_SUMMARY_DESTINATION_INDEX_NAME } from '../../common/constants'; +import { SLODefinition, SLOSettings } from '../domain/models'; +import { SloDefinitionClient } from './slo_definition_client'; + +const DEFAULT_SIZE = 100; + +export class GetSLOGroupings { + constructor( + private definitionClient: SloDefinitionClient, + private esClient: ElasticsearchClient, + private sloSettings: SLOSettings, + private spaceId: string + ) {} + + public async execute( + sloId: string, + params: GetSLOGroupingsParams + ): Promise { + const { slo } = await this.definitionClient.execute(sloId, this.spaceId, params.remoteName); + + const groupingKeys = [slo.groupBy].flat(); + if (groupingKeys.includes(ALL_VALUE) || params.instanceId === ALL_VALUE) { + throw new Error('Ungrouped SLO cannot be queried for available groupings'); + } + + if (!groupingKeys.includes(params.groupingKey)) { + throw new Error("Provided groupingKey doesn't match the SLO's groupBy field"); + } + + const groupingValues = params.instanceId.split(',') ?? []; + if (groupingKeys.length !== groupingValues.length) { + throw new Error('Provided instanceId does not match the number of grouping keys'); + } + + const response = await this.esClient.search< + unknown, + { + groupingValues: { + buckets: Array<{ key: { value: string } }>; + after_key: { value: string }; + }; + } + >({ + index: params.remoteName + ? `${params.remoteName}:${SLO_SUMMARY_DESTINATION_INDEX_NAME}` + : SLO_SUMMARY_DESTINATION_INDEX_NAME, + ...generateQuery(slo, params, this.sloSettings), + }); + + return { + groupingKey: params.groupingKey, + values: response.aggregations?.groupingValues.buckets.map((bucket) => bucket.key.value) ?? [], + afterKey: + response.aggregations?.groupingValues.buckets.length === Number(params.size ?? DEFAULT_SIZE) + ? response.aggregations?.groupingValues.after_key.value + : undefined, + }; + } +} + +function generateQuery(slo: SLODefinition, params: GetSLOGroupingsParams, settings: SLOSettings) { + const groupingKeys = [slo.groupBy].flat(); + const groupingValues = params.instanceId.split(',') ?? []; + + const groupingKeyValuePairs = groupingKeys.map((groupingKey, index) => [ + groupingKey, + groupingValues[index], + ]); + + const aggs = generateAggs(params); + + const query = { + size: 0, + query: { + bool: { + filter: [ + { + term: { + 'slo.id': slo.id, + }, + }, + { + term: { + 'slo.revision': slo.revision, + }, + }, + // exclude stale summary documents if specified + ...(!!params.excludeStale + ? [ + { + range: { + summaryUpdatedAt: { + gte: `now-${settings.staleThresholdInHours}h`, + }, + }, + }, + ] + : []), + // Set other groupings as term filters + ...groupingKeyValuePairs + .filter(([groupingKey]) => groupingKey !== params.groupingKey) + .map(([groupingKey, groupingValue]) => ({ + term: { + [`slo.groupings.${groupingKey}`]: groupingValue, + }, + })), + // search on the specified groupingKey + ...(params.search + ? [ + { + query_string: { + default_field: `slo.groupings.${params.groupingKey}`, + query: `*${params.search.replace(/^\*/, '').replace(/\*$/, '')}*`, + }, + }, + ] + : []), + ], + }, + }, + aggs, + }; + + return query; +} + +function generateAggs(params: GetSLOGroupingsParams): { + groupingValues: { composite: AggregationsCompositeAggregation }; +} { + return { + groupingValues: { + composite: { + size: Number(params.size ?? DEFAULT_SIZE), + sources: [ + { + value: { + terms: { + field: `slo.groupings.${params.groupingKey}`, + }, + }, + }, + ], + ...(params.afterKey ? { after: { value: params.afterKey } } : {}), + }, + }, + }; +} diff --git a/x-pack/plugins/observability_solution/slo/server/services/get_slo_instances.test.ts b/x-pack/plugins/observability_solution/slo/server/services/get_slo_instances.test.ts deleted file mode 100644 index e8bce36fd18ef..0000000000000 --- a/x-pack/plugins/observability_solution/slo/server/services/get_slo_instances.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { ElasticsearchClientMock, elasticsearchServiceMock } from '@kbn/core/server/mocks'; -import { createSLO } from './fixtures/slo'; -import { GetSLOInstances, SLORepository } from '.'; -import { createSLORepositoryMock } from './mocks'; -import { ALL_VALUE } from '@kbn/slo-schema'; - -describe('Get SLO Instances', () => { - let repositoryMock: jest.Mocked; - let esClientMock: ElasticsearchClientMock; - - beforeEach(() => { - repositoryMock = createSLORepositoryMock(); - esClientMock = elasticsearchServiceMock.createElasticsearchClient(); - }); - - it("returns an empty response when the SLO has no 'groupBy' defined", async () => { - const slo = createSLO({ groupBy: ALL_VALUE }); - repositoryMock.findById.mockResolvedValue(slo); - - const service = new GetSLOInstances(repositoryMock, esClientMock); - - const result = await service.execute(slo.id); - - expect(result).toEqual({ groupBy: ALL_VALUE, instances: [] }); - }); - - it("returns all instances of a SLO defined with a 'groupBy'", async () => { - const slo = createSLO({ id: 'slo-id', revision: 2, groupBy: 'field.to.host' }); - repositoryMock.findById.mockResolvedValue(slo); - esClientMock.search.mockResolvedValue({ - took: 100, - timed_out: false, - _shards: { - total: 0, - successful: 0, - skipped: 0, - failed: 0, - }, - hits: { - hits: [], - }, - aggregations: { - instances: { - buckets: [ - { key: 'host-aaa', doc_value: 100 }, - { key: 'host-bbb', doc_value: 200 }, - { key: 'host-ccc', doc_value: 500 }, - ], - }, - }, - }); - - const service = new GetSLOInstances(repositoryMock, esClientMock); - - const result = await service.execute(slo.id); - - expect(result).toEqual({ - groupBy: 'field.to.host', - instances: ['host-aaa', 'host-bbb', 'host-ccc'], - }); - expect(esClientMock.search.mock.calls[0]).toMatchSnapshot(); - }); -}); diff --git a/x-pack/plugins/observability_solution/slo/server/services/get_slo_instances.ts b/x-pack/plugins/observability_solution/slo/server/services/get_slo_instances.ts deleted file mode 100644 index c95c2275547ae..0000000000000 --- a/x-pack/plugins/observability_solution/slo/server/services/get_slo_instances.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; -import { ALL_VALUE, GetSLOInstancesResponse } from '@kbn/slo-schema'; -import { SLO_DESTINATION_INDEX_PATTERN } from '../../common/constants'; -import { SLORepository } from './slo_repository'; - -export class GetSLOInstances { - constructor(private repository: SLORepository, private esClient: ElasticsearchClient) {} - - public async execute(sloId: string): Promise { - const slo = await this.repository.findById(sloId); - - if ([slo.groupBy].flat().includes(ALL_VALUE)) { - return { groupBy: ALL_VALUE, instances: [] }; - } - - const result = await this.esClient.search({ - index: SLO_DESTINATION_INDEX_PATTERN, - size: 0, - query: { - bool: { - filter: [ - { range: { '@timestamp': { gte: 'now-7d' } } }, - { term: { 'slo.id': slo.id } }, - { term: { 'slo.revision': slo.revision } }, - ], - }, - }, - aggs: { - instances: { - terms: { - size: 1000, - field: 'slo.instanceId', - }, - }, - }, - }); - - // @ts-ignore - const buckets = result?.aggregations?.instances.buckets ?? []; - const instances = buckets.map((bucket: { key: string }) => bucket.key); - return { groupBy: slo.groupBy, instances }; - } -} diff --git a/x-pack/plugins/observability_solution/slo/server/services/index.ts b/x-pack/plugins/observability_solution/slo/server/services/index.ts index 6c9bdc906914c..4688a34740c63 100644 --- a/x-pack/plugins/observability_solution/slo/server/services/index.ts +++ b/x-pack/plugins/observability_solution/slo/server/services/index.ts @@ -19,6 +19,6 @@ export * from './transform_manager'; export * from './summay_transform_manager'; export * from './update_slo'; export * from './summary_client'; -export * from './get_slo_instances'; +export * from './get_slo_groupings'; export * from './find_slo_groups'; export * from './get_slo_health'; diff --git a/x-pack/plugins/observability_solution/slo/server/services/slo_settings.ts b/x-pack/plugins/observability_solution/slo/server/services/slo_settings.ts index e3bce05843374..3874ab808dc34 100644 --- a/x-pack/plugins/observability_solution/slo/server/services/slo_settings.ts +++ b/x-pack/plugins/observability_solution/slo/server/services/slo_settings.ts @@ -14,10 +14,12 @@ import { SLO_SUMMARY_DESTINATION_INDEX_PATTERN, } from '../../common/constants'; import { getListOfSloSummaryIndices } from '../../common/summary_indices'; -import { StoredSLOSettings } from '../domain/models'; -import { sloSettingsObjectId, SO_SLO_SETTINGS_TYPE } from '../saved_objects/slo_settings'; +import { SLOSettings, StoredSLOSettings } from '../domain/models'; +import { SO_SLO_SETTINGS_TYPE, sloSettingsObjectId } from '../saved_objects/slo_settings'; -export const getSloSettings = async (soClient: SavedObjectsClientContract) => { +export const getSloSettings = async ( + soClient: SavedObjectsClientContract +): Promise => { try { const soObject = await soClient.get( SO_SLO_SETTINGS_TYPE, @@ -41,7 +43,7 @@ export const getSloSettings = async (soClient: SavedObjectsClientContract) => { export const storeSloSettings = async ( soClient: SavedObjectsClientContract, params: PutSLOSettingsParams -) => { +): Promise => { const object = await soClient.create( SO_SLO_SETTINGS_TYPE, sloSettingsSchema.encode(params),