feat(slo): SLO grouping values selector (#202364)

This commit is contained in:
Kevin Delemme 2024-12-05 13:51:03 -05:00 committed by GitHub
parent c5cc1532d7
commit 7806861c5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 645 additions and 254 deletions

View file

@ -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<typeof getSLOInstancesResponseSchema>;
export { getSLOInstancesParamsSchema, getSLOInstancesResponseSchema };
export type { GetSLOInstancesResponse };

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * 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<typeof getSLOGroupingsParamsSchema.props.query>;
type GetSLOGroupingsResponse = t.OutputOf<typeof getSLOGroupingsResponseSchema>;
export { getSLOGroupingsParamsSchema, getSLOGroupingsResponseSchema };
export type { GetSLOGroupingsResponse, GetSLOGroupingsParams };

View file

@ -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';

View file

@ -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 (
<EuiFlexGroup alignItems="center" justifyContent="center" style={{ height: '100%' }}>
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="xxl" data-test-subj={dataTestSubj} />
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -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', {

View file

@ -67,6 +67,15 @@ export const sloKeys = {
groupings?: Record<string, unknown>
) => [...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;

View file

@ -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<string | undefined>(value);
const [options, setOptions] = useState<Field[]>([]);
const [search, setSearch] = useState<string | undefined>(undefined);
const [debouncedSearch, setDebouncedSearch] = useState<string | undefined>(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<EuiComboBoxOptionOption<string>>) => {
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 (
<EuiFlexItem grow={false}>
<EuiComboBox<string>
css={css`
max-width: 500px;
`}
isClearable={false}
compressed
prepend={groupingKey}
append={
currentValue ? (
<EuiCopy textToCopy={currentValue}>
{(copy) => (
<EuiButtonIcon
data-test-subj="sloSLOGroupingValueSelectorButton"
color="text"
iconType="copyClipboard"
onClick={copy}
aria-label={i18n.translate(
'xpack.slo.sLOGroupingValueSelector.copyButton.label',
{
defaultMessage: 'Copy value to clipboard',
}
)}
/>
)}
</EuiCopy>
) : (
<EuiButtonIcon
data-test-subj="sloSLOGroupingValueSelectorButton"
color="text"
disabled={true}
iconType="copyClipboard"
aria-label={i18n.translate(
'xpack.slo.sLOGroupingValueSelector.copyButton.noValueLabel',
{ defaultMessage: 'Select a value before' }
)}
/>
)
}
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);
}
}}
/>
</EuiFlexItem>
);
}
function toField(value: string): Field {
return { label: value, value };
}
function toInstanceId(
groupings: Record<string, string | number>,
groupBy: string | string[]
): string {
const groups = [groupBy].flat();
return groups.map((group) => groupings[group]).join(',');
}

View file

@ -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 (
<EuiFlexGroup direction="row" gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiText size="xs">
<h4>
{i18n.translate('xpack.slo.sloDetails.groupings.title', {
defaultMessage: 'Instance',
})}
</h4>
</EuiText>
</EuiFlexItem>
{groupings.map(([groupingKey, groupingValue]) => {
return (
<SLOGroupingValueSelector
key={groupingKey}
slo={slo}
groupingKey={groupingKey}
value={String(groupingValue)}
/>
);
})}
</EuiFlexGroup>
);
}

View file

@ -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<typeof Component> = (props: Props) => <Component
const defaultProps: Props = {
slo: buildSlo(),
isLoading: false,
};
export const Default = Template.bind({});
Default.args = defaultProps;
export const WithLoading = Template.bind({});
WithLoading.args = { slo: undefined, isLoading: true };
WithLoading.args = { slo: undefined };

View file

@ -22,9 +22,9 @@ import { SloDeleteModal } from '../../../components/slo/delete_confirmation_moda
import { SloResetConfirmationModal } from '../../../components/slo/reset_confirmation_modal/slo_reset_confirmation_modal';
import { useCloneSlo } from '../../../hooks/use_clone_slo';
import { useFetchRulesForSlo } from '../../../hooks/use_fetch_rules_for_slo';
import { useKibana } from '../../../hooks/use_kibana';
import { usePermissions } from '../../../hooks/use_permissions';
import { useResetSlo } from '../../../hooks/use_reset_slo';
import { useKibana } from '../../../hooks/use_kibana';
import { convertSliApmParamsToApmAppDeeplinkUrl } from '../../../utils/slo/convert_sli_apm_params_to_apm_app_deeplink_url';
import { isApmIndicatorType } from '../../../utils/slo/indicator';
import { EditBurnRateRuleFlyout } from '../../slos/components/common/edit_burn_rate_rule_flyout';
@ -32,11 +32,10 @@ import { useGetQueryParams } from '../hooks/use_get_query_params';
import { useSloActions } from '../hooks/use_slo_actions';
export interface Props {
slo?: SLOWithSummaryResponse;
isLoading: boolean;
slo: SLOWithSummaryResponse;
}
export function HeaderControl({ isLoading, slo }: Props) {
export function HeaderControl({ slo }: Props) {
const {
application: { navigateToUrl, capabilities },
http: { basePath },
@ -58,10 +57,10 @@ export function HeaderControl({ isLoading, slo }: Props) {
const { mutateAsync: resetSlo, isLoading: isResetLoading } = useResetSlo();
const { data: rulesBySlo, refetchRules } = useFetchRulesForSlo({
sloIds: slo ? [slo.id] : undefined,
sloIds: [slo.id],
});
const rules = slo ? rulesBySlo?.[slo?.id] ?? [] : [];
const rules = rulesBySlo?.[slo.id] ?? [];
const handleActionsClick = () => 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 ? (
<AddRuleFlyout
consumer={sloFeatureId}
ruleTypeId={SLO_BURN_RATE_RULE_TYPE_ID}
@ -326,11 +315,11 @@ export function HeaderControl({ isLoading, slo }: Props) {
/>
) : null}
{slo && isDeleteConfirmationModalOpen ? (
{isDeleteConfirmationModalOpen ? (
<SloDeleteModal slo={slo} onCancel={handleDeleteCancel} onSuccess={handleDeleteConfirm} />
) : null}
{slo && isResetConfirmationModalOpen ? (
{isResetConfirmationModalOpen ? (
<SloResetConfirmationModal
slo={slo}
onCancel={handleResetCancel}

View file

@ -5,14 +5,14 @@
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem, EuiSkeletonText, EuiSpacer, EuiText } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiSkeletonText, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import moment from 'moment';
import React from 'react';
import { SloRemoteBadge } from '../../slos/components/badges/slo_remote_badge';
import { SLOGroupings } from '../../slos/components/common/slo_groupings';
import { SloStatusBadge } from '../../../components/slo/slo_status_badge';
import { SloRemoteBadge } from '../../slos/components/badges/slo_remote_badge';
import { SLOGroupings } from './groupings/slo_groupings';
export interface Props {
slo?: SLOWithSummaryResponse;
@ -21,13 +21,11 @@ export interface Props {
export function HeaderTitle({ isLoading, slo }: Props) {
if (isLoading || !slo) {
return <EuiSkeletonText lines={1} data-test-subj="loadingTitle" />;
return <EuiSkeletonText lines={2} data-test-subj="loadingTitle" />;
}
return (
<EuiFlexGroup direction="column" gutterSize="xs">
<SLOGroupings slo={slo} />
<EuiFlexGroup direction="column" gutterSize="m">
<EuiFlexGroup
direction="row"
gutterSize="s"
@ -61,7 +59,7 @@ export function HeaderTitle({ isLoading, slo }: Props) {
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<SLOGroupings slo={slo} />
</EuiFlexGroup>
);
}

View file

@ -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 };
}

View file

@ -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,

View file

@ -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 ?? <EuiSkeletonText lines={1} />,
children: <HeaderTitle isLoading={isPerformingAction} slo={slo} />,
rightSideItems: [
<HeaderControl isLoading={isPerformingAction} slo={slo} />,
<AutoRefreshButton
disabled={isPerformingAction}
isAutoRefreshing={isAutoRefreshing}
onClick={handleToggleAutoRefresh}
/>,
],
rightSideItems: !isLoading
? [
<HeaderControl slo={slo!} />,
<AutoRefreshButton
isAutoRefreshing={isAutoRefreshing}
onClick={handleToggleAutoRefresh}
/>,
]
: undefined,
tabs,
}}
data-test-subj="sloDetailsPage"
>
<HeaderMenu />
{isLoading && <EuiLoadingSpinner data-test-subj="sloDetailsLoading" />}
{!isLoading && (
{isLoading ? (
<LoadingState dataTestSubj="sloDetailsLoading" />
) : (
<SloDetails slo={slo!} isAutoRefreshing={isAutoRefreshing} selectedTabId={selectedTabId} />
)}
</ObservabilityPageTemplate>

View file

@ -29,6 +29,7 @@ type Meta = t.TypeOf<typeof metaSchema>;
type GroupSummary = t.TypeOf<typeof groupSummarySchema>;
type GroupBy = t.TypeOf<typeof groupBySchema>;
type StoredSLOSettings = t.OutputOf<typeof sloSettingsSchema>;
type SLOSettings = t.TypeOf<typeof sloSettingsSchema>;
export type {
Objective,
@ -41,4 +42,5 @@ export type {
GroupBy,
GroupSummary,
StoredSLOSettings,
SLOSettings,
};

View file

@ -10,7 +10,6 @@ import * as t from 'io-ts';
type SLODefinition = t.TypeOf<typeof sloDefinitionSchema>;
type StoredSLODefinition = t.OutputOf<typeof sloDefinitionSchema>;
type SLOId = t.TypeOf<typeof sloIdSchema>;
export type { SLODefinition, StoredSLODefinition, SLOId };

View file

@ -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 repository = new KibanaSavedObjectsSLORepository(soClient, logger);
const getSLOInstances = new GetSLOInstances(repository, esClient);
const [spaceId, settings] = await Promise.all([
getSpaceId(plugins, request),
getSloSettings(soClient),
]);
return await executeWithErrorHandler(() => getSLOInstances.execute(params.path.id));
const repository = new KibanaSavedObjectsSLORepository(soClient, logger);
const definitionClient = new SloDefinitionClient(repository, esClient, logger);
const getSLOGroupings = new GetSLOGroupings(definitionClient, esClient, settings, spaceId);
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,

View file

@ -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,
},
]
`;

View file

@ -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<SLORepository>;
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');
});
});

View file

@ -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<GetSLOGroupingsResponse> {
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 } } : {}),
},
},
};
}

View file

@ -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<SLORepository>;
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();
});
});

View file

@ -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<GetSLOInstancesResponse> {
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 };
}
}

View file

@ -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';

View file

@ -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<SLOSettings> => {
try {
const soObject = await soClient.get<StoredSLOSettings>(
SO_SLO_SETTINGS_TYPE,
@ -41,7 +43,7 @@ export const getSloSettings = async (soClient: SavedObjectsClientContract) => {
export const storeSloSettings = async (
soClient: SavedObjectsClientContract,
params: PutSLOSettingsParams
) => {
): Promise<SLOSettings> => {
const object = await soClient.create<StoredSLOSettings>(
SO_SLO_SETTINGS_TYPE,
sloSettingsSchema.encode(params),