mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
bfc7de92dc
commit
afb50e02a0
66 changed files with 2432 additions and 76 deletions
|
@ -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',
|
||||
|
|
|
@ -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.' },
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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": {
|
||||
|
|
23
x-pack/plugins/apm/common/service_groups.ts
Normal file
23
x-pack/plugins/apm/common/service_groups.ts
Normal 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;
|
||||
}
|
22
x-pack/plugins/apm/common/utils/service_group_query.ts
Normal file
22
x-pack/plugins/apm/common/utils/service_group_query.ts
Normal 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 } }]
|
||||
: [];
|
||||
}
|
|
@ -83,6 +83,7 @@ export function BackendDetailDependenciesTable() {
|
|||
rangeTo,
|
||||
latencyAggregationType: undefined,
|
||||
transactionType: undefined,
|
||||
serviceGroup: '',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -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/.';
|
|
@ -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}</>;
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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';
|
|
@ -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' }
|
||||
);
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -21,6 +21,7 @@ const query = {
|
|||
rangeTo: 'now',
|
||||
environment: ENVIRONMENT_ALL.value,
|
||||
kuery: '',
|
||||
serviceGroup: '',
|
||||
};
|
||||
|
||||
const service: any = {
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -86,7 +86,7 @@ export function getTraceListColumns({
|
|||
content={
|
||||
<ServiceLink
|
||||
agentName={agentName}
|
||||
query={{ ...query, transactionType }}
|
||||
query={{ ...query, transactionType, serviceGroup: '' }}
|
||||
serviceName={serviceName}
|
||||
/>
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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}</>;
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
}));
|
||||
}
|
|
@ -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({
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -32,6 +32,7 @@ export const PERSISTENT_APM_PARAMS: Array<keyof APMQueryParams> = [
|
|||
'refreshPaused',
|
||||
'refreshInterval',
|
||||
'environment',
|
||||
'serviceGroup',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 === ''
|
||||
);
|
||||
|
|
|
@ -36,6 +36,7 @@ Example.args = {
|
|||
kuery: '',
|
||||
rangeFrom: 'now-15m',
|
||||
rangeTo: 'now',
|
||||
serviceGroup: '',
|
||||
},
|
||||
serviceName: 'opbeans-java',
|
||||
};
|
||||
|
|
|
@ -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>>(
|
||||
|
|
|
@ -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' },
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}) ?? []
|
||||
);
|
||||
}
|
148
x-pack/plugins/apm/server/routes/service_groups/route.ts
Normal file
148
x-pack/plugins/apm/server/routes/service_groups/route.ts
Normal 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,
|
||||
};
|
|
@ -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
|
||||
);
|
||||
}
|
|
@ -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 ?? [])),
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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),
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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),
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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 [
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -59,6 +59,7 @@ describe('services queries', () => {
|
|||
kuery: '',
|
||||
start: 0,
|
||||
end: 50000,
|
||||
serviceGroup: null,
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
|
|
@ -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',
|
||||
}),
|
||||
},
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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}`,
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue