[APM] Service groups - Experimental (#123998)

* [APM] Adds service group technical preview feature (#122430)

* fixes overloaded ts signature check

* updates old snapshot in unit test

* addressed PR feedback to clean up query params passing in service group links

* fixes loading state of the page title for group name

* fixes typing issues in routing

* fixes linting error

* fixes missing query params in service links

* Cleans up ServiceGroupTemplate component query params and fixes breadcrumbs

* improves usability of the kuery search when creating service groups

* adds loading state for service groups list

* adds custom path matching for apm side nav entries

* addressed PR feedback

* fixes filtering terms enum responses by service group services

* uses service group in place of terms enum when selected
This commit is contained in:
Oliver Gupte 2022-03-09 16:31:29 -05:00 committed by GitHub
parent bfc7de92dc
commit afb50e02a0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
66 changed files with 2432 additions and 76 deletions

View file

@ -17,6 +17,7 @@ const previouslyRegisteredTypes = [
'api_key_pending_invalidation',
'apm-indices',
'apm-server-schema',
'apm-service-group',
'apm-services-telemetry',
'apm-telemetry',
'app_search_telemetry',

View file

@ -440,6 +440,10 @@ export const stackManagementSchema: MakeSchemaFrom<UsageStats> = {
type: 'boolean',
_meta: { description: 'Non-default value of setting.' },
},
'observability:enableServiceGroups': {
type: 'boolean',
_meta: { description: 'Non-default value of setting.' },
},
'banners:placement': {
type: 'keyword',
_meta: { description: 'Non-default value of setting.' },

View file

@ -41,6 +41,7 @@ export interface UsageStats {
'observability:maxSuggestions': number;
'observability:enableComparisonByDefault': boolean;
'observability:enableInfrastructureView': boolean;
'observability:enableServiceGroups': boolean;
'visualize:enableLabs': boolean;
'visualization:heatmap:maxBuckets': number;
'visualization:colorMapping': string;

View file

@ -7814,6 +7814,12 @@
"description": "Non-default value of setting."
}
},
"observability:enableServiceGroups": {
"type": "boolean",
"_meta": {
"description": "Non-default value of setting."
}
},
"banners:placement": {
"type": "keyword",
"_meta": {

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.
*/
export const APM_SERVICE_GROUP_SAVED_OBJECT_TYPE = 'apm-service-group';
export const SERVICE_GROUP_COLOR_DEFAULT = '#D1DAE7';
export const MAX_NUMBER_OF_SERVICES_IN_GROUP = 500;
export interface ServiceGroup {
groupName: string;
kuery: string;
description?: string;
serviceNames: string[];
color?: string;
}
export interface SavedServiceGroup extends ServiceGroup {
id: string;
updatedAt: number;
}

View file

@ -0,0 +1,22 @@
/*
* 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 { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { SERVICE_NAME } from '../elasticsearch_fieldnames';
import { ServiceGroup } from '../service_groups';
export function serviceGroupQuery(
serviceGroup?: ServiceGroup | null
): QueryDslQueryContainer[] {
if (!serviceGroup) {
return [];
}
return serviceGroup?.serviceNames
? [{ terms: { [SERVICE_NAME]: serviceGroup.serviceNames } }]
: [];
}

View file

@ -83,6 +83,7 @@ export function BackendDetailDependenciesTable() {
rangeTo,
latencyAggregationType: undefined,
transactionType: undefined,
serviceGroup: '',
}}
/>
),

View file

@ -112,7 +112,7 @@ export function ErrorGroupDetails() {
const {
path: { groupId },
query: { rangeFrom, rangeTo, environment, kuery },
query: { rangeFrom, rangeTo, environment, kuery, serviceGroup },
} = useApmParams('/services/{serviceName}/errors/{groupId}');
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
@ -129,6 +129,7 @@ export function ErrorGroupDetails() {
rangeTo,
environment,
kuery,
serviceGroup,
},
}),
});

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
export { ServiceGroupsList } from './service_groups_list/.';
export { ServiceGroupSaveButton } from './service_group_save/.';

View file

@ -0,0 +1,39 @@
/*
* 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 React, { useEffect, useRef } from 'react';
import { Subject, Subscription } from 'rxjs';
const refreshServiceGroupsSubject = new Subject();
export function refreshServiceGroups() {
refreshServiceGroupsSubject.next();
}
export function RefreshServiceGroupsSubscriber({
onRefresh,
children,
}: {
onRefresh: () => void;
children?: React.ReactNode;
}) {
const subscription = useRef<Subscription | null>(null);
useEffect(() => {
if (!subscription.current) {
subscription.current = refreshServiceGroupsSubject.subscribe(() =>
onRefresh()
);
}
return () => {
if (!subscription.current) {
return;
}
subscription.current.unsubscribe();
};
}, [onRefresh]);
return <>{children}</>;
}

View file

@ -0,0 +1,216 @@
/*
* 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 {
EuiButton,
EuiButtonEmpty,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiColorPicker,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
useColorPickerState,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useEffect, useRef, useState } from 'react';
import type { StagedServiceGroup } from './save_modal';
interface Props {
serviceGroup?: StagedServiceGroup;
isEdit?: boolean;
onCloseModal: () => void;
onClickNext: (serviceGroup: StagedServiceGroup) => void;
onDeleteGroup: () => void;
isLoading: boolean;
}
export function GroupDetails({
isEdit,
serviceGroup,
onCloseModal,
onClickNext,
onDeleteGroup,
isLoading,
}: Props) {
const [name, setName] = useState<string>(serviceGroup?.groupName || '');
const [color, setColor, colorPickerErrors] = useColorPickerState(
serviceGroup?.color || '#5094C4'
);
const [description, setDescription] = useState<string | undefined>(
serviceGroup?.description
);
useEffect(() => {
if (serviceGroup) {
setName(serviceGroup.groupName);
if (serviceGroup.color) {
setColor(serviceGroup.color, {
hex: serviceGroup.color,
isValid: true,
});
}
setDescription(serviceGroup.description);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serviceGroup]); // setColor omitted: new reference each render
const isInvalidColor = !!colorPickerErrors?.length;
const isInvalidName = !name;
const isInvalid = isInvalidName || isInvalidColor;
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
inputRef.current?.focus(); // autofocus on initial render
}, []);
return (
<>
<EuiModalHeader>
<EuiModalHeaderTitle>
<h1>
{isEdit
? i18n.translate(
'xpack.apm.serviceGroups.groupDetailsForm.edit.title',
{ defaultMessage: 'Edit group' }
)
: i18n.translate(
'xpack.apm.serviceGroups.groupDetailsForm.create.title',
{ defaultMessage: 'Create group' }
)}
</h1>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
label={i18n.translate(
'xpack.apm.serviceGroups.groupDetailsForm.name',
{ defaultMessage: 'Name' }
)}
isInvalid={isInvalidName}
>
<EuiFieldText
value={name}
onChange={(e) => {
setName(e.target.value);
}}
inputRef={inputRef}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
label={i18n.translate(
'xpack.apm.serviceGroups.groupDetailsForm.color',
{ defaultMessage: 'Color' }
)}
isInvalid={isInvalidColor}
error={
isInvalidColor
? i18n.translate(
'xpack.apm.serviceGroups.groupDetailsForm.invalidColorError',
{
defaultMessage:
'Please provide a valid color value',
}
)
: undefined
}
>
<EuiColorPicker onChange={setColor} color={color} />
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.apm.serviceGroups.groupDetailsForm.description',
{ defaultMessage: 'Description' }
)}
labelAppend={
<EuiText size="s" color="subdued">
{i18n.translate(
'xpack.apm.serviceGroups.groupDetailsForm.description.optional',
{ defaultMessage: 'Optional' }
)}
</EuiText>
}
>
<EuiFieldText
fullWidth
value={description}
onChange={(e) => {
setDescription(e.target.value);
}}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalBody>
<EuiModalFooter>
<EuiFlexGroup>
{isEdit && (
<EuiFlexItem grow={false}>
<EuiButton
iconType="trash"
iconSide="left"
onClick={() => {
onDeleteGroup();
}}
color="danger"
isDisabled={isLoading}
>
{i18n.translate(
'xpack.apm.serviceGroups.groupDetailsForm.deleteGroup',
{ defaultMessage: 'Delete group' }
)}
</EuiButton>
</EuiFlexItem>
)}
<EuiFlexItem grow={false} style={{ marginLeft: 'auto' }}>
<EuiButtonEmpty onClick={onCloseModal} isDisabled={isLoading}>
{i18n.translate(
'xpack.apm.serviceGroups.groupDetailsForm.cancel',
{ defaultMessage: 'Cancel' }
)}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
iconType="sortRight"
iconSide="right"
onClick={() => {
onClickNext({
groupName: name,
color,
description,
kuery: serviceGroup?.kuery ?? '',
});
}}
isDisabled={isInvalid || isLoading}
>
{i18n.translate(
'xpack.apm.serviceGroups.groupDetailsForm.selectServices',
{ defaultMessage: 'Select services' }
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalFooter>
</>
);
}

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { ServiceGroupSaveButton } from './save_button';

View file

@ -0,0 +1,64 @@
/*
* 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 { EuiButton } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useState } from 'react';
import { useAnyOfApmParams } from '../../../../hooks/use_apm_params';
import { useFetcher } from '../../../../hooks/use_fetcher';
import { SaveGroupModal } from './save_modal';
export function ServiceGroupSaveButton() {
const [isModalVisible, setIsModalVisible] = useState(false);
const {
query: { serviceGroup },
} = useAnyOfApmParams('/service-groups', '/services', '/service-map');
const isGroupEditMode = !!serviceGroup;
const { data } = useFetcher(
(callApmApi) => {
if (isGroupEditMode) {
return callApmApi('GET /internal/apm/service-group', {
params: { query: { serviceGroup } },
});
}
},
[serviceGroup, isGroupEditMode]
);
const savedServiceGroup = data?.serviceGroup;
return (
<>
<EuiButton
iconType={isGroupEditMode ? 'pencil' : 'plusInCircle'}
onClick={async () => {
setIsModalVisible((state) => !state);
}}
>
{isGroupEditMode ? EDIT_GROUP_LABEL : CREATE_GROUP_LABEL}
</EuiButton>
{isModalVisible && (
<SaveGroupModal
savedServiceGroup={savedServiceGroup}
onClose={() => {
setIsModalVisible(false);
}}
/>
)}
</>
);
}
const CREATE_GROUP_LABEL = i18n.translate(
'xpack.apm.serviceGroups.createGroupLabel',
{ defaultMessage: 'Create group' }
);
const EDIT_GROUP_LABEL = i18n.translate(
'xpack.apm.serviceGroups.editGroupLabel',
{ defaultMessage: 'Edit group' }
);

View file

@ -0,0 +1,264 @@
/*
* 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 datemath from '@elastic/datemath';
import { EuiModal } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useHistory } from 'react-router-dom';
import React, { useCallback, useEffect, useState } from 'react';
import { callApmApi } from '../../../../services/rest/create_call_apm_api';
import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context';
import { GroupDetails } from './group_details';
import { SelectServices } from './select_services';
import {
ServiceGroup,
SavedServiceGroup,
} from '../../../../../common/service_groups';
import { refreshServiceGroups } from '../refresh_service_groups_subscriber';
interface Props {
onClose: () => void;
savedServiceGroup?: SavedServiceGroup;
}
type ModalView = 'group_details' | 'select_service';
export type StagedServiceGroup = Pick<
ServiceGroup,
'groupName' | 'color' | 'description' | 'kuery'
>;
export function SaveGroupModal({ onClose, savedServiceGroup }: Props) {
const {
core: { notifications },
} = useApmPluginContext();
const [modalView, setModalView] = useState<ModalView>('group_details');
const [stagedServiceGroup, setStagedServiceGroup] = useState<
StagedServiceGroup | undefined
>(savedServiceGroup);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
setStagedServiceGroup(savedServiceGroup);
}, [savedServiceGroup]);
const isEdit = !!savedServiceGroup;
const history = useHistory();
const navigateToServiceGroups = useCallback(() => {
history.push({
...history.location,
pathname: '/service-groups',
search: '',
});
}, [history]);
const onSave = useCallback(
async function (newServiceGroup: StagedServiceGroup) {
setIsLoading(true);
try {
const start = datemath.parse('now-24h')?.toISOString();
const end = datemath.parse('now', { roundUp: true })?.toISOString();
if (!start || !end) {
throw new Error('Unable to determine start/end time range.');
}
await callApmApi('POST /internal/apm/service-group', {
params: {
query: { start, end, serviceGroupId: savedServiceGroup?.id },
body: {
groupName: newServiceGroup.groupName,
kuery: newServiceGroup.kuery,
description: newServiceGroup.description,
color: newServiceGroup.color,
},
},
signal: null,
});
notifications.toasts.addSuccess(
isEdit
? getEditSuccessToastLabels(newServiceGroup)
: getCreateSuccessToastLabels(newServiceGroup)
);
refreshServiceGroups();
navigateToServiceGroups();
} catch (error) {
console.error(error);
notifications.toasts.addDanger(
isEdit
? getEditFailureToastLabels(newServiceGroup, error)
: getCreateFailureToastLabels(newServiceGroup, error)
);
}
onClose();
setIsLoading(false);
},
[
savedServiceGroup?.id,
notifications.toasts,
onClose,
isEdit,
navigateToServiceGroups,
]
);
const onDelete = useCallback(
async function () {
setIsLoading(true);
if (!savedServiceGroup) {
notifications.toasts.addDanger(
getDeleteFailureUnknownIdToastLabels(stagedServiceGroup!)
);
return;
}
try {
await callApmApi('DELETE /internal/apm/service-group', {
params: { query: { serviceGroupId: savedServiceGroup.id } },
signal: null,
});
notifications.toasts.addSuccess(
getDeleteSuccessToastLabels(stagedServiceGroup!)
);
refreshServiceGroups();
navigateToServiceGroups();
} catch (error) {
console.error(error);
notifications.toasts.addDanger(
getDeleteFailureToastLabels(stagedServiceGroup!, error)
);
}
onClose();
setIsLoading(false);
},
[
stagedServiceGroup,
notifications.toasts,
onClose,
navigateToServiceGroups,
savedServiceGroup,
]
);
return (
<EuiModal onClose={onClose}>
{modalView === 'group_details' && (
<GroupDetails
serviceGroup={stagedServiceGroup}
isEdit={isEdit}
onCloseModal={onClose}
onClickNext={(_serviceGroup) => {
setStagedServiceGroup(_serviceGroup);
setModalView('select_service');
}}
onDeleteGroup={onDelete}
isLoading={isLoading}
/>
)}
{modalView === 'select_service' && stagedServiceGroup && (
<SelectServices
serviceGroup={stagedServiceGroup}
isEdit={isEdit}
onCloseModal={onClose}
onSaveClick={onSave}
onEditGroupDetailsClick={() => {
setModalView('group_details');
}}
isLoading={isLoading}
/>
)}
</EuiModal>
);
}
function getCreateSuccessToastLabels({ groupName }: StagedServiceGroup) {
return {
title: i18n.translate('xpack.apm.serviceGroups.createSucess.toast.title', {
defaultMessage: 'Created "{groupName}" group',
values: { groupName },
}),
text: i18n.translate('xpack.apm.serviceGroups.createSuccess.toast.text', {
defaultMessage:
'Your group is now visible in the new Services view for groups.',
}),
};
}
function getEditSuccessToastLabels({ groupName }: StagedServiceGroup) {
return {
title: i18n.translate('xpack.apm.serviceGroups.editSucess.toast.title', {
defaultMessage: 'Edited "{groupName}" group',
values: { groupName },
}),
text: i18n.translate('xpack.apm.serviceGroups.editSuccess.toast.text', {
defaultMessage: 'Saved new changes to service group.',
}),
};
}
function getCreateFailureToastLabels(
{ groupName }: StagedServiceGroup,
error: Error & { body: { message: string } }
) {
return {
title: i18n.translate('xpack.apm.serviceGroups.createFailure.toast.title', {
defaultMessage: 'Error while creating "{groupName}" group',
values: { groupName },
}),
text: error.body.message,
};
}
function getEditFailureToastLabels(
{ groupName }: StagedServiceGroup,
error: Error & { body: { message: string } }
) {
return {
title: i18n.translate('xpack.apm.serviceGroups.editFailure.toast.title', {
defaultMessage: 'Error while editing "{groupName}" group',
values: { groupName },
}),
text: error.body.message,
};
}
function getDeleteSuccessToastLabels({ groupName }: StagedServiceGroup) {
return {
title: i18n.translate('xpack.apm.serviceGroups.deleteSuccess.toast.title', {
defaultMessage: 'Deleted "{groupName}" group',
values: { groupName },
}),
};
}
function getDeleteFailureUnknownIdToastLabels({
groupName,
}: StagedServiceGroup) {
return {
title: i18n.translate(
'xpack.apm.serviceGroups.deleteFailure.unknownId.toast.title',
{
defaultMessage: 'Error while deleting "{groupName}" group',
values: { groupName },
}
),
text: i18n.translate(
'xpack.apm.serviceGroups.deleteFailure.unknownId.toast.text',
{ defaultMessage: 'Unable to delete group: unknown service group id.' }
),
};
}
function getDeleteFailureToastLabels(
{ groupName }: StagedServiceGroup,
error: Error & { body: { message: string } }
) {
return {
title: i18n.translate('xpack.apm.serviceGroups.deleteFailure.toast.title', {
defaultMessage: 'Error while deleting "{groupName}" group',
values: { groupName },
}),
text: error.body.message,
};
}

View file

@ -0,0 +1,256 @@
/*
* 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 {
EuiButton,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiPanel,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useEffect, useState, useMemo } from 'react';
import styled from 'styled-components';
import { isEmpty } from 'lodash';
import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher';
import { KueryBar } from '../../../shared/kuery_bar';
import { ServiceListPreview } from './service_list_preview';
import type { StagedServiceGroup } from './save_modal';
import { getDateRange } from '../../../../context/url_params_context/helpers';
const CentralizedContainer = styled.div`
display: flex;
height: 100%;
justify-content: center;
align-items: center;
`;
const MAX_CONTAINER_HEIGHT = 600;
const MODAL_HEADER_HEIGHT = 122;
const MODAL_FOOTER_HEIGHT = 80;
const Container = styled.div`
width: 600px;
height: ${MAX_CONTAINER_HEIGHT}px;
`;
interface Props {
serviceGroup: StagedServiceGroup;
isEdit?: boolean;
onCloseModal: () => void;
onSaveClick: (serviceGroup: StagedServiceGroup) => void;
onEditGroupDetailsClick: () => void;
isLoading: boolean;
}
export function SelectServices({
serviceGroup,
isEdit,
onCloseModal,
onSaveClick,
onEditGroupDetailsClick,
isLoading,
}: Props) {
const [kuery, setKuery] = useState(serviceGroup?.kuery || '');
const [stagedKuery, setStagedKuery] = useState(serviceGroup?.kuery || '');
useEffect(() => {
if (isEdit) {
setKuery(serviceGroup.kuery);
setStagedKuery(serviceGroup.kuery);
}
}, [isEdit, serviceGroup.kuery]);
const { start, end } = useMemo(
() =>
getDateRange({
rangeFrom: 'now-24h',
rangeTo: 'now',
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[kuery]
);
const { data, status } = useFetcher(
(callApmApi) => {
if (start && end && !isEmpty(kuery)) {
return callApmApi('GET /internal/apm/service-group/services', {
params: { query: { kuery, start, end } },
});
}
},
[kuery, start, end],
{ preservePreviousData: true }
);
const isServiceListPreviewLoading = status === FETCH_STATUS.LOADING;
return (
<Container>
<EuiModalHeader>
<EuiModalHeaderTitle>
<h1>
{i18n.translate(
'xpack.apm.serviceGroups.selectServicesForm.title',
{ defaultMessage: 'Select services' }
)}
</h1>
<EuiSpacer size="s" />
<EuiText color="subdued" size="s">
{i18n.translate(
'xpack.apm.serviceGroups.selectServicesForm.subtitle',
{
defaultMessage:
'Use a query to select services for this group. Services that match this query within the last 24 hours will be assigned to the group.',
}
)}
</EuiText>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody
style={{
height: `calc(75vh - ${MODAL_HEADER_HEIGHT}px - ${MODAL_FOOTER_HEIGHT}px)`,
maxHeight:
MAX_CONTAINER_HEIGHT - MODAL_HEADER_HEIGHT - MODAL_FOOTER_HEIGHT,
}}
>
<EuiFlexGroup
direction="column"
gutterSize="s"
style={{ height: '100%' }}
>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem>
<KueryBar
placeholder={i18n.translate(
'xpack.apm.serviceGroups.selectServicesForm.kql',
{ defaultMessage: 'E.g. labels.team: "web"' }
)}
onSubmit={(value) => {
setKuery(value);
}}
onChange={(value) => {
setStagedKuery(value);
}}
value={kuery}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
onClick={() => {
setKuery(stagedKuery);
}}
iconType={!kuery ? 'search' : 'refresh'}
isDisabled={isServiceListPreviewLoading || !stagedKuery}
>
{!kuery
? i18n.translate(
'xpack.apm.serviceGroups.selectServicesForm.preview',
{ defaultMessage: 'Preview' }
)
: i18n.translate(
'xpack.apm.serviceGroups.selectServicesForm.refresh',
{ defaultMessage: 'Refresh' }
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
{kuery && data?.items && (
<EuiFlexItem grow={false}>
<EuiText color="success" size="s">
{i18n.translate(
'xpack.apm.serviceGroups.selectServicesForm.matchingServiceCount',
{
defaultMessage:
'{servicesCount} {servicesCount, plural, =0 {services} one {service} other {services}} match the query',
values: { servicesCount: data?.items.length },
}
)}
</EuiText>
</EuiFlexItem>
)}
<EuiFlexItem>
<EuiPanel hasShadow={false} hasBorder paddingSize="s">
{!kuery && (
<CentralizedContainer>
<EuiText size="s" color="subdued">
{i18n.translate(
'xpack.apm.serviceGroups.selectServicesForm.panelLabel',
{ defaultMessage: 'Enter a query to select services' }
)}
</EuiText>
</CentralizedContainer>
)}
{!data && isServiceListPreviewLoading && (
<CentralizedContainer>
<EuiLoadingSpinner />
</CentralizedContainer>
)}
{kuery && data && (
<ServiceListPreview
items={data.items}
isLoading={isServiceListPreviewLoading}
/>
)}
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalBody>
<EuiModalFooter>
<EuiFlexGroup>
<EuiFlexItem>
<div>
<EuiButton
color="text"
onClick={onEditGroupDetailsClick}
iconType="sortLeft"
isDisabled={isLoading}
>
{i18n.translate(
'xpack.apm.serviceGroups.selectServicesForm.editGroupDetails',
{ defaultMessage: 'Edit group details' }
)}
</EuiButton>
</div>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={onCloseModal} isDisabled={isLoading}>
{i18n.translate(
'xpack.apm.serviceGroups.selectServicesForm.cancel',
{
defaultMessage: 'Cancel',
}
)}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
onClick={() => {
onSaveClick({ ...serviceGroup, kuery });
}}
isDisabled={isLoading || !kuery}
>
{i18n.translate(
'xpack.apm.serviceGroups.selectServicesForm.saveGroup',
{ defaultMessage: 'Save group' }
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalFooter>
</Container>
);
}

View file

@ -0,0 +1,135 @@
/*
* 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 {
EuiBasicTable,
EuiBasicTableColumn,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { orderBy } from 'lodash';
import React, { useCallback, useMemo, useState } from 'react';
import { ValuesType } from 'utility-types';
import { AgentIcon } from '../../../shared/agent_icon';
import { APIReturnType } from '../../../../services/rest/create_call_apm_api';
import { unit } from '../../../../utils/style';
import { EnvironmentBadge } from '../../../shared/environment_badge';
import { TruncateWithTooltip } from '../../../shared/truncate_with_tooltip';
type ServiceListAPIResponse =
APIReturnType<'GET /internal/apm/service-group/services'>;
type Items = ServiceListAPIResponse['items'];
type ServiceListItem = ValuesType<Items>;
interface Props {
items: Items;
isLoading: boolean;
}
const DEFAULT_SORT_FIELD = 'serviceName';
const DEFAULT_SORT_DIRECTION = 'asc';
type DIRECTION = 'asc' | 'desc';
type SORT_FIELD = 'serviceName' | 'environments' | 'agentName';
export function ServiceListPreview({ items, isLoading }: Props) {
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(5);
const [sortField, setSortField] = useState<SORT_FIELD>(DEFAULT_SORT_FIELD);
const [sortDirection, setSortDirection] = useState<DIRECTION>(
DEFAULT_SORT_DIRECTION
);
const onTableChange = useCallback(
(options: {
page: { index: number; size: number };
sort?: { field: SORT_FIELD; direction: DIRECTION };
}) => {
setPageIndex(options.page.index);
setPageSize(options.page.size);
setSortField(options.sort?.field || DEFAULT_SORT_FIELD);
setSortDirection(options.sort?.direction || DEFAULT_SORT_DIRECTION);
},
[]
);
const sort = useMemo(() => {
return {
sort: { field: sortField, direction: sortDirection },
};
}, [sortField, sortDirection]);
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
totalItemCount: items.length,
hidePerPageOptions: true,
}),
[pageIndex, pageSize, items.length]
);
const renderedItems = useMemo(() => {
const sortedItems = orderBy(items, sortField, sortDirection);
return sortedItems.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize);
}, [pageIndex, pageSize, sortField, sortDirection, items]);
const columns: Array<EuiBasicTableColumn<ServiceListItem>> = [
{
field: 'serviceName',
name: i18n.translate(
'xpack.apm.serviceGroups.selectServicesList.nameColumnLabel',
{ defaultMessage: 'Name' }
),
sortable: true,
render: (_, { serviceName, agentName }) => (
<TruncateWithTooltip
data-test-subj="apmServiceListAppLink"
text={serviceName}
content={
<EuiFlexGroup gutterSize="s" justifyContent="flexStart">
<EuiFlexItem grow={false}>
<AgentIcon agentName={agentName} />
</EuiFlexItem>
<EuiFlexItem grow={false}>{serviceName}</EuiFlexItem>
</EuiFlexGroup>
}
/>
),
},
{
field: 'environments',
name: i18n.translate(
'xpack.apm.serviceGroups.selectServicesList.environmentColumnLabel',
{ defaultMessage: 'Environments' }
),
width: `${unit * 10}px`,
sortable: true,
render: (_, { environments }) => (
<EnvironmentBadge environments={environments ?? []} />
),
},
];
return (
<EuiBasicTable
loading={isLoading}
noItemsMessage={i18n.translate(
'xpack.apm.serviceGroups.selectServicesList.notFoundLabel',
{
defaultMessage:
'No services available within the last 24 hours. You can still create the group and services that match your query will be added.',
}
)}
items={renderedItems}
columns={columns}
sorting={sort}
onChange={onTableChange}
pagination={pagination}
/>
);
}

View file

@ -0,0 +1,165 @@
/*
* 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 {
EuiEmptyPrompt,
EuiLoadingLogo,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiFormControlLayout,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { isEmpty, sortBy } from 'lodash';
import React, { useState, useCallback } from 'react';
import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher';
import { ServiceGroupsListItems } from './service_groups_list';
import { Sort } from './sort';
import { RefreshServiceGroupsSubscriber } from '../refresh_service_groups_subscriber';
export type ServiceGroupsSortType = 'recently_added' | 'alphabetical';
export function ServiceGroupsList() {
const [filter, setFilter] = useState('');
const [apmServiceGroupsSortType, setServiceGroupsSortType] =
useState<ServiceGroupsSortType>('recently_added');
const {
data = { serviceGroups: [] },
status,
refetch,
} = useFetcher(
(callApmApi) => callApmApi('GET /internal/apm/service-groups'),
[]
);
const { serviceGroups } = data;
const isLoading =
status === FETCH_STATUS.NOT_INITIATED || status === FETCH_STATUS.LOADING;
const filteredItems = isEmpty(filter)
? serviceGroups
: serviceGroups.filter((item) =>
item.groupName.toLowerCase().includes(filter.toLowerCase())
);
const sortedItems = sortBy(filteredItems, (item) =>
apmServiceGroupsSortType === 'alphabetical'
? item.groupName
: item.updatedAt
);
const items =
apmServiceGroupsSortType === 'recently_added'
? sortedItems.reverse()
: sortedItems;
const clearFilterCallback = useCallback(() => {
setFilter('');
}, []);
if (isLoading) {
// return null;
return (
<EuiEmptyPrompt
icon={<EuiLoadingLogo logo="logoObservability" size="xl" />}
title={
<h2>
{i18n.translate('xpack.apm.servicesGroups.loadingServiceGroups', {
defaultMessage: 'Loading service groups',
})}
</h2>
}
/>
);
}
return (
<RefreshServiceGroupsSubscriber onRefresh={refetch}>
<EuiFlexGroup direction="column" gutterSize="m">
<EuiFlexItem>
<EuiFlexGroup direction="row" gutterSize="s" alignItems="center">
<EuiFlexItem>
<EuiFormControlLayout
fullWidth
clear={
!isEmpty(filter)
? { onClick: clearFilterCallback }
: undefined
}
>
<EuiFieldText
icon="search"
fullWidth
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder={i18n.translate(
'xpack.apm.servicesGroups.filter',
{
defaultMessage: 'Filter groups',
}
)}
/>
</EuiFormControlLayout>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<Sort
type={apmServiceGroupsSortType}
onChange={setServiceGroupsSortType}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup direction="column" gutterSize="m">
<EuiFlexItem>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<EuiText style={{ fontWeight: 'bold' }} size="s">
{i18n.translate('xpack.apm.serviceGroups.groupsCount', {
defaultMessage:
'{servicesCount} {servicesCount, plural, =0 {group} one {group} other {groups}}',
values: { servicesCount: filteredItems.length + 1 },
})}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
{items.length ? (
<ServiceGroupsListItems items={items} isLoading={isLoading} />
) : (
<EuiEmptyPrompt
iconType="layers"
iconColor="black"
title={
<h2>
{i18n.translate(
'xpack.apm.serviceGroups.emptyPrompt.serviceGroups',
{ defaultMessage: 'Service groups' }
)}
</h2>
}
body={
<p>
{i18n.translate(
'xpack.apm.serviceGroups.emptyPrompt.message',
{ defaultMessage: 'No groups found for this filter' }
)}
</p>
}
/>
)}
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</RefreshServiceGroupsSubscriber>
);
}

View file

@ -0,0 +1,81 @@
/*
* 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 {
EuiAvatar,
EuiCard,
EuiCardProps,
EuiFlexGroup,
EuiFlexItem,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import {
ServiceGroup,
SERVICE_GROUP_COLOR_DEFAULT,
} from '../../../../../common/service_groups';
interface Props {
serviceGroup: ServiceGroup;
hideServiceCount?: boolean;
onClick?: () => void;
href?: string;
}
export function ServiceGroupsCard({
serviceGroup,
hideServiceCount = false,
onClick,
href,
}: Props) {
const cardProps: EuiCardProps = {
style: { width: 286, height: 186 },
icon: (
<EuiAvatar
name={serviceGroup.groupName}
color={serviceGroup.color || SERVICE_GROUP_COLOR_DEFAULT}
size="l"
/>
),
title: serviceGroup.groupName,
description: (
<EuiFlexGroup direction={'column'} gutterSize="m">
<EuiFlexItem>
<EuiText size="s">
{serviceGroup.description ||
i18n.translate(
'xpack.apm.serviceGroups.cardsList.emptyDescription',
{ defaultMessage: 'No description available' }
)}
</EuiText>
</EuiFlexItem>
{!hideServiceCount && (
<EuiFlexItem>
<EuiText size="s">
{i18n.translate(
'xpack.apm.serviceGroups.cardsList.serviceCount',
{
defaultMessage:
'{servicesCount} {servicesCount, plural, one {service} other {services}}',
values: { servicesCount: serviceGroup.serviceNames.length },
}
)}
</EuiText>
</EuiFlexItem>
)}
</EuiFlexGroup>
),
onClick,
href,
};
return (
<EuiFlexItem key={serviceGroup.groupName}>
<EuiCard layout="vertical" {...cardProps} />
</EuiFlexItem>
);
}

View file

@ -0,0 +1,66 @@
/*
* 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 { EuiFlexGrid } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { SavedServiceGroup } from '../../../../../common/service_groups';
import { ServiceGroupsCard } from './service_group_card';
import { SERVICE_GROUP_COLOR_DEFAULT } from '../../../../../common/service_groups';
import { useApmRouter } from '../../../../hooks/use_apm_router';
import { useApmParams } from '../../../../hooks/use_apm_params';
import { ENVIRONMENT_ALL } from '../../../../../common/environment_filter_values';
interface Props {
items: SavedServiceGroup[];
isLoading: boolean;
}
export function ServiceGroupsListItems({ items }: Props) {
const router = useApmRouter();
const { query } = useApmParams('/service-groups');
return (
<EuiFlexGrid gutterSize="m">
{items.map((item) => (
<ServiceGroupsCard
serviceGroup={item}
href={router.link('/services', {
query: {
...query,
serviceGroup: item.id,
environment: ENVIRONMENT_ALL.value,
kuery: '',
},
})}
/>
))}
<ServiceGroupsCard
serviceGroup={{
groupName: i18n.translate(
'xpack.apm.serviceGroups.list.allServices.name',
{ defaultMessage: 'All services' }
),
kuery: 'service.name : *',
description: i18n.translate(
'xpack.apm.serviceGroups.list.allServices.description',
{ defaultMessage: 'View all services' }
),
serviceNames: [],
color: SERVICE_GROUP_COLOR_DEFAULT,
}}
hideServiceCount
href={router.link('/services', {
query: {
...query,
serviceGroup: '',
environment: ENVIRONMENT_ALL.value,
kuery: '',
},
})}
/>
</EuiFlexGrid>
);
}

View file

@ -0,0 +1,46 @@
/*
* 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 { EuiSelect } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { ServiceGroupsSortType } from '.';
interface Props {
type: ServiceGroupsSortType;
onChange: (type: ServiceGroupsSortType) => void;
}
const options: Array<{
value: ServiceGroupsSortType;
text: string;
}> = [
{
value: 'recently_added',
text: i18n.translate('xpack.apm.serviceGroups.list.sort.recentlyAdded', {
defaultMessage: 'Recently added',
}),
},
{
value: 'alphabetical',
text: i18n.translate('xpack.apm.serviceGroups.list.sort.alphabetical', {
defaultMessage: 'Alphabetical',
}),
},
];
export function Sort({ type, onChange }: Props) {
return (
<EuiSelect
options={options}
value={type}
onChange={(e) => onChange(e.target.value as ServiceGroupsSortType)}
prepend={i18n.translate('xpack.apm.serviceGroups.sortLabel', {
defaultMessage: 'Sort',
})}
/>
);
}

View file

@ -34,7 +34,7 @@ function useServicesFetcher() {
} = useLegacyUrlParams();
const {
query: { rangeFrom, rangeTo, environment, kuery },
query: { rangeFrom, rangeTo, environment, kuery, serviceGroup },
} = useApmParams('/services');
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
@ -55,11 +55,12 @@ function useServicesFetcher() {
end,
environment,
kuery,
serviceGroup,
},
},
});
},
[start, end, environment, kuery]
[start, end, environment, kuery, serviceGroup]
);
const mainStatisticsFetch = useFetcher(
@ -72,6 +73,7 @@ function useServicesFetcher() {
kuery,
start,
end,
serviceGroup,
},
},
}).then((mainStatisticsData) => {
@ -82,7 +84,7 @@ function useServicesFetcher() {
});
}
},
[environment, kuery, start, end]
[environment, kuery, start, end, serviceGroup]
);
const { data: mainStatisticsData = initialData } = mainStatisticsFetch;

View file

@ -21,6 +21,7 @@ const query = {
rangeTo: 'now',
environment: ENVIRONMENT_ALL.value,
kuery: '',
serviceGroup: '',
};
const service: any = {

View file

@ -67,7 +67,7 @@ function LoadingSpinner() {
export function ServiceMapHome() {
const {
query: { environment, kuery, rangeFrom, rangeTo },
query: { environment, kuery, rangeFrom, rangeTo, serviceGroup },
} = useApmParams('/service-map');
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
return (
@ -76,6 +76,7 @@ export function ServiceMapHome() {
kuery={kuery}
start={start}
end={end}
serviceGroupId={serviceGroup}
/>
);
}
@ -100,11 +101,13 @@ export function ServiceMap({
kuery,
start,
end,
serviceGroupId,
}: {
environment: Environment;
kuery: string;
start: string;
end: string;
serviceGroupId?: string;
}) {
const theme = useTheme();
const license = useLicenseContext();
@ -130,11 +133,12 @@ export function ServiceMap({
end,
environment,
serviceName,
serviceGroup: serviceGroupId,
},
},
});
},
[license, serviceName, environment, start, end]
[license, serviceName, environment, start, end, serviceGroupId]
);
const { ref, height } = useRefDimensions();

View file

@ -63,6 +63,7 @@ export function ServiceContents({
});
const serviceName = nodeData.id!;
const serviceGroup = ('serviceGroup' in query && query.serviceGroup) || '';
const { data = INITIAL_STATE, status } = useFetcher(
(callApmApi) => {
@ -85,12 +86,19 @@ export function ServiceContents({
const detailsUrl = apmRouter.link('/services/{serviceName}', {
path: { serviceName },
query: { rangeFrom, rangeTo, environment, kuery, comparisonEnabled },
query: {
rangeFrom,
rangeTo,
environment,
kuery,
comparisonEnabled,
serviceGroup,
},
});
const focusUrl = apmRouter.link('/services/{serviceName}/service-map', {
path: { serviceName },
query: { rangeFrom, rangeTo, environment, kuery },
query: { rangeFrom, rangeTo, environment, kuery, serviceGroup },
});
const { serviceAnomalyStats } = nodeData;

View file

@ -38,7 +38,7 @@ export function ServiceOverviewDependenciesTable({
} = useLegacyUrlParams();
const {
query: { environment, kuery, rangeFrom, rangeTo },
query: { environment, kuery, rangeFrom, rangeTo, serviceGroup },
} = useApmParams('/services/{serviceName}/*');
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
@ -112,6 +112,7 @@ export function ServiceOverviewDependenciesTable({
rangeTo,
latencyAggregationType,
transactionType,
serviceGroup,
}}
/>
);

View file

@ -86,7 +86,7 @@ export function getTraceListColumns({
content={
<ServiceLink
agentName={agentName}
query={{ ...query, transactionType }}
query={{ ...query, transactionType, serviceGroup: '' }}
serviceName={serviceName}
/>
}

View file

@ -5,15 +5,30 @@
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { createRouter, Outlet } from '@kbn/typed-react-router-config';
import * as t from 'io-ts';
import React from 'react';
import { toBooleanRt } from '@kbn/io-ts-utils';
import { Breadcrumb } from '../app/breadcrumb';
import { TraceLink } from '../app/trace_link';
import { TransactionLink } from '../app/transaction_link';
import { home } from './home';
import { serviceDetail } from './service_detail';
import { settings } from './settings';
import { ApmMainTemplate } from './templates/apm_main_template';
import { ServiceGroupsList } from '../app/service_groups';
import { ServiceGroupsRedirect } from './service_groups_redirect';
import { comparisonTypeRt } from '../../../common/runtime_types/comparison_type_rt';
const ServiceGroupsBreadcrumnbLabel = i18n.translate(
'xpack.apm.views.serviceGroups.breadcrumbLabel',
{ defaultMessage: 'Services' }
);
const ServiceGroupsTitle = i18n.translate(
'xpack.apm.views.serviceGroups.title',
{ defaultMessage: 'Service groups' }
);
/**
* The array of route definitions to be used when the application
@ -59,6 +74,42 @@ const apmRoutes = {
</Breadcrumb>
),
children: {
// this route fails on navigation unless it's defined before home
'/service-groups': {
element: (
<Breadcrumb
title={ServiceGroupsBreadcrumnbLabel}
href={'/service-groups'}
>
<ApmMainTemplate
pageTitle={ServiceGroupsTitle}
environmentFilter={false}
showServiceGroupSaveButton
>
<ServiceGroupsRedirect>
<ServiceGroupsList />
</ServiceGroupsRedirect>
</ApmMainTemplate>
</Breadcrumb>
),
params: t.type({
query: t.intersection([
t.type({
rangeFrom: t.string,
rangeTo: t.string,
}),
t.partial({
serviceGroup: t.string,
}),
t.partial({
refreshPaused: t.union([t.literal('true'), t.literal('false')]),
refreshInterval: t.string,
comparisonEnabled: toBooleanRt,
comparisonType: comparisonTypeRt,
}),
]),
}),
},
...settings,
...serviceDetail,
...home,

View file

@ -7,9 +7,8 @@
import { i18n } from '@kbn/i18n';
import { Outlet } from '@kbn/typed-react-router-config';
import * as t from 'io-ts';
import React from 'react';
import React, { ComponentProps } from 'react';
import { toBooleanRt } from '@kbn/io-ts-utils';
import { RedirectTo } from '../redirect_to';
import { comparisonTypeRt } from '../../../../common/runtime_types/comparison_type_rt';
import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values';
import { environmentRt } from '../../../../common/environment_rt';
@ -21,15 +20,20 @@ import { ServiceMapHome } from '../../app/service_map';
import { TraceOverview } from '../../app/trace_overview';
import { ApmMainTemplate } from '../templates/apm_main_template';
import { RedirectToBackendOverviewRouteView } from './redirect_to_backend_overview_route_view';
import { ServiceGroupTemplate } from '../templates/service_group_template';
import { ServiceGroupsRedirect } from '../service_groups_redirect';
import { RedirectTo } from '../redirect_to';
function page<TPath extends string>({
path,
element,
title,
showServiceGroupSaveButton = false,
}: {
path: TPath;
element: React.ReactElement<any, any>;
title: string;
showServiceGroupSaveButton?: boolean;
}): Record<
TPath,
{
@ -40,14 +44,61 @@ function page<TPath extends string>({
[path]: {
element: (
<Breadcrumb title={title} href={path}>
<ApmMainTemplate pageTitle={title}>{element}</ApmMainTemplate>
<ApmMainTemplate
pageTitle={title}
showServiceGroupSaveButton={showServiceGroupSaveButton}
>
{element}
</ApmMainTemplate>
</Breadcrumb>
),
},
} as Record<TPath, { element: React.ReactElement<any, any> }>;
}
function serviceGroupPage<TPath extends string>({
path,
element,
title,
serviceGroupContextTab,
}: {
path: TPath;
element: React.ReactElement<any, any>;
title: string;
serviceGroupContextTab: ComponentProps<
typeof ServiceGroupTemplate
>['serviceGroupContextTab'];
}): Record<
TPath,
{
element: React.ReactElement<any, any>;
params: t.TypeC<{ query: t.TypeC<{ serviceGroup: t.StringC }> }>;
defaults: { query: { serviceGroup: string } };
}
> {
return {
[path]: {
element: (
<Breadcrumb title={title} href={path}>
<ServiceGroupTemplate
pageTitle={title}
serviceGroupContextTab={serviceGroupContextTab}
>
{element}
</ServiceGroupTemplate>
</Breadcrumb>
),
params: t.type({
query: t.type({ serviceGroup: t.string }),
}),
defaults: { query: { serviceGroup: '' } },
},
} as Record<
TPath,
{
element: React.ReactElement<any, any>;
params: t.TypeC<{ query: t.TypeC<{ serviceGroup: t.StringC }> }>;
defaults: { query: { serviceGroup: string } };
}
>;
}
@ -58,6 +109,12 @@ export const ServiceInventoryTitle = i18n.translate(
defaultMessage: 'Services',
}
);
export const ServiceMapTitle = i18n.translate(
'xpack.apm.views.serviceMap.title',
{
defaultMessage: 'Service Map',
}
);
export const DependenciesInventoryTitle = i18n.translate(
'xpack.apm.views.dependenciesInventory.title',
@ -92,10 +149,11 @@ export const home = {
},
},
children: {
...page({
...serviceGroupPage({
path: '/services',
title: ServiceInventoryTitle,
element: <ServiceInventory />,
serviceGroupContextTab: 'service-inventory',
}),
...page({
path: '/traces',
@ -104,12 +162,11 @@ export const home = {
}),
element: <TraceOverview />,
}),
...page({
...serviceGroupPage({
path: '/service-map',
title: i18n.translate('xpack.apm.views.serviceMap.title', {
defaultMessage: 'Service Map',
}),
title: ServiceMapTitle,
element: <ServiceMapHome />,
serviceGroupContextTab: 'service-map',
}),
'/backends': {
element: <Outlet />,
@ -144,7 +201,11 @@ export const home = {
},
},
'/': {
element: <RedirectTo pathname="/services" />,
element: (
<ServiceGroupsRedirect>
<RedirectTo pathname="/service-groups" />
</ServiceGroupsRedirect>
),
},
},
},

View file

@ -76,6 +76,7 @@ export const serviceDetail = {
rangeFrom: t.string,
rangeTo: t.string,
kuery: t.string,
serviceGroup: t.string,
}),
t.partial({
comparisonEnabled: toBooleanRt,
@ -92,6 +93,7 @@ export const serviceDetail = {
query: {
kuery: '',
environment: ENVIRONMENT_ALL.value,
serviceGroup: '',
},
},
children: {

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 React from 'react';
import { RedirectTo } from './redirect_to';
import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
import { enableServiceGroups } from '../../../../observability/public';
import { useFetcher, FETCH_STATUS } from '../../hooks/use_fetcher';
export function ServiceGroupsRedirect({
children,
}: {
children?: React.ReactNode;
}) {
const { data = { serviceGroups: [] }, status } = useFetcher(
(callApmApi) => callApmApi('GET /internal/apm/service-groups'),
[]
);
const { serviceGroups } = data;
const isLoading =
status === FETCH_STATUS.NOT_INITIATED || status === FETCH_STATUS.LOADING;
const {
services: { uiSettings },
} = useKibana();
const isServiceGroupsEnabled = uiSettings?.get<boolean>(enableServiceGroups);
if (isLoading) {
return null;
}
if (!isServiceGroupsEnabled || serviceGroups.length === 0) {
return <RedirectTo pathname={'/services'} />;
}
return <>{children}</>;
}

View file

@ -17,6 +17,8 @@ import { useFetcher, FETCH_STATUS } from '../../../hooks/use_fetcher';
import { ApmPluginStartDeps } from '../../../plugin';
import { ApmEnvironmentFilter } from '../../shared/environment_filter';
import { getNoDataConfig } from './no_data_config';
import { enableServiceGroups } from '../../../../../observability/public';
import { ServiceGroupSaveButton } from '../../app/service_groups';
// Paths that must skip the no data screen
const bypassNoDataScreenPaths = ['/settings'];
@ -29,18 +31,21 @@ const bypassNoDataScreenPaths = ['/settings'];
*
* Optionally:
* - EnvironmentFilter
* - ServiceGroupSaveButton
*/
export function ApmMainTemplate({
pageTitle,
pageHeader,
children,
environmentFilter = true,
showServiceGroupSaveButton = false,
...pageTemplateProps
}: {
pageTitle?: React.ReactNode;
pageHeader?: EuiPageHeaderProps;
children: React.ReactNode;
environmentFilter?: boolean;
showServiceGroupSaveButton?: boolean;
} & KibanaPageTemplateProps) {
const location = useLocation();
@ -79,7 +84,16 @@ export function ApmMainTemplate({
fleetApmPoliciesStatus === FETCH_STATUS.LOADING,
});
const rightSideItems = environmentFilter ? [<ApmEnvironmentFilter />] : [];
const {
services: { uiSettings },
} = useKibana<ApmPluginStartDeps>();
const isServiceGroupsEnabled = uiSettings?.get<boolean>(enableServiceGroups);
const renderServiceGroupSaveButton =
showServiceGroupSaveButton && isServiceGroupsEnabled;
const rightSideItems = [
...(renderServiceGroupSaveButton ? [<ServiceGroupSaveButton />] : []),
...(environmentFilter ? [<ApmEnvironmentFilter />] : []),
];
const pageTemplate = (
<ObservabilityPageTemplate

View file

@ -0,0 +1,172 @@
/*
* 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 {
EuiPageHeaderProps,
EuiFlexGroup,
EuiFlexItem,
EuiButtonIcon,
EuiLoadingContent,
} from '@elastic/eui';
import React from 'react';
import { i18n } from '@kbn/i18n';
import {
useKibana,
KibanaPageTemplateProps,
} from '../../../../../../../src/plugins/kibana_react/public';
import { useFetcher } from '../../../hooks/use_fetcher';
import { ApmPluginStartDeps } from '../../../plugin';
import { enableServiceGroups } from '../../../../../observability/public';
import { useApmRouter } from '../../../hooks/use_apm_router';
import { useAnyOfApmParams } from '../../../hooks/use_apm_params';
import { ApmMainTemplate } from './apm_main_template';
import { useBreadcrumb } from '../../../context/breadcrumbs/use_breadcrumb';
export function ServiceGroupTemplate({
pageTitle,
pageHeader,
children,
environmentFilter = true,
serviceGroupContextTab,
...pageTemplateProps
}: {
pageTitle?: React.ReactNode;
pageHeader?: EuiPageHeaderProps;
children: React.ReactNode;
environmentFilter?: boolean;
serviceGroupContextTab: ServiceGroupContextTab['key'];
} & KibanaPageTemplateProps) {
const {
services: { uiSettings },
} = useKibana<ApmPluginStartDeps>();
const isServiceGroupsEnabled = uiSettings?.get<boolean>(enableServiceGroups);
const router = useApmRouter();
const {
query,
query: { serviceGroup: serviceGroupId },
} = useAnyOfApmParams('/services', '/service-map');
const { data } = useFetcher((callApmApi) => {
if (serviceGroupId) {
return callApmApi('GET /internal/apm/service-group', {
params: { query: { serviceGroup: serviceGroupId } },
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const serviceGroupName = data?.serviceGroup.groupName;
const loadingServiceGroupName = !!serviceGroupId && !serviceGroupName;
const serviceGroupsLink = router.link('/service-groups', {
query: { ...query, serviceGroup: '' },
});
const serviceGroupsPageTitle = (
<EuiFlexGroup
direction="row"
gutterSize="s"
alignItems="center"
justifyContent="flexStart"
>
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="layers"
color="text"
aria-label="Go to service groups"
iconSize="xl"
href={serviceGroupsLink}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{loadingServiceGroupName ? (
<EuiLoadingContent lines={2} style={{ width: 180, height: 40 }} />
) : (
serviceGroupName ||
i18n.translate('xpack.apm.serviceGroup.allServices.title', {
defaultMessage: 'Services',
})
)}
</EuiFlexItem>
</EuiFlexGroup>
);
const tabs = useTabs(serviceGroupContextTab);
const selectedTab = tabs?.find(({ isSelected }) => isSelected);
useBreadcrumb([
{
title: i18n.translate('xpack.apm.serviceGroups.breadcrumb.title', {
defaultMessage: 'Services',
}),
href: serviceGroupsLink,
},
...(selectedTab
? [
...(serviceGroupName
? [
{
title: serviceGroupName,
href: router.link('/services', { query }),
},
]
: []),
{
title: selectedTab.label,
href: selectedTab.href,
} as { title: string; href: string },
]
: []),
]);
return (
<ApmMainTemplate
pageTitle={isServiceGroupsEnabled ? serviceGroupsPageTitle : pageTitle}
pageHeader={{
tabs: isServiceGroupsEnabled ? tabs : undefined,
...pageHeader,
}}
environmentFilter={environmentFilter}
showServiceGroupSaveButton={true}
{...pageTemplateProps}
>
{children}
</ApmMainTemplate>
);
}
type ServiceGroupContextTab = NonNullable<EuiPageHeaderProps['tabs']>[0] & {
key: 'service-inventory' | 'service-map';
};
function useTabs(selectedTab: ServiceGroupContextTab['key']) {
const router = useApmRouter();
const { query } = useAnyOfApmParams('/services', '/service-map');
const tabs: ServiceGroupContextTab[] = [
{
key: 'service-inventory',
label: i18n.translate('xpack.apm.serviceGroup.serviceInventory', {
defaultMessage: 'Inventory',
}),
href: router.link('/services', { query }),
},
{
key: 'service-map',
label: i18n.translate('xpack.apm.serviceGroup.serviceMap', {
defaultMessage: 'Service map',
}),
href: router.link('/service-map', { query }),
},
];
return tabs
.filter((t) => !t.hidden)
.map(({ href, key, label }) => ({
href,
label,
isSelected: key === selectedTab,
}));
}

View file

@ -39,13 +39,17 @@ export function KueryBar(props: {
placeholder?: string;
boolFilter?: QueryDslQueryContainer[];
prepend?: React.ReactNode | string;
onSubmit?: (value: string) => void;
onChange?: (value: string) => void;
value?: string;
}) {
const { path, query } = useApmParams('/*');
const serviceName = 'serviceName' in path ? path.serviceName : undefined;
const groupId = 'groupId' in path ? path.groupId : undefined;
const environment = 'environment' in query ? query.environment : undefined;
const kuery = 'kuery' in query ? query.kuery : undefined;
const _kuery = 'kuery' in query ? query.kuery : undefined;
const kuery = props.value || _kuery;
const history = useHistory();
const [state, setState] = useState<State>({
@ -88,6 +92,9 @@ export function KueryBar(props: {
});
async function onChange(inputValue: string, selectionStart: number) {
if (typeof props.onChange === 'function') {
props.onChange(inputValue);
}
if (dataView == null) {
return;
}
@ -140,6 +147,11 @@ export function KueryBar(props: {
return;
}
if (typeof props.onSubmit === 'function') {
props.onSubmit(inputValue.trim());
return;
}
history.push({
...location,
search: fromQuery({

View file

@ -108,6 +108,16 @@ export class Typeahead extends Component {
}
};
onBlur = () => {
const { isSuggestionsVisible, index, value } = this.state;
if (isSuggestionsVisible && this.props.suggestions[index]) {
this.selectSuggestion(this.props.suggestions[index]);
} else {
this.setState({ isSuggestionsVisible: false });
this.props.onSubmit(value);
}
};
selectSuggestion = (suggestion) => {
const nextInputValue =
this.state.value.substr(0, suggestion.start) +
@ -184,6 +194,7 @@ export class Typeahead extends Component {
onKeyUp={this.onKeyUp}
onChange={this.onChangeInputValue}
onClick={this.onClickInput}
onBlur={this.onBlur}
autoComplete="off"
spellCheck={false}
prepend={prepend}

View file

@ -32,6 +32,7 @@ export const PERSISTENT_APM_PARAMS: Array<keyof APMQueryParams> = [
'refreshPaused',
'refreshInterval',
'environment',
'serviceGroup',
];
/**

View file

@ -95,6 +95,7 @@ export interface APMQueryParams {
podName?: string;
agentName?: string;
serviceVersion?: string;
serviceGroup?: string;
}
// forces every value of T[K] to be type: string

View file

@ -38,6 +38,7 @@ export function RedirectWithDefaultDateRange({
route.path === '/service-map' ||
route.path === '/backends' ||
route.path === '/services/{serviceName}' ||
route.path === '/service-groups' ||
location.pathname === '/' ||
location.pathname === ''
);

View file

@ -36,6 +36,7 @@ Example.args = {
kuery: '',
rangeFrom: 'now-15m',
rangeTo: 'now',
serviceGroup: '',
},
serviceName: 'opbeans-java',
};

View file

@ -13,10 +13,9 @@ import { ApmRoutes } from '../components/routing/apm_route_config';
// union type that is created.
export function useMaybeApmParams<TPath extends PathsOf<ApmRoutes>>(
path: TPath,
optional: true
path: TPath
): TypeOf<ApmRoutes, TPath> | undefined {
return useParams(path, optional) as TypeOf<ApmRoutes, TPath> | undefined;
return useParams(path, true) as TypeOf<ApmRoutes, TPath> | undefined;
}
export function useApmParams<TPath extends PathsOf<ApmRoutes>>(

View file

@ -54,6 +54,7 @@ import { getLazyAPMPolicyEditExtension } from './components/fleet_integration/la
import { featureCatalogueEntry } from './feature_catalogue_entry';
import type { SecurityPluginStart } from '../../security/public';
import { SpacesPluginStart } from '../../spaces/public';
import { enableServiceGroups } from '../../observability/public';
export type ApmPluginSetup = ReturnType<ApmPlugin['setup']>;
@ -118,6 +119,11 @@ export class ApmPlugin implements Plugin<ApmPluginSetup, ApmPluginStart> {
pluginSetupDeps.home.featureCatalogue.register(featureCatalogueEntry);
}
const serviceGroupsEnabled = core.uiSettings.get<boolean>(
enableServiceGroups,
false
);
// register observability nav if user has access to plugin
plugins.observability.navigation.registerSections(
from(core.getStartServices()).pipe(
@ -129,7 +135,26 @@ export class ApmPlugin implements Plugin<ApmPluginSetup, ApmPluginStart> {
label: 'APM',
sortKey: 400,
entries: [
{ label: servicesTitle, app: 'apm', path: '/services' },
serviceGroupsEnabled
? {
label: servicesTitle,
app: 'apm',
path: '/service-groups',
matchPath(currentPath: string) {
return [
'/service-groups',
'/services',
'/service-map',
].some((testPath) =>
currentPath.startsWith(testPath)
);
},
}
: {
label: servicesTitle,
app: 'apm',
path: '/services',
},
{ label: tracesTitle, app: 'apm', path: '/traces' },
{
label: dependenciesTitle,
@ -149,7 +174,15 @@ export class ApmPlugin implements Plugin<ApmPluginSetup, ApmPluginStart> {
}
},
},
{ label: serviceMapTitle, app: 'apm', path: '/service-map' },
...(serviceGroupsEnabled
? []
: [
{
label: serviceMapTitle,
app: 'apm',
path: '/service-map',
},
]),
],
},
];
@ -230,7 +263,30 @@ export class ApmPlugin implements Plugin<ApmPluginSetup, ApmPluginStart> {
icon: 'plugins/apm/public/icon.svg',
category: DEFAULT_APP_CATEGORIES.observability,
deepLinks: [
{ id: 'services', title: servicesTitle, path: '/services' },
{
id: 'services',
title: servicesTitle,
// path: serviceGroupsEnabled ? '/service-groups' : '/services',
deepLinks: serviceGroupsEnabled
? [
{
id: 'service-groups-list',
title: 'Service groups',
path: '/service-groups',
},
{
id: 'service-groups-services',
title: servicesTitle,
path: '/services',
},
{
id: 'service-groups-service-map',
title: serviceMapTitle,
path: '/service-map',
},
]
: [],
},
{ id: 'traces', title: tracesTitle, path: '/traces' },
{ id: 'service-map', title: serviceMapTitle, path: '/service-map' },
{ id: 'backends', title: dependenciesTitle, path: '/backends' },

View file

@ -29,7 +29,12 @@ import { getInternalSavedObjectsClient } from './lib/helpers/get_internal_saved_
import { createApmAgentConfigurationIndex } from './routes/settings/agent_configuration/create_agent_config_index';
import { getApmIndices } from './routes/settings/apm_indices/get_apm_indices';
import { createApmCustomLinkIndex } from './routes/settings/custom_link/create_custom_link_index';
import { apmIndices, apmTelemetry, apmServerSettings } from './saved_objects';
import {
apmIndices,
apmTelemetry,
apmServerSettings,
apmServiceGroups,
} from './saved_objects';
import type {
ApmPluginRequestHandlerContext,
APMRouteHandlerResources,
@ -75,6 +80,7 @@ export class APMPlugin
core.savedObjects.registerType(apmIndices);
core.savedObjects.registerType(apmTelemetry);
core.savedObjects.registerType(apmServerSettings);
core.savedObjects.registerType(apmServiceGroups);
const currentConfig = this.initContext.config.get<APMConfig>();
this.currentConfig = currentConfig;

View file

@ -23,6 +23,7 @@ import { observabilityOverviewRouteRepository } from '../observability_overview/
import { rumRouteRepository } from '../rum_client/route';
import { fallbackToTransactionsRouteRepository } from '../fallback_to_transactions/route';
import { serviceRouteRepository } from '../services/route';
import { serviceGroupRouteRepository } from '../service_groups/route';
import { serviceMapRouteRepository } from '../service_map/route';
import { serviceNodeRouteRepository } from '../service_nodes/route';
import { agentConfigurationRouteRepository } from '../settings/agent_configuration/route';
@ -49,6 +50,7 @@ const getTypedGlobalApmServerRouteRepository = () => {
...serviceMapRouteRepository,
...serviceNodeRouteRepository,
...serviceRouteRepository,
...serviceGroupRouteRepository,
...suggestionsRouteRepository,
...traceRouteRepository,
...transactionRouteRepository,

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 { SavedObjectsClientContract } from 'kibana/server';
import { APM_SERVICE_GROUP_SAVED_OBJECT_TYPE } from '../../../common/service_groups';
interface Options {
savedObjectsClient: SavedObjectsClientContract;
serviceGroupId: string;
}
export async function deleteServiceGroup({
savedObjectsClient,
serviceGroupId,
}: Options) {
return savedObjectsClient.delete(
APM_SERVICE_GROUP_SAVED_OBJECT_TYPE,
serviceGroupId
);
}

View file

@ -0,0 +1,35 @@
/*
* 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 { SavedObjectsClientContract } from 'kibana/server';
import {
ServiceGroup,
SavedServiceGroup,
APM_SERVICE_GROUP_SAVED_OBJECT_TYPE,
} from '../../../common/service_groups';
export async function getServiceGroup({
savedObjectsClient,
serviceGroupId,
}: {
savedObjectsClient: SavedObjectsClientContract;
serviceGroupId: string;
}): Promise<SavedServiceGroup> {
const {
id,
updated_at: updatedAt,
attributes,
} = await savedObjectsClient.get<ServiceGroup>(
APM_SERVICE_GROUP_SAVED_OBJECT_TYPE,
serviceGroupId
);
return {
id,
updatedAt: updatedAt ? Date.parse(updatedAt) : 0,
...attributes,
};
}

View file

@ -0,0 +1,33 @@
/*
* 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 { SavedObjectsClientContract } from 'kibana/server';
import {
ServiceGroup,
SavedServiceGroup,
APM_SERVICE_GROUP_SAVED_OBJECT_TYPE,
MAX_NUMBER_OF_SERVICES_IN_GROUP,
} from '../../../common/service_groups';
export async function getServiceGroups({
savedObjectsClient,
}: {
savedObjectsClient: SavedObjectsClientContract;
}): Promise<SavedServiceGroup[]> {
const result = await savedObjectsClient.find<ServiceGroup>({
type: APM_SERVICE_GROUP_SAVED_OBJECT_TYPE,
page: 1,
perPage: MAX_NUMBER_OF_SERVICES_IN_GROUP,
});
return result.saved_objects.map(
({ id, attributes, updated_at: upatedAt }) => ({
id,
updatedAt: upatedAt ? Date.parse(upatedAt) : 0,
...attributes,
})
);
}

View file

@ -0,0 +1,83 @@
/*
* 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 { AgentName } from '../../../typings/es_schemas/ui/fields/agent';
import {
AGENT_NAME,
SERVICE_ENVIRONMENT,
SERVICE_NAME,
} from '../../../common/elasticsearch_fieldnames';
import { kqlQuery, rangeQuery } from '../../../../observability/server';
import { ProcessorEvent } from '../../../common/processor_event';
import { Setup } from '../../lib/helpers/setup_request';
import { MAX_NUMBER_OF_SERVICES_IN_GROUP } from '../../../common/service_groups';
export async function lookupServices({
setup,
kuery,
start,
end,
}: {
setup: Setup;
kuery: string;
start: number;
end: number;
}) {
const { apmEventClient } = setup;
const response = await apmEventClient.search('lookup_services', {
apm: {
events: [
ProcessorEvent.metric,
ProcessorEvent.transaction,
ProcessorEvent.span,
ProcessorEvent.error,
],
},
body: {
size: 0,
query: {
bool: {
filter: [...rangeQuery(start, end), ...kqlQuery(kuery)],
},
},
aggs: {
services: {
terms: {
field: SERVICE_NAME,
size: MAX_NUMBER_OF_SERVICES_IN_GROUP,
},
aggs: {
environments: {
terms: {
field: SERVICE_ENVIRONMENT,
},
},
latest: {
top_metrics: {
metrics: [{ field: AGENT_NAME } as const],
sort: { '@timestamp': 'desc' },
},
},
},
},
},
},
});
return (
response.aggregations?.services.buckets.map((bucket) => {
return {
serviceName: bucket.key as string,
environments: bucket.environments.buckets.map(
(envBucket) => envBucket.key as string
),
agentName: bucket.latest.top[0].metrics[AGENT_NAME] as AgentName,
};
}) ?? []
);
}

View file

@ -0,0 +1,148 @@
/*
* 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 { setupRequest } from '../../lib/helpers/setup_request';
import { createApmServerRoute } from '../apm_routes/create_apm_server_route';
import { kueryRt, rangeRt } from '../default_api_types';
import { getServiceGroups } from '../service_groups/get_service_groups';
import { getServiceGroup } from '../service_groups/get_service_group';
import { saveServiceGroup } from '../service_groups/save_service_group';
import { deleteServiceGroup } from '../service_groups/delete_service_group';
import { lookupServices } from '../service_groups/lookup_services';
import {
ServiceGroup,
SavedServiceGroup,
} from '../../../common/service_groups';
const serviceGroupsRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/service-groups',
options: {
tags: ['access:apm'],
},
handler: async (
resources
): Promise<{ serviceGroups: SavedServiceGroup[] }> => {
const { context } = resources;
const savedObjectsClient = context.core.savedObjects.client;
const serviceGroups = await getServiceGroups({ savedObjectsClient });
return { serviceGroups };
},
});
const serviceGroupRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/service-group',
params: t.type({
query: t.type({
serviceGroup: t.string,
}),
}),
options: {
tags: ['access:apm'],
},
handler: async (resources): Promise<{ serviceGroup: SavedServiceGroup }> => {
const { context, params } = resources;
const savedObjectsClient = context.core.savedObjects.client;
const serviceGroup = await getServiceGroup({
savedObjectsClient,
serviceGroupId: params.query.serviceGroup,
});
return { serviceGroup };
},
});
const serviceGroupSaveRoute = createApmServerRoute({
endpoint: 'POST /internal/apm/service-group',
params: t.type({
query: t.intersection([
rangeRt,
t.partial({
serviceGroupId: t.string,
}),
]),
body: t.type({
groupName: t.string,
kuery: t.string,
description: t.union([t.string, t.undefined]),
color: t.union([t.string, t.undefined]),
}),
}),
options: { tags: ['access:apm', 'access:apm_write'] },
handler: async (resources): Promise<void> => {
const { context, params } = resources;
const { start, end, serviceGroupId } = params.query;
const savedObjectsClient = context.core.savedObjects.client;
const setup = await setupRequest(resources);
const items = await lookupServices({
setup,
kuery: params.body.kuery,
start,
end,
});
const serviceNames = items.map(({ serviceName }): string => serviceName);
const serviceGroup: ServiceGroup = {
...params.body,
serviceNames,
};
await saveServiceGroup({
savedObjectsClient,
serviceGroupId,
serviceGroup,
});
},
});
const serviceGroupDeleteRoute = createApmServerRoute({
endpoint: 'DELETE /internal/apm/service-group',
params: t.type({
query: t.type({
serviceGroupId: t.string,
}),
}),
options: { tags: ['access:apm', 'access:apm_write'] },
handler: async (resources): Promise<void> => {
const { context, params } = resources;
const { serviceGroupId } = params.query;
const savedObjectsClient = context.core.savedObjects.client;
await deleteServiceGroup({
savedObjectsClient,
serviceGroupId,
});
},
});
const serviceGroupServicesRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/service-group/services',
params: t.type({
query: t.intersection([rangeRt, t.partial(kueryRt.props)]),
}),
options: {
tags: ['access:apm'],
},
handler: async (
resources
): Promise<{ items: Awaited<ReturnType<typeof lookupServices>> }> => {
const { params } = resources;
const { kuery = '', start, end } = params.query;
const setup = await setupRequest(resources);
const items = await lookupServices({
setup,
kuery,
start,
end,
});
return { items };
},
});
export const serviceGroupRouteRepository = {
...serviceGroupsRoute,
...serviceGroupRoute,
...serviceGroupSaveRoute,
...serviceGroupDeleteRoute,
...serviceGroupServicesRoute,
};

View file

@ -0,0 +1,38 @@
/*
* 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 { SavedObjectsClientContract } from 'kibana/server';
import {
APM_SERVICE_GROUP_SAVED_OBJECT_TYPE,
ServiceGroup,
} from '../../../common/service_groups';
interface Options {
savedObjectsClient: SavedObjectsClientContract;
serviceGroupId?: string;
serviceGroup: ServiceGroup;
}
export async function saveServiceGroup({
savedObjectsClient,
serviceGroupId,
serviceGroup,
}: Options) {
// update existing service group
if (serviceGroupId) {
return await savedObjectsClient.update(
APM_SERVICE_GROUP_SAVED_OBJECT_TYPE,
serviceGroupId,
serviceGroup
);
}
// create new saved object
return await savedObjectsClient.create(
APM_SERVICE_GROUP_SAVED_OBJECT_TYPE,
serviceGroup
);
}

View file

@ -8,7 +8,7 @@
import { Logger } from 'kibana/server';
import { chunk } from 'lodash';
import { ProcessorEvent } from '../../../common/processor_event';
import { rangeQuery, termQuery } from '../../../../observability/server';
import { rangeQuery, termsQuery } from '../../../../observability/server';
import {
AGENT_NAME,
SERVICE_ENVIRONMENT,
@ -29,7 +29,7 @@ import { getProcessorEventForTransactions } from '../../lib/helpers/transactions
export interface IEnvOptions {
setup: Setup;
serviceName?: string;
serviceNames?: string[];
environment: string;
searchAggregatedTransactions: boolean;
logger: Logger;
@ -39,7 +39,7 @@ export interface IEnvOptions {
async function getConnectionData({
setup,
serviceName,
serviceNames,
environment,
start,
end,
@ -47,7 +47,7 @@ async function getConnectionData({
return withApmSpan('get_service_map_connections', async () => {
const { traceIds } = await getTraceSampleIds({
setup,
serviceName,
serviceNames,
environment,
start,
end,
@ -109,7 +109,7 @@ async function getServicesData(options: IEnvOptions) {
filter: [
...rangeQuery(start, end),
...environmentQuery(environment),
...termQuery(SERVICE_NAME, options.serviceName),
...termsQuery(SERVICE_NAME, ...(options.serviceNames ?? [])),
],
},
},

View file

@ -23,13 +23,13 @@ import { Setup } from '../../lib/helpers/setup_request';
const MAX_TRACES_TO_INSPECT = 1000;
export async function getTraceSampleIds({
serviceName,
serviceNames,
environment,
setup,
start,
end,
}: {
serviceName?: string;
serviceNames?: string[];
environment: string;
setup: Setup;
start: number;
@ -45,8 +45,12 @@ export async function getTraceSampleIds({
let events: ProcessorEvent[];
if (serviceName) {
query.bool.filter.push({ term: { [SERVICE_NAME]: serviceName } });
const hasServiceNamesFilter = (serviceNames?.length ?? 0) > 0;
if (hasServiceNamesFilter) {
query.bool.filter.push({
terms: { [SERVICE_NAME]: serviceNames as string[] },
});
events = [ProcessorEvent.span, ProcessorEvent.transaction];
} else {
events = [ProcessorEvent.span];
@ -59,10 +63,10 @@ export async function getTraceSampleIds({
query.bool.filter.push(...environmentQuery(environment));
const fingerprintBucketSize = serviceName
const fingerprintBucketSize = hasServiceNamesFilter
? config.serviceMapFingerprintBucketSize
: config.serviceMapFingerprintGlobalBucketSize;
const traceIdBucketSize = serviceName
const traceIdBucketSize = hasServiceNamesFilter
? config.serviceMapTraceIdBucketSize
: config.serviceMapTraceIdGlobalBucketSize;
const samplerShardSize = traceIdBucketSize * 10;

View file

@ -17,6 +17,7 @@ import { getServiceMapBackendNodeInfo } from './get_service_map_backend_node_inf
import { getServiceMapServiceNodeInfo } from './get_service_map_service_node_info';
import { createApmServerRoute } from '../apm_routes/create_apm_server_route';
import { environmentRt, offsetRt, rangeRt } from '../default_api_types';
import { getServiceGroup } from '../service_groups/get_service_group';
const serviceMapRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/service-map',
@ -24,6 +25,7 @@ const serviceMapRoute = createApmServerRoute({
query: t.intersection([
t.partial({
serviceName: t.string,
serviceGroup: t.string,
}),
environmentRt,
rangeRt,
@ -94,11 +96,32 @@ const serviceMapRoute = createApmServerRoute({
featureName: 'serviceMaps',
});
const setup = await setupRequest(resources);
const {
query: { serviceName, environment, start, end },
query: {
serviceName,
serviceGroup: serviceGroupId,
environment,
start,
end,
},
} = params;
const savedObjectsClient = context.core.savedObjects.client;
const [setup, serviceGroup] = await Promise.all([
setupRequest(resources),
serviceGroupId
? getServiceGroup({
savedObjectsClient,
serviceGroupId,
})
: Promise.resolve(null),
]);
const serviceNames = [
...(serviceName ? [serviceName] : []),
...(serviceGroup?.serviceNames ?? []),
];
const searchAggregatedTransactions = await getSearchAggregatedTransactions({
apmEventClient: setup.apmEventClient,
config: setup.config,
@ -108,7 +131,7 @@ const serviceMapRoute = createApmServerRoute({
});
return getServiceMap({
setup,
serviceName,
serviceNames,
environment,
searchAggregatedTransactions,
logger,

View file

@ -124,7 +124,7 @@ Array [
},
"terms": Object {
"field": "service.name",
"size": 500,
"size": 50,
},
},
},
@ -177,7 +177,7 @@ Array [
},
"terms": Object {
"field": "service.name",
"size": 500,
"size": 50,
},
},
},

View file

@ -29,6 +29,8 @@ import {
getOutcomeAggregation,
} from '../../../lib/helpers/transaction_error_rate';
import { ServicesItemsSetup } from './get_services_items';
import { serviceGroupQuery } from '../../../../common/utils/service_group_query';
import { ServiceGroup } from '../../../../common/service_groups';
interface AggregationParams {
environment: string;
@ -38,6 +40,7 @@ interface AggregationParams {
maxNumServices: number;
start: number;
end: number;
serviceGroup: ServiceGroup | null;
}
export async function getServiceTransactionStats({
@ -48,6 +51,7 @@ export async function getServiceTransactionStats({
maxNumServices,
start,
end,
serviceGroup,
}: AggregationParams) {
const { apmEventClient } = setup;
@ -81,6 +85,7 @@ export async function getServiceTransactionStats({
...rangeQuery(start, end),
...environmentQuery(environment),
...kqlQuery(kuery),
...serviceGroupQuery(serviceGroup),
],
},
},

View file

@ -15,6 +15,8 @@ import { kqlQuery, rangeQuery } from '../../../../../observability/server';
import { environmentQuery } from '../../../../common/utils/environment_query';
import { ProcessorEvent } from '../../../../common/processor_event';
import { Setup } from '../../../lib/helpers/setup_request';
import { serviceGroupQuery } from '../../../../common/utils/service_group_query';
import { ServiceGroup } from '../../../../common/service_groups';
export async function getServicesFromErrorAndMetricDocuments({
environment,
@ -23,6 +25,7 @@ export async function getServicesFromErrorAndMetricDocuments({
kuery,
start,
end,
serviceGroup,
}: {
setup: Setup;
environment: string;
@ -30,6 +33,7 @@ export async function getServicesFromErrorAndMetricDocuments({
kuery: string;
start: number;
end: number;
serviceGroup: ServiceGroup | null;
}) {
const { apmEventClient } = setup;
@ -47,6 +51,7 @@ export async function getServicesFromErrorAndMetricDocuments({
...rangeQuery(start, end),
...environmentQuery(environment),
...kqlQuery(kuery),
...serviceGroupQuery(serviceGroup),
],
},
},

View file

@ -12,10 +12,11 @@ import { getHealthStatuses } from './get_health_statuses';
import { getServicesFromErrorAndMetricDocuments } from './get_services_from_error_and_metric_documents';
import { getServiceTransactionStats } from './get_service_transaction_stats';
import { mergeServiceStats } from './merge_service_stats';
import { ServiceGroup } from '../../../../common/service_groups';
export type ServicesItemsSetup = Setup;
const MAX_NUMBER_OF_SERVICES = 500;
const MAX_NUMBER_OF_SERVICES = 50;
export async function getServicesItems({
environment,
@ -25,6 +26,7 @@ export async function getServicesItems({
logger,
start,
end,
serviceGroup,
}: {
environment: string;
kuery: string;
@ -33,6 +35,7 @@ export async function getServicesItems({
logger: Logger;
start: number;
end: number;
serviceGroup: ServiceGroup | null;
}) {
return withApmSpan('get_services_items', async () => {
const params = {
@ -43,6 +46,7 @@ export async function getServicesItems({
maxNumServices: MAX_NUMBER_OF_SERVICES,
start,
end,
serviceGroup,
};
const [

View file

@ -11,6 +11,7 @@ import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values';
import { Environment } from '../../../../common/environment_rt';
import { ProcessorEvent } from '../../../../common/processor_event';
import { joinByKey } from '../../../../common/utils/join_by_key';
import { ServiceGroup } from '../../../../common/service_groups';
import { Setup } from '../../../lib/helpers/setup_request';
import { getHealthStatuses } from './get_health_statuses';
@ -20,16 +21,18 @@ export async function getSortedAndFilteredServices({
end,
environment,
logger,
serviceGroup,
}: {
setup: Setup;
start: number;
end: number;
environment: Environment;
logger: Logger;
serviceGroup: ServiceGroup | null;
}) {
const { apmEventClient } = setup;
async function getServicesFromTermsEnum() {
async function getServiceNamesFromTermsEnum() {
if (environment !== ENVIRONMENT_ALL.value) {
return [];
}
@ -54,27 +57,32 @@ export async function getSortedAndFilteredServices({
return response.terms;
}
const [servicesWithHealthStatuses, serviceNamesFromTermsEnum] =
await Promise.all([
getHealthStatuses({
setup,
start,
end,
environment,
}).catch((error) => {
logger.error(error);
return [];
}),
getServicesFromTermsEnum(),
]);
const [servicesWithHealthStatuses, selectedServices] = await Promise.all([
getHealthStatuses({
setup,
start,
end,
environment,
}).catch((error) => {
logger.error(error);
return [];
}),
serviceGroup
? getServiceNamesFromServiceGroup(serviceGroup)
: getServiceNamesFromTermsEnum(),
]);
const services = joinByKey(
[
...servicesWithHealthStatuses,
...serviceNamesFromTermsEnum.map((serviceName) => ({ serviceName })),
...selectedServices.map((serviceName) => ({ serviceName })),
],
'serviceName'
);
return services;
}
async function getServiceNamesFromServiceGroup(serviceGroup: ServiceGroup) {
return serviceGroup.serviceNames;
}

View file

@ -9,6 +9,7 @@ import { Logger } from '@kbn/logging';
import { withApmSpan } from '../../../utils/with_apm_span';
import { Setup } from '../../../lib/helpers/setup_request';
import { getServicesItems } from './get_services_items';
import { ServiceGroup } from '../../../../common/service_groups';
export async function getServices({
environment,
@ -18,6 +19,7 @@ export async function getServices({
logger,
start,
end,
serviceGroup,
}: {
environment: string;
kuery: string;
@ -26,6 +28,7 @@ export async function getServices({
logger: Logger;
start: number;
end: number;
serviceGroup: ServiceGroup | null;
}) {
return withApmSpan('get_services', async () => {
const items = await getServicesItems({
@ -36,6 +39,7 @@ export async function getServices({
logger,
start,
end,
serviceGroup,
});
return {

View file

@ -59,6 +59,7 @@ describe('services queries', () => {
kuery: '',
start: 0,
end: 50000,
serviceGroup: null,
})
);

View file

@ -54,11 +54,17 @@ import { Annotation } from './../../../../observability/common/annotations';
import { ConnectionStatsItemWithImpact } from './../../../common/connections';
import { getSortedAndFilteredServices } from './get_services/get_sorted_and_filtered_services';
import { ServiceHealthStatus } from './../../../common/service_health_status';
import { getServiceGroup } from '../service_groups/get_service_group';
const servicesRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/services',
params: t.type({
query: t.intersection([environmentRt, kueryRt, rangeRt]),
query: t.intersection([
environmentRt,
kueryRt,
rangeRt,
t.partial({ serviceGroup: t.string }),
]),
}),
options: { tags: ['access:apm'] },
async handler(resources): Promise<{
@ -99,16 +105,28 @@ const servicesRoute = createApmServerRoute({
}
>;
}> {
const setup = await setupRequest(resources);
const { params, logger } = resources;
const { environment, kuery, start, end } = params.query;
const { context, params, logger } = resources;
const {
environment,
kuery,
start,
end,
serviceGroup: serviceGroupId,
} = params.query;
const savedObjectsClient = context.core.savedObjects.client;
const [setup, serviceGroup] = await Promise.all([
setupRequest(resources),
serviceGroupId
? getServiceGroup({ savedObjectsClient, serviceGroupId })
: Promise.resolve(null),
]);
const searchAggregatedTransactions = await getSearchAggregatedTransactions({
...setup,
kuery,
start,
end,
});
return getServices({
environment,
kuery,
@ -117,6 +135,7 @@ const servicesRoute = createApmServerRoute({
logger,
start,
end,
serviceGroup,
});
},
});
@ -1232,7 +1251,12 @@ const sortedAndFilteredServicesRoute = createApmServerRoute({
tags: ['access:apm'],
},
params: t.type({
query: t.intersection([rangeRt, environmentRt, kueryRt]),
query: t.intersection([
rangeRt,
environmentRt,
kueryRt,
t.partial({ serviceGroup: t.string }),
]),
}),
handler: async (
resources
@ -1243,7 +1267,7 @@ const sortedAndFilteredServicesRoute = createApmServerRoute({
}>;
}> => {
const {
query: { start, end, environment, kuery },
query: { start, end, environment, kuery, serviceGroup: serviceGroupId },
} = resources.params;
if (kuery) {
@ -1252,7 +1276,14 @@ const sortedAndFilteredServicesRoute = createApmServerRoute({
};
}
const setup = await setupRequest(resources);
const savedObjectsClient = resources.context.core.savedObjects.client;
const [setup, serviceGroup] = await Promise.all([
setupRequest(resources),
serviceGroupId
? getServiceGroup({ savedObjectsClient, serviceGroupId })
: Promise.resolve(null),
]);
return {
services: await getSortedAndFilteredServices({
setup,
@ -1260,6 +1291,7 @@ const sortedAndFilteredServicesRoute = createApmServerRoute({
end,
environment,
logger: resources.logger,
serviceGroup,
}),
};
},

View file

@ -0,0 +1,33 @@
/*
* 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 { SavedObjectsType } from 'src/core/server';
import { i18n } from '@kbn/i18n';
import { APM_SERVICE_GROUP_SAVED_OBJECT_TYPE } from '../../common/service_groups';
export const apmServiceGroups: SavedObjectsType = {
name: APM_SERVICE_GROUP_SAVED_OBJECT_TYPE,
hidden: false,
namespaceType: 'multiple',
mappings: {
properties: {
groupName: { type: 'keyword' },
kuery: { type: 'text' },
description: { type: 'text' },
serviceNames: { type: 'keyword' },
color: { type: 'text' },
},
},
management: {
importableAndExportable: false,
icon: 'apmApp',
getTitle: () =>
i18n.translate('xpack.apm.apmServiceGroups.index', {
defaultMessage: 'APM Service Groups - Index',
}),
},
};

View file

@ -8,3 +8,4 @@
export { apmIndices } from './apm_indices';
export { apmTelemetry } from './apm_telemetry';
export { apmServerSettings } from './apm_server_settings';
export { apmServiceGroups } from './apm_service_groups';

View file

@ -10,3 +10,4 @@ export const maxSuggestions = 'observability:maxSuggestions';
export const enableComparisonByDefault = 'observability:enableComparisonByDefault';
export const enableInfrastructureView = 'observability:enableInfrastructureView';
export const defaultApmServiceEnvironment = 'observability:apmDefaultServiceEnvironment';
export const enableServiceGroups = 'observability:enableServiceGroups';

View file

@ -71,11 +71,13 @@ export function ObservabilityPageTemplate({
const isSelected =
entry.app === currentAppId &&
matchPath(currentPath, {
path: entry.path,
exact: !!entry.matchFullPath,
strict: !entry.ignoreTrailingSlash,
}) != null;
(entry.matchPath
? entry.matchPath(currentPath)
: matchPath(currentPath, {
path: entry.path,
exact: !!entry.matchFullPath,
strict: !entry.ignoreTrailingSlash,
}) != null);
const badgeLocalStorageId = `observability.nav_item_badge_visible_${entry.app}${entry.path}`;
return {
id: `${sectionIndex}.${entryIndex}`,

View file

@ -27,7 +27,7 @@ export {
enableInspectEsQueries,
enableComparisonByDefault,
enableInfrastructureView,
defaultApmServiceEnvironment,
enableServiceGroups,
} from '../common/ui_settings_keys';
export { uptimeOverviewLocatorID } from '../common';
@ -92,7 +92,7 @@ export { getApmTraceUrl } from './utils/get_apm_trace_url';
export { createExploratoryViewUrl } from './components/shared/exploratory_view/configurations/utils';
export { ALL_VALUES_SELECTED } from './components/shared/field_value_suggestions/field_value_combobox';
export type { AllSeries } from './components/shared/exploratory_view/hooks/use_series_storage';
export type { SeriesUrl, ReportViewType } from './components/shared/exploratory_view/types';
export type { SeriesUrl } from './components/shared/exploratory_view/types';
export type {
ObservabilityRuleTypeFormatter,
@ -101,7 +101,6 @@ export type {
} from './rules/create_observability_rule_type_registry';
export { createObservabilityRuleTypeRegistryMock } from './rules/observability_rule_type_registry_mock';
export type { ExploratoryEmbeddableProps } from './components/shared/exploratory_view/embeddable/embeddable';
export type { ActionTypes } from './components/shared/exploratory_view/embeddable/use_actions';
export type { AddInspectorRequest } from './context/inspector/inspector_context';
export { InspectorContextProvider } from './context/inspector/inspector_context';

View file

@ -32,6 +32,8 @@ export interface NavigationEntry {
onClick?: (event: React.MouseEvent<HTMLElement | HTMLButtonElement, MouseEvent>) => void;
// shows NEW badge besides the navigation label, which will automatically disappear when menu item is clicked.
isNewFeature?: boolean;
// override default path matching logic to determine if nav entry is selected
matchPath?: (path: string) => boolean;
}
export interface NavigationRegistry {

View file

@ -15,8 +15,16 @@ import {
maxSuggestions,
enableInfrastructureView,
defaultApmServiceEnvironment,
enableServiceGroups,
} from '../common/ui_settings_keys';
const technicalPreviewLabel = i18n.translate(
'xpack.observability.uiSettings.technicalPreviewLabel',
{
defaultMessage: 'technical preview',
}
);
/**
* uiSettings definitions for Observability.
*/
@ -77,4 +85,17 @@ export const uiSettings: Record<string, UiSettingsParams<boolean | number | stri
value: '',
schema: schema.string(),
},
[enableServiceGroups]: {
category: [observabilityFeatureId],
name: i18n.translate('xpack.observability.enableServiceGroups', {
defaultMessage: 'Service groups feature',
}),
value: false,
description: i18n.translate('xpack.observability.enableServiceGroupsDescription', {
defaultMessage: '{technicalPreviewLabel} Enable the Service groups feature on APM UI',
values: { technicalPreviewLabel: `<em>[${technicalPreviewLabel}]</em>` },
}),
schema: schema.boolean(),
requiresPageReload: true,
},
};