+ + + {routes.map((route, i) => ( diff --git a/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap b/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap index 82fabff6101919..5094287a402ea0 100644 --- a/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap @@ -8,6 +8,7 @@ exports[`Home component should render services 1`] = ` "setHeaderActionMenu": [Function], }, "config": Object { + "profilingEnabled": false, "serviceMapEnabled": true, "ui": Object { "enabled": false, @@ -95,6 +96,7 @@ exports[`Home component should render traces 1`] = ` "setHeaderActionMenu": [Function], }, "config": Object { + "profilingEnabled": false, "serviceMapEnabled": true, "ui": Object { "enabled": false, diff --git a/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx b/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx index 08d95aca24714b..a7cbd7a79b4a7f 100644 --- a/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx @@ -114,6 +114,12 @@ function ServiceDetailsTransactions( return ; } +function ServiceDetailsProfiling( + props: RouteComponentProps<{ serviceName: string }> +) { + return ; +} + function SettingsAgentConfiguration(props: RouteComponentProps<{}>) { return ( @@ -307,6 +313,14 @@ export const routes: APMRouteDefinition[] = [ return query.transactionName as string; }, }, + { + exact: true, + path: '/services/:serviceName/profiling', + component: withApmServiceContext(ServiceDetailsProfiling), + breadcrumb: i18n.translate('xpack.apm.breadcrumb.serviceProfilingTitle', { + defaultMessage: 'Profiling', + }), + }, { exact: true, path: '/services/:serviceName/service-map', diff --git a/x-pack/plugins/apm/public/components/app/Settings/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/index.tsx index 36c36e3957e962..61f68a74be9b70 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/index.tsx @@ -16,8 +16,6 @@ import { import { i18n } from '@kbn/i18n'; import React, { ReactNode, useState } from 'react'; import { RouteComponentProps } from 'react-router-dom'; -import { HeaderMenuPortal } from '../../../../../observability/public'; -import { ActionMenu } from '../../../application/action_menu'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; import { getAPMHref } from '../../shared/Links/apm/APMLink'; import { HomeLink } from '../../shared/Links/apm/HomeLink'; @@ -27,7 +25,7 @@ interface SettingsProps extends RouteComponentProps<{}> { } export function Settings({ children, location }: SettingsProps) { - const { appMountParameters, core } = useApmPluginContext(); + const { core } = useApmPluginContext(); const { basePath } = core.http; const canAccessML = !!core.application.capabilities.ml?.canAccessML; const { search, pathname } = location; @@ -44,11 +42,6 @@ export function Settings({ children, location }: SettingsProps) { return ( <> - - - diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx b/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx index d2d5c9f6f3a9ab..5c9d79f37cc573 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx +++ b/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx @@ -8,6 +8,9 @@ import { EuiTab } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { ReactNode } from 'react'; +import { EuiBetaBadge } from '@elastic/eui'; +import { EuiFlexItem } from '@elastic/eui'; +import { EuiFlexGroup } from '@elastic/eui'; import { isJavaAgentName, isRumAgentName } from '../../../../common/agent_name'; import { enableServiceOverview } from '../../../../common/ui_settings_keys'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; @@ -19,6 +22,7 @@ import { useServiceMapHref } from '../../shared/Links/apm/ServiceMapLink'; import { useServiceNodeOverviewHref } from '../../shared/Links/apm/ServiceNodeOverviewLink'; import { useServiceOverviewHref } from '../../shared/Links/apm/service_overview_link'; import { useTransactionsOverviewHref } from '../../shared/Links/apm/transaction_overview_link'; +import { useServiceProfilingHref } from '../../shared/Links/apm/service_profiling_link'; import { MainTabs } from '../../shared/main_tabs'; import { ErrorGroupOverview } from '../error_group_overview'; import { ServiceMap } from '../ServiceMap'; @@ -26,12 +30,13 @@ import { ServiceNodeOverview } from '../service_node_overview'; import { ServiceMetrics } from '../service_metrics'; import { ServiceOverview } from '../service_overview'; import { TransactionOverview } from '../transaction_overview'; +import { ServiceProfiling } from '../service_profiling'; import { Correlations } from '../correlations'; interface Tab { key: string; href: string; - text: string; + text: ReactNode; render: () => ReactNode; } @@ -43,12 +48,16 @@ interface Props { | 'nodes' | 'overview' | 'service-map' + | 'profiling' | 'transactions'; } export function ServiceDetailTabs({ serviceName, tab }: Props) { const { agentName } = useApmServiceContext(); - const { uiSettings } = useApmPluginContext().core; + const { + core: { uiSettings }, + config, + } = useApmPluginContext(); const { urlParams: { latencyAggregationType }, } = useUrlParams(); @@ -114,6 +123,38 @@ export function ServiceDetailTabs({ serviceName, tab }: Props) { ) : null, }; + const profilingTab = { + key: 'profiling', + href: useServiceProfilingHref({ serviceName }), + text: ( + + + {i18n.translate('xpack.apm.serviceDetails.profilingTabLabel', { + defaultMessage: 'Profiling', + })} + + + + + + ), + render: () => , + }; + const tabs: Tab[] = [transactionsTab, errorsTab]; if (uiSettings.get(enableServiceOverview)) { @@ -128,6 +169,10 @@ export function ServiceDetailTabs({ serviceName, tab }: Props) { tabs.push(serviceMapTab); + if (config.profilingEnabled) { + tabs.push(profilingTab); + } + const selectedTab = tabs.find((serviceTab) => serviceTab.key === tab); return ( diff --git a/x-pack/plugins/apm/public/components/app/service_profiling/index.tsx b/x-pack/plugins/apm/public/components/app/service_profiling/index.tsx new file mode 100644 index 00000000000000..09a42f9b2df90a --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_profiling/index.tsx @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { + EuiFlexGroup, + EuiFlexItem, + EuiPage, + EuiPanel, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useEffect, useState } from 'react'; +import { + getValueTypeConfig, + ProfilingValueType, +} from '../../../../common/profiling'; +import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { useFetcher } from '../../../hooks/use_fetcher'; +import { SearchBar } from '../../shared/search_bar'; +import { ServiceProfilingFlamegraph } from './service_profiling_flamegraph'; +import { ServiceProfilingTimeline } from './service_profiling_timeline'; + +interface ServiceProfilingProps { + serviceName: string; + environment?: string; +} + +export function ServiceProfiling({ + serviceName, + environment, +}: ServiceProfilingProps) { + const { + urlParams: { start, end }, + uiFilters, + } = useUrlParams(); + + const { data = [] } = useFetcher( + (callApmApi) => { + if (!start || !end) { + return; + } + + return callApmApi({ + endpoint: 'GET /api/apm/services/{serviceName}/profiling/timeline', + params: { + path: { serviceName }, + query: { + start, + end, + environment, + uiFilters: JSON.stringify(uiFilters), + }, + }, + }); + }, + [start, end, serviceName, environment, uiFilters] + ); + + const [valueType, setValueType] = useState(); + + useEffect(() => { + if (!data.length) { + return; + } + + const availableValueTypes = data.reduce((set, point) => { + (Object.keys(point.valueTypes).filter( + (type) => type !== 'unknown' + ) as ProfilingValueType[]) + .filter((type) => point.valueTypes[type] > 0) + .forEach((type) => { + set.add(type); + }); + + return set; + }, new Set()); + + if (!valueType || !availableValueTypes.has(valueType)) { + setValueType(Array.from(availableValueTypes)[0]); + } + }, [data, valueType]); + + return ( + <> + + + + + +

+ {i18n.translate('xpack.apm.profilingOverviewTitle', { + defaultMessage: 'Profiling', + })} +

+
+
+ + + + + { + setValueType(type); + }} + selectedValueType={valueType} + /> + + {valueType ? ( + + +

{getValueTypeConfig(valueType).label}

+
+
+ ) : null} + + + +
+
+
+
+
+ + ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_profiling/service_profiling_flamegraph.tsx b/x-pack/plugins/apm/public/components/app/service_profiling/service_profiling_flamegraph.tsx new file mode 100644 index 00000000000000..03248d28366748 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_profiling/service_profiling_flamegraph.tsx @@ -0,0 +1,419 @@ +/* + * 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 { + Chart, + Datum, + Partition, + PartitionLayout, + PrimitiveValue, + Settings, + TooltipInfo, +} from '@elastic/charts'; +import { EuiInMemoryTable } from '@elastic/eui'; +import { EuiFieldText } from '@elastic/eui'; +import { EuiToolTip } from '@elastic/eui'; +import { + EuiCheckbox, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + euiPaletteForTemperature, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { find, sumBy } from 'lodash'; +import { rgba } from 'polished'; +import React, { useMemo, useState } from 'react'; +import seedrandom from 'seedrandom'; +import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; +import { useChartTheme } from '../../../../../observability/public'; +import { + getValueTypeConfig, + ProfileNode, + ProfilingValueType, + ProfilingValueTypeUnit, +} from '../../../../common/profiling'; +import { + asDuration, + asDynamicBytes, + asInteger, +} from '../../../../common/utils/formatters'; +import { UIFilters } from '../../../../typings/ui_filters'; +import { useFetcher } from '../../../hooks/use_fetcher'; +import { useTheme } from '../../../hooks/use_theme'; +import { px, unit } from '../../../style/variables'; + +const colors = euiPaletteForTemperature(100).slice(50, 85); + +interface ProfileDataPoint { + id: string; + value: number; + depth: number; + layers: Record; +} + +const TooltipContainer = euiStyled.div` + background-color: ${(props) => props.theme.eui.euiColorDarkestShade}; + border-radius: ${(props) => props.theme.eui.euiBorderRadius}; + color: ${(props) => props.theme.eui.euiColorLightestShade}; + padding: ${(props) => props.theme.eui.paddingSizes.s}; +`; + +const formatValue = ( + value: number, + valueUnit: ProfilingValueTypeUnit +): string => { + switch (valueUnit) { + case ProfilingValueTypeUnit.ns: + return asDuration(value / 1000); + + case ProfilingValueTypeUnit.us: + return asDuration(value); + + case ProfilingValueTypeUnit.count: + return asInteger(value); + + case ProfilingValueTypeUnit.bytes: + return asDynamicBytes(value); + } +}; + +function CustomTooltip({ + values, + nodes, + valueUnit, +}: TooltipInfo & { + nodes: Record; + valueUnit: ProfilingValueTypeUnit; +}) { + const first = values[0]; + + const foundNode = find(nodes, (node) => node.label === first.label); + + const label = foundNode?.fqn ?? first.label; + const value = formatValue(first.value, valueUnit) + first.formattedValue; + + return ( + + + + + + + {label} + + + + {value} + + + + + ); +} + +export function ServiceProfilingFlamegraph({ + serviceName, + environment, + valueType, + start, + end, + uiFilters, +}: { + serviceName: string; + environment?: string; + valueType?: ProfilingValueType; + start?: string; + end?: string; + uiFilters: UIFilters; +}) { + const theme = useTheme(); + + const [collapseSimilarFrames, setCollapseSimilarFrames] = useState(true); + const [highlightFilter, setHighlightFilter] = useState(''); + + const { data } = useFetcher( + (callApmApi) => { + if (!start || !end || !valueType) { + return undefined; + } + + return callApmApi({ + endpoint: 'GET /api/apm/services/{serviceName}/profiling/statistics', + params: { + path: { + serviceName, + }, + query: { + start, + end, + environment, + valueType, + uiFilters: JSON.stringify(uiFilters), + }, + }, + }); + }, + [start, end, environment, serviceName, valueType, uiFilters] + ); + + const points = useMemo(() => { + if (!data) { + return []; + } + + const { rootNodes, nodes } = data; + + const getDataPoints = ( + node: ProfileNode, + depth: number + ): ProfileDataPoint[] => { + const { children } = node; + + if (!children.length) { + // edge + return [ + { + id: node.id, + value: node.value, + depth, + layers: { + [depth]: node.id, + }, + }, + ]; + } + + const directChildNodes = children.map((childId) => nodes[childId]); + + const shouldCollapse = + collapseSimilarFrames && + node.value === 0 && + directChildNodes.length === 1 && + directChildNodes[0].value === 0; + + const nextDepth = shouldCollapse ? depth : depth + 1; + + const childDataPoints = children.flatMap((childId) => + getDataPoints(nodes[childId], nextDepth) + ); + + if (!shouldCollapse) { + childDataPoints.forEach((point) => { + point.layers[depth] = node.id; + }); + } + + const totalTime = sumBy(childDataPoints, 'value'); + const selfTime = node.value - totalTime; + + if (selfTime === 0) { + return childDataPoints; + } + + return [ + ...childDataPoints, + { + id: '', + value: selfTime, + layers: { [nextDepth]: '' }, + depth, + }, + ]; + }; + + const root = { + id: 'root', + label: 'root', + fqn: 'root', + children: rootNodes, + value: 0, + }; + + nodes.root = root; + + return getDataPoints(root, 0); + }, [data, collapseSimilarFrames]); + + const layers = useMemo(() => { + if (!data || !points.length) { + return []; + } + + const { nodes } = data; + + const maxDepth = Math.max(...points.map((point) => point.depth)); + + return [...new Array(maxDepth)].map((_, depth) => { + return { + groupByRollup: (d: Datum) => d.layers[depth], + nodeLabel: (id: PrimitiveValue) => { + if (nodes[id!]) { + return nodes[id!].label; + } + return ''; + }, + showAccessor: (id: PrimitiveValue) => !!id, + shape: { + fillColor: (d: { dataName: string }) => { + const node = nodes[d.dataName]; + + if ( + !node || + // TODO: apply highlight to entire stack, not just node + (highlightFilter && !node.fqn.includes(highlightFilter)) + ) { + return rgba(0, 0, 0, 0.25); + } + + const integer = + Math.abs(seedrandom(d.dataName).int32()) % colors.length; + return colors[integer]; + }, + }, + }; + }); + }, [points, highlightFilter, data]); + + const chartTheme = useChartTheme(); + + const chartSize = { + height: layers.length * 20, + width: '100%', + }; + + const items = Object.values(data?.nodes ?? {}).filter((node) => + highlightFilter ? node.fqn.includes(highlightFilter) : true + ); + + const valueUnit = valueType + ? getValueTypeConfig(valueType).unit + : ProfilingValueTypeUnit.count; + + return ( + + + + ( + + ), + }} + /> + d.value as number} + valueFormatter={() => ''} + config={{ + fillLabel: { + fontFamily: theme.eui.euiCodeFontFamily, + // @ts-expect-error (coming soon in Elastic charts) + clipText: true, + }, + drilldown: true, + fontFamily: theme.eui.euiCodeFontFamily, + minFontSize: 9, + maxFontSize: 9, + maxRowCount: 1, + partitionLayout: PartitionLayout.icicle, + }} + /> + + + + + + { + setCollapseSimilarFrames((state) => !state); + }} + label={i18n.translate( + 'xpack.apm.profiling.collapseSimilarFrames', + { + defaultMessage: 'Collapse similar', + } + )} + /> + + + { + if (!e.target.value) { + setHighlightFilter(''); + } + }} + onKeyPress={(e) => { + if (e.charCode === 13) { + setHighlightFilter(() => (e.target as any).value); + } + }} + /> + + + { + return ( + + {item.label} + + ); + }, + }, + { + field: 'value', + name: i18n.translate('xpack.apm.profiling.table.value', { + defaultMessage: 'Self', + }), + render: (_, item) => formatValue(item.value, valueUnit), + width: px(unit * 6), + }, + ]} + /> + + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_profiling/service_profiling_timeline.tsx b/x-pack/plugins/apm/public/components/app/service_profiling/service_profiling_timeline.tsx new file mode 100644 index 00000000000000..d5dc2f5d56afc0 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_profiling/service_profiling_timeline.tsx @@ -0,0 +1,147 @@ +/* + * 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 { + Axis, + BarSeries, + Chart, + niceTimeFormatter, + Position, + ScaleType, + Settings, +} from '@elastic/charts'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiText, + euiPaletteColorBlind, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { useChartTheme } from '../../../../../observability/public'; +import { + getValueTypeConfig, + ProfilingValueType, +} from '../../../../common/profiling'; + +type ProfilingTimelineItem = { + x: number; +} & { valueTypes: Record }; + +const palette = euiPaletteColorBlind(); + +export function ServiceProfilingTimeline({ + start, + end, + series, + onValueTypeSelect, + selectedValueType, +}: { + series: ProfilingTimelineItem[]; + start: string; + end: string; + onValueTypeSelect: (valueType: ProfilingValueType) => void; + selectedValueType: ProfilingValueType | undefined; +}) { + const chartTheme = useChartTheme(); + + const xFormat = niceTimeFormatter([Date.parse(start), Date.parse(end)]); + + function getSeriesForValueType(type: ProfilingValueType | 'unknown') { + const label = + type === 'unknown' + ? i18n.translate('xpack.apm.serviceProfiling.valueTypeLabel.unknown', { + defaultMessage: 'Other', + }) + : getValueTypeConfig(type).label; + + return { + name: label, + id: type, + data: series.map((coord) => ({ + x: coord.x, + y: coord.valueTypes[type], + })), + }; + } + + const specs = [ + getSeriesForValueType('unknown'), + ...Object.values(ProfilingValueType).map((type) => + getSeriesForValueType(type) + ), + ] + .filter((spec) => spec.data.some((coord) => coord.y > 0)) + .map((spec, index) => { + return { + ...spec, + color: palette[index], + }; + }); + + return ( + + + + + + + {specs.map((spec) => ( + + ))} + + + + + {specs.map((spec) => ( + + + + + + + { + if (spec.id !== 'unknown') { + onValueTypeSelect(spec.id); + } + }} + > + + {spec.name} + + + + + + ))} + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx b/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx index 20a589f3126c45..2ba2ae4b5acb6f 100644 --- a/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx @@ -8,9 +8,6 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React, { ReactNode } from 'react'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; -import { HeaderMenuPortal } from '../../../../../observability/public'; -import { ActionMenu } from '../../../application/action_menu'; -import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; import { EnvironmentFilter } from '../EnvironmentFilter'; const HeaderFlexGroup = euiStyled(EuiFlexGroup)` @@ -19,13 +16,8 @@ const HeaderFlexGroup = euiStyled(EuiFlexGroup)` `; export function ApmHeader({ children }: { children: ReactNode }) { - const { setHeaderActionMenu } = useApmPluginContext().appMountParameters; - return ( - - - {children} diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/service_profiling_link.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/service_profiling_link.tsx new file mode 100644 index 00000000000000..ab3b085e4e2559 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/service_profiling_link.tsx @@ -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 { EuiLink } from '@elastic/eui'; +import React from 'react'; +import { APMLinkExtendProps, useAPMHref } from './APMLink'; + +interface ServiceProfilingLinkProps extends APMLinkExtendProps { + serviceName: string; + environment?: string; +} + +export function useServiceProfilingHref({ + serviceName, + environment, +}: ServiceProfilingLinkProps) { + const query = environment + ? { + environment, + } + : {}; + return useAPMHref({ + path: `/services/${serviceName}/profiling`, + query, + }); +} + +export function ServiceProfilingLink({ + serviceName, + environment, + ...rest +}: ServiceProfilingLinkProps) { + const href = useServiceProfilingHref({ serviceName, environment }); + return ; +} diff --git a/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx b/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx index d17a4a27b646cf..024deca558497b 100644 --- a/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx +++ b/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx @@ -81,6 +81,7 @@ const mockConfig: ConfigSchema = { ui: { enabled: false, }, + profilingEnabled: false, }; const mockPlugin = { diff --git a/x-pack/plugins/apm/public/index.ts b/x-pack/plugins/apm/public/index.ts index 5460c6dc625a66..2734269b9cff9e 100644 --- a/x-pack/plugins/apm/public/index.ts +++ b/x-pack/plugins/apm/public/index.ts @@ -13,6 +13,7 @@ import { ApmPlugin, ApmPluginSetup, ApmPluginStart } from './plugin'; export interface ConfigSchema { serviceMapEnabled: boolean; + profilingEnabled: boolean; ui: { enabled: boolean; }; diff --git a/x-pack/plugins/apm/server/index.ts b/x-pack/plugins/apm/server/index.ts index 52b5765a984d51..00910353ac2787 100644 --- a/x-pack/plugins/apm/server/index.ts +++ b/x-pack/plugins/apm/server/index.ts @@ -16,6 +16,7 @@ export const config = { exposeToBrowser: { serviceMapEnabled: true, ui: true, + profilingEnabled: true, }, schema: schema.object({ enabled: schema.boolean({ defaultValue: true }), @@ -47,6 +48,7 @@ export const config = { metricsInterval: schema.number({ defaultValue: 30 }), maxServiceEnvironments: schema.number({ defaultValue: 100 }), maxServiceSelection: schema.number({ defaultValue: 50 }), + profilingEnabled: schema.boolean({ defaultValue: false }), }), }; diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts index c47d511ca565c7..368c0eb305f21f 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts @@ -6,6 +6,7 @@ */ import { ValuesType } from 'utility-types'; +import { Profile } from '../../../../../typings/es_schemas/ui/profile'; import { ElasticsearchClient, KibanaRequest, @@ -43,6 +44,7 @@ type TypeOfProcessorEvent = { transaction: Transaction; span: Span; metric: Metric; + profile: Profile; }[T]; type ESSearchRequestOf = Omit< diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.ts index eef9aff946ea7a..38989d172a73f8 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.ts @@ -23,6 +23,8 @@ const processorEventIndexMap: Record = { [ProcessorEvent.span]: 'apm_oss.spanIndices', [ProcessorEvent.metric]: 'apm_oss.metricsIndices', [ProcessorEvent.error]: 'apm_oss.errorIndices', + // TODO: should have its own config setting + [ProcessorEvent.profile]: 'apm_oss.transactionIndices', }; export function unpackProcessorEvents( diff --git a/x-pack/plugins/apm/server/lib/services/profiling/get_service_profiling_statistics.ts b/x-pack/plugins/apm/server/lib/services/profiling/get_service_profiling_statistics.ts new file mode 100644 index 00000000000000..0c9bbb35be631a --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/profiling/get_service_profiling_statistics.ts @@ -0,0 +1,279 @@ +/* + * 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 { keyBy, last } from 'lodash'; +import { Logger } from 'kibana/server'; +import util from 'util'; +import { maybe } from '../../../../common/utils/maybe'; +import { ProfileStackFrame } from '../../../../typings/es_schemas/ui/profile'; +import { + ProfilingValueType, + ProfileNode, + getValueTypeConfig, +} from '../../../../common/profiling'; +import { ProcessorEvent } from '../../../../common/processor_event'; +import { ESFilter } from '../../../../../../typings/elasticsearch'; +import { + PROFILE_STACK, + PROFILE_TOP_ID, + SERVICE_NAME, +} from '../../../../common/elasticsearch_fieldnames'; +import { rangeQuery, environmentQuery } from '../../../../common/utils/queries'; +import { APMEventClient } from '../../helpers/create_es_client/create_apm_event_client'; +import { Setup, SetupTimeRange } from '../../helpers/setup_request'; +import { withApmSpan } from '../../../utils/with_apm_span'; + +const MAX_STACK_IDS = 10000; +const MAX_STACKS_PER_REQUEST = 1000; + +const maybeAdd = (to: any[], value: any) => { + if (to.includes(value)) { + return; + } + + to.push(value); +}; + +function getProfilingStats({ + apmEventClient, + filter, + valueTypeField, +}: { + apmEventClient: APMEventClient; + filter: ESFilter[]; + valueTypeField: string; +}) { + return withApmSpan('get_profiling_stats', async () => { + const response = await apmEventClient.search({ + apm: { + events: [ProcessorEvent.profile], + }, + body: { + size: 0, + query: { + bool: { + filter, + }, + }, + aggs: { + stacks: { + terms: { + field: PROFILE_TOP_ID, + size: MAX_STACK_IDS, + order: { + value: 'desc', + }, + }, + aggs: { + value: { + sum: { + field: valueTypeField, + }, + }, + }, + }, + }, + }, + }); + + const stacks = + response.aggregations?.stacks.buckets.map((stack) => { + return { + id: stack.key as string, + value: stack.value.value!, + }; + }) ?? []; + + return stacks; + }); +} + +function getProfilesWithStacks({ + apmEventClient, + filter, +}: { + apmEventClient: APMEventClient; + filter: ESFilter[]; +}) { + return withApmSpan('get_profiles_with_stacks', async () => { + const cardinalityResponse = await withApmSpan('get_top_cardinality', () => + apmEventClient.search({ + apm: { + events: [ProcessorEvent.profile], + }, + body: { + size: 0, + query: { + bool: { filter }, + }, + aggs: { + top: { + cardinality: { + field: PROFILE_TOP_ID, + }, + }, + }, + }, + }) + ); + + const cardinality = cardinalityResponse.aggregations?.top.value ?? 0; + + const numStacksToFetch = Math.min( + Math.ceil(cardinality * 1.1), + MAX_STACK_IDS + ); + + const partitions = Math.ceil(numStacksToFetch / MAX_STACKS_PER_REQUEST); + + if (partitions === 0) { + return []; + } + + const allResponses = await withApmSpan('get_all_stacks', async () => { + return Promise.all( + [...new Array(partitions)].map(async (_, num) => { + const response = await withApmSpan('get_partition', () => + apmEventClient.search({ + apm: { + events: [ProcessorEvent.profile], + }, + body: { + query: { + bool: { + filter, + }, + }, + aggs: { + top: { + terms: { + field: PROFILE_TOP_ID, + size: Math.max(MAX_STACKS_PER_REQUEST), + include: { + num_partitions: partitions, + partition: num, + }, + }, + aggs: { + latest: { + top_hits: { + _source: [PROFILE_TOP_ID, PROFILE_STACK], + }, + }, + }, + }, + }, + }, + }) + ); + + return ( + response.aggregations?.top.buckets.flatMap((bucket) => { + return bucket.latest.hits.hits[0]._source; + }) ?? [] + ); + }) + ); + }); + + return allResponses.flat(); + }); +} + +export async function getServiceProfilingStatistics({ + serviceName, + setup, + environment, + valueType, + logger, +}: { + serviceName: string; + setup: Setup & SetupTimeRange; + environment?: string; + valueType: ProfilingValueType; + logger: Logger; +}) { + return withApmSpan('get_service_profiling_statistics', async () => { + const { apmEventClient, start, end } = setup; + + const valueTypeField = getValueTypeConfig(valueType).field; + + const filter: ESFilter[] = [ + ...rangeQuery(start, end), + { term: { [SERVICE_NAME]: serviceName } }, + ...environmentQuery(environment), + { exists: { field: valueTypeField } }, + ...setup.esFilter, + ]; + + const [profileStats, profileStacks] = await Promise.all([ + getProfilingStats({ apmEventClient, filter, valueTypeField }), + getProfilesWithStacks({ apmEventClient, filter }), + ]); + + const nodes: Record = {}; + const rootNodes: string[] = []; + + function getNode(frame: ProfileStackFrame) { + const { id, filename, function: functionName, line } = frame; + const location = [functionName, line].filter(Boolean).join(':'); + const fqn = [filename, location].filter(Boolean).join('/'); + const label = last(location.split('/'))!; + let node = nodes[id]; + if (!node) { + node = { id, label, fqn, value: 0, children: [] }; + nodes[id] = node; + } + return node; + } + + const stackStatsById = keyBy(profileStats, 'id'); + + const missingStacks: string[] = []; + + profileStacks.forEach((profile) => { + const stats = maybe(stackStatsById[profile.profile.top.id]); + + if (!stats) { + missingStacks.push(profile.profile.top.id); + return; + } + + const frames = profile.profile.stack.concat().reverse(); + + frames.forEach((frame, index) => { + const node = getNode(frame); + + if (index === frames.length - 1 && stats) { + node.value += stats.value; + } + + if (index === 0) { + // root node + maybeAdd(rootNodes, node.id); + } else { + const parent = nodes[frames[index - 1].id]; + maybeAdd(parent.children, node.id); + } + }); + }); + + if (missingStacks.length > 0) { + logger.warn( + `Could not find stats for all stacks: ${util.inspect({ + numProfileStats: profileStats.length, + numStacks: profileStacks.length, + missing: missingStacks, + })}` + ); + } + + return { + nodes, + rootNodes, + }; + }); +} diff --git a/x-pack/plugins/apm/server/lib/services/profiling/get_service_profiling_timeline.ts b/x-pack/plugins/apm/server/lib/services/profiling/get_service_profiling_timeline.ts new file mode 100644 index 00000000000000..dc29d6a43d82d5 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/profiling/get_service_profiling_timeline.ts @@ -0,0 +1,121 @@ +/* + * 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 { mapKeys, mapValues } from 'lodash'; +import { rangeQuery, environmentQuery } from '../../../../common/utils/queries'; +import { ProcessorEvent } from '../../../../common/processor_event'; +import { + PROFILE_ID, + SERVICE_NAME, +} from '../../../../common/elasticsearch_fieldnames'; +import { + getValueTypeConfig, + ProfilingValueType, +} from '../../../../common/profiling'; +import { Setup, SetupTimeRange } from '../../helpers/setup_request'; +import { getBucketSize } from '../../helpers/get_bucket_size'; +import { withApmSpan } from '../../../utils/with_apm_span'; + +const configMap = mapValues( + mapKeys(ProfilingValueType, (val, key) => val), + (value) => getValueTypeConfig(value) +) as Record>; + +const allFields = Object.values(configMap).map((config) => config.field); + +export async function getServiceProfilingTimeline({ + serviceName, + environment, + setup, +}: { + serviceName: string; + setup: Setup & SetupTimeRange; + environment?: string; +}) { + return withApmSpan('get_service_profiling_timeline', async () => { + const { apmEventClient, start, end, esFilter } = setup; + + const response = await apmEventClient.search({ + apm: { + events: [ProcessorEvent.profile], + }, + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [SERVICE_NAME]: serviceName } }, + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...esFilter, + ], + }, + }, + aggs: { + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: getBucketSize({ start, end }).intervalString, + min_doc_count: 0, + extended_bounds: { + min: start, + max: end, + }, + }, + aggs: { + value_type: { + filters: { + filters: { + unknown: { + bool: { + must_not: allFields.map((field) => ({ + exists: { field }, + })), + }, + }, + ...mapValues(configMap, ({ field }) => ({ + exists: { field }, + })), + }, + }, + aggs: { + num_profiles: { + cardinality: { + field: PROFILE_ID, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + const { aggregations } = response; + + if (!aggregations) { + return []; + } + + return aggregations.timeseries.buckets.map((bucket) => { + return { + x: bucket.key, + valueTypes: { + unknown: bucket.value_type.buckets.unknown.num_profiles.value, + // TODO: use enum as object key. not possible right now + // because of https://github.com/microsoft/TypeScript/issues/37888 + ...mapValues(configMap, (_, key) => { + return ( + bucket.value_type.buckets[key as ProfilingValueType]?.num_profiles + .value ?? 0 + ); + }), + }, + }; + }); + }); +} diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index c96e02f6c18215..2bd7e25e848c84 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -31,6 +31,8 @@ import { serviceMetadataDetailsRoute, serviceMetadataIconsRoute, serviceInstancesRoute, + serviceProfilingStatisticsRoute, + serviceProfilingTimelineRoute, } from './services'; import { agentConfigurationRoute, @@ -134,6 +136,8 @@ const createApmApi = () => { .add(serviceMetadataIconsRoute) .add(serviceInstancesRoute) .add(serviceErrorGroupsComparisonStatisticsRoute) + .add(serviceProfilingTimelineRoute) + .add(serviceProfilingStatisticsRoute) // Agent configuration .add(getSingleAgentConfigurationRoute) diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index 2ce41f3d1e1a09..86f7853647894a 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -34,6 +34,9 @@ import { uiFiltersRt, } from './default_api_types'; import { withApmSpan } from '../utils/with_apm_span'; +import { getServiceProfilingStatistics } from '../lib/services/profiling/get_service_profiling_statistics'; +import { getServiceProfilingTimeline } from '../lib/services/profiling/get_service_profiling_timeline'; +import { ProfilingValueType } from '../../common/profiling'; import { latencyAggregationTypeRt, LatencyAggregationType, @@ -179,12 +182,7 @@ export const serviceAnnotationsRoute = createRoute({ path: t.type({ serviceName: t.string, }), - query: t.intersection([ - rangeRt, - t.partial({ - environment: t.string, - }), - ]), + query: t.intersection([rangeRt, environmentRt]), }), options: { tags: ['access:apm'] }, handler: async ({ context, request }) => { @@ -488,3 +486,82 @@ export const serviceDependenciesRoute = createRoute({ }); }, }); + +export const serviceProfilingTimelineRoute = createRoute({ + endpoint: 'GET /api/apm/services/{serviceName}/profiling/timeline', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.intersection([ + rangeRt, + uiFiltersRt, + t.partial({ + environment: t.string, + }), + ]), + }), + options: { + tags: ['access:apm'], + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + + const { + path: { serviceName }, + query: { environment }, + } = context.params; + + return getServiceProfilingTimeline({ + setup, + serviceName, + environment, + }); + }, +}); + +export const serviceProfilingStatisticsRoute = createRoute({ + endpoint: 'GET /api/apm/services/{serviceName}/profiling/statistics', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.intersection([ + rangeRt, + uiFiltersRt, + t.partial({ + environment: t.string, + }), + t.type({ + valueType: t.union([ + t.literal(ProfilingValueType.wallTime), + t.literal(ProfilingValueType.cpuTime), + t.literal(ProfilingValueType.samples), + t.literal(ProfilingValueType.allocObjects), + t.literal(ProfilingValueType.allocSpace), + t.literal(ProfilingValueType.inuseObjects), + t.literal(ProfilingValueType.inuseSpace), + ]), + }), + ]), + }), + options: { + tags: ['access:apm'], + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + + const { + path: { serviceName }, + query: { environment, valueType }, + } = context.params; + + return getServiceProfilingStatistics({ + serviceName, + environment, + valueType, + setup, + logger: context.logger, + }); + }, +}); diff --git a/x-pack/plugins/apm/typings/es_schemas/ui/profile.ts b/x-pack/plugins/apm/typings/es_schemas/ui/profile.ts new file mode 100644 index 00000000000000..e8fbe8805fd0f7 --- /dev/null +++ b/x-pack/plugins/apm/typings/es_schemas/ui/profile.ts @@ -0,0 +1,40 @@ +/* + * 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 { Observer } from '@elastic/eui/src/components/observer/observer'; +import { Agent } from './fields/agent'; + +export interface ProfileStackFrame { + filename?: string; + line?: string; + function: string; + id: string; +} + +export interface Profile { + agent: Agent; + '@timestamp': string; + labels?: { + [key: string]: string | number | boolean; + }; + observer?: Observer; + profile: { + top: ProfileStackFrame; + duration: number; + stack: ProfileStackFrame[]; + id: string; + wall?: { + us: number; + }; + cpu?: { + ns: number; + }; + samples: { + count: number; + }; + }; +} diff --git a/x-pack/plugins/data_enhanced/public/plugin.ts b/x-pack/plugins/data_enhanced/public/plugin.ts index 29f3494433befe..056135b34cf9f3 100644 --- a/x-pack/plugins/data_enhanced/public/plugin.ts +++ b/x-pack/plugins/data_enhanced/public/plugin.ts @@ -92,6 +92,7 @@ export class DataEnhancedPlugin createConnectedSearchSessionIndicator({ sessionService: plugins.data.search.session, application: core.application, + basePath: core.http.basePath, timeFilter: plugins.data.query.timefilter.timefilter, storage: this.storage, disableSaveAfterSessionCompletesTimeout: moment diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx index 0aef27310e0906..c96d821641dd61 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx @@ -27,6 +27,7 @@ import { createSearchUsageCollectorMock } from '../../../../../../../src/plugins const coreStart = coreMock.createStart(); const application = coreStart.application; +const basePath = coreStart.http.basePath; const dataStart = dataPluginMock.createStartContract(); const sessionService = dataStart.search.session as jest.Mocked; let storage: Storage; @@ -63,6 +64,7 @@ test("shouldn't show indicator in case no active search session", async () => { storage, disableSaveAfterSessionCompletesTimeout, usageCollector, + basePath, }); const { getByTestId, container } = render( @@ -91,6 +93,7 @@ test("shouldn't show indicator in case app hasn't opt-in", async () => { storage, disableSaveAfterSessionCompletesTimeout, usageCollector, + basePath, }); const { getByTestId, container } = render( @@ -121,6 +124,7 @@ test('should show indicator in case there is an active search session', async () storage, disableSaveAfterSessionCompletesTimeout, usageCollector, + basePath, }); const { getByTestId } = render( @@ -146,6 +150,7 @@ test('should be disabled in case uiConfig says so ', async () => { storage, disableSaveAfterSessionCompletesTimeout, usageCollector, + basePath, }); render( @@ -169,6 +174,7 @@ test('should be disabled in case not enough permissions', async () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + basePath, }); render( @@ -195,6 +201,7 @@ test('should be disabled during auto-refresh', async () => { storage, disableSaveAfterSessionCompletesTimeout, usageCollector, + basePath, }); render( @@ -233,6 +240,7 @@ describe('Completed inactivity', () => { storage, disableSaveAfterSessionCompletesTimeout, usageCollector, + basePath, }); render( @@ -294,6 +302,7 @@ describe('tour steps', () => { storage, disableSaveAfterSessionCompletesTimeout, usageCollector, + basePath, }); const rendered = render( @@ -335,6 +344,7 @@ describe('tour steps', () => { storage, disableSaveAfterSessionCompletesTimeout, usageCollector, + basePath, }); const rendered = render( @@ -370,6 +380,7 @@ describe('tour steps', () => { storage, disableSaveAfterSessionCompletesTimeout, usageCollector, + basePath, }); const rendered = render( @@ -397,6 +408,7 @@ describe('tour steps', () => { storage, disableSaveAfterSessionCompletesTimeout, usageCollector, + basePath, }); const rendered = render( diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx index 7c70a270bd30a2..7e2c9c063daa4b 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx @@ -18,7 +18,7 @@ import { SearchUsageCollector, } from '../../../../../../../src/plugins/data/public'; import { RedirectAppLinks } from '../../../../../../../src/plugins/kibana_react/public'; -import { ApplicationStart } from '../../../../../../../src/core/public'; +import { ApplicationStart, IBasePath } from '../../../../../../../src/core/public'; import { IStorageWrapper } from '../../../../../../../src/plugins/kibana_utils/public'; import { useSearchSessionTour } from './search_session_tour'; @@ -26,6 +26,7 @@ export interface SearchSessionIndicatorDeps { sessionService: ISessionService; timeFilter: TimefilterContract; application: ApplicationStart; + basePath: IBasePath; storage: IStorageWrapper; /** * Controls for how long we allow to save a session, @@ -42,7 +43,9 @@ export const createConnectedSearchSessionIndicator = ({ storage, disableSaveAfterSessionCompletesTimeout, usageCollector, + basePath, }: SearchSessionIndicatorDeps): React.FC => { + const searchSessionsManagementUrl = basePath.prepend('/app/management/kibana/search_sessions'); const isAutoRefreshEnabled = () => !timeFilter.getRefreshInterval().pause; const isAutoRefreshEnabled$ = timeFilter .getRefreshIntervalUpdate$() @@ -185,6 +188,7 @@ export const createConnectedSearchSessionIndicator = ({ onCancel={onCancel} onOpened={onOpened} onViewSearchSessions={onViewSearchSessions} + viewSearchSessionsLink={searchSessionsManagementUrl} /> ); diff --git a/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx b/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx index 24ffc1359acae4..c27a42d8d3d601 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx @@ -163,7 +163,7 @@ const searchSessionIndicatorViewStateToProps: { [SearchSessionState.Completed]: { button: { color: 'subdued', - iconType: 'clock', + iconType: 'check', 'aria-label': i18n.translate('xpack.data.searchSessionIndicator.resultsLoadedIconAriaLabel', { defaultMessage: 'Search session complete', }), diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/types.ts index cda3ab213fb878..e763264a041de0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/types.ts @@ -6,5 +6,5 @@ */ export * from '../../../common/types/app_search'; -export { Role, RoleTypes, AbilityTypes } from './utils/role'; +export { Role, RoleTypes, AbilityTypes, ASRoleMapping } from './utils/role'; export { Engine } from './components/engine/types'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/index.ts index 3c75d34e6cdc9e..6e0a2f8e2adf2c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/index.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { RoleMapping } from '../../../shared/types'; +import { Engine } from '../../components/engine/types'; import { Account } from '../../types'; export type RoleTypes = 'owner' | 'admin' | 'dev' | 'editor' | 'analyst'; @@ -103,3 +105,11 @@ export const getRoleAbilities = (role: Account['role']): Role => { return Object.assign(myRole, topLevelProps, abilities); }; + +export interface ASRoleMapping extends RoleMapping { + accessAllEngines: boolean; + engines: Engine[]; + toolTip?: { + content: string; + }; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/__mocks__/roles.ts b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/__mocks__/roles.ts new file mode 100644 index 00000000000000..6e9c867b15679f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/__mocks__/roles.ts @@ -0,0 +1,68 @@ +/* + * 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 asRoleMapping = { + id: null, + attributeName: 'role', + attributeValue: ['superuser'], + authProvider: ['*'], + roleType: 'owner', + rules: { + role: 'superuser', + }, + accessAllEngines: true, + engines: [], + toolTip: { + content: 'Elasticsearch superusers will always be able to log in as the owner', + }, +}; + +export const wsRoleMapping = { + id: '602d4ba85foobarbaz123', + attributeName: 'username', + attributeValue: 'user', + authProvider: ['*', 'other_auth'], + roleType: 'admin', + rules: { + username: 'user', + }, + allGroups: true, + groups: [ + { + id: '602c3b475foobarbaz123', + name: 'Default', + createdAt: '2021-02-16T21:38:15Z', + updatedAt: '2021-02-16T21:40:32Z', + contentSources: [ + { + id: '602c3bcf5foobarbaz123', + name: 'National Parks', + serviceType: 'custom', + }, + ], + users: [ + { + id: '602c3b485foobarbaz123', + name: 'you_know_for_search', + email: 'foo@example.com', + initials: 'E', + pictureUrl: null, + color: '#ffcc13', + }, + { + id: '602c3bf85foobarbaz123', + name: 'elastic', + email: null, + initials: 'E', + pictureUrl: null, + color: '#7968ff', + }, + ], + usersCount: 2, + }, + ], +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/add_role_mapping_button.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/add_role_mapping_button.test.tsx new file mode 100644 index 00000000000000..a02f6c43225c08 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/add_role_mapping_button.test.tsx @@ -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 React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiButtonTo } from '../react_router_helpers'; + +import { AddRoleMappingButton } from './add_role_mapping_button'; + +describe('AddRoleMappingButton', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiButtonTo)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/add_role_mapping_button.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/add_role_mapping_button.tsx new file mode 100644 index 00000000000000..0ae9f16ea2f9be --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/add_role_mapping_button.tsx @@ -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 React from 'react'; + +import { EuiButtonTo } from '../react_router_helpers'; + +import { ADD_ROLE_MAPPING_BUTTON } from './constants'; + +interface Props { + path: string; +} + +export const AddRoleMappingButton: React.FC = ({ path }) => ( + + {ADD_ROLE_MAPPING_BUTTON} + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/attribute_selector.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/attribute_selector.test.tsx new file mode 100644 index 00000000000000..bc31732527b0ee --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/attribute_selector.test.tsx @@ -0,0 +1,167 @@ +/* + * 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 { shallow, ShallowWrapper } from 'enzyme'; + +import { EuiComboBox, EuiFieldText } from '@elastic/eui'; + +import { AttributeSelector, AttributeName } from './attribute_selector'; +import { ANY_AUTH_PROVIDER, ANY_AUTH_PROVIDER_OPTION_LABEL } from './constants'; + +const handleAttributeSelectorChange = jest.fn(); +const handleAttributeValueChange = jest.fn(); +const handleAuthProviderChange = jest.fn(); + +const baseProps = { + attributeName: 'username' as AttributeName, + attributeValue: 'Something', + attributes: ['a', 'b', 'c'], + availableAuthProviders: ['ees_saml', 'kbn_saml'], + selectedAuthProviders: ['ees_saml'], + elasticsearchRoles: ['whatever'], + multipleAuthProvidersConfig: true, + disabled: false, + handleAttributeSelectorChange, + handleAttributeValueChange, + handleAuthProviderChange, +}; + +describe('AttributeSelector', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find('[data-test-subj="AttributeSelector"]').exists()).toBe(true); + }); + + it('renders disabled panel with className', () => { + const wrapper = shallow(); + + expect(wrapper.find('[data-test-subj="AttributeSelector"]').prop('className')).toEqual( + 'euiPanel--disabled' + ); + }); + + describe('Auth Providers', () => { + const findAuthProvidersSelect = (wrapper: ShallowWrapper) => + wrapper.find('[data-test-subj="AuthProviderSelect"]'); + + it('will not render if "availableAuthProviders" prop has not been provided', () => { + const wrapper = shallow( + + ); + + expect(findAuthProvidersSelect(wrapper)).toHaveLength(0); + }); + + it('handles fallback props', () => { + const wrapper = shallow( + + ); + + const select: ShallowWrapper = findAuthProvidersSelect(wrapper); + + expect(select.prop('selectedOptions')).toEqual([ + { + label: ANY_AUTH_PROVIDER_OPTION_LABEL, + value: ANY_AUTH_PROVIDER, + }, + ]); + }); + + it('renders a list of auth providers from the "availableAuthProviders" prop including an "Any" option', () => { + const wrapper = shallow( + + ); + const select = findAuthProvidersSelect(wrapper) as any; + + expect(select.props().options).toEqual([ + { + label: expect.any(String), + options: [{ label: ANY_AUTH_PROVIDER_OPTION_LABEL, value: '*' }], + }, + { + label: expect.any(String), + options: [ + { label: 'ees_saml', value: 'ees_saml' }, + { label: 'kbn_saml', value: 'kbn_saml' }, + ], + }, + ]); + }); + + it('the "selectedAuthProviders" prop should be used as the selected value', () => { + const wrapper = shallow( + + ); + const select = findAuthProvidersSelect(wrapper) as any; + + expect(select.props().selectedOptions).toEqual([{ label: 'kbn_saml', value: 'kbn_saml' }]); + }); + + it('should call the "handleAuthProviderChange" prop when a value is selected', () => { + const wrapper = shallow(); + const select = findAuthProvidersSelect(wrapper); + select.simulate('change', [{ label: 'kbn_saml', value: 'kbn_saml' }]); + + expect(handleAuthProviderChange).toHaveBeenCalledWith(['kbn_saml']); + }); + + it('should call the "handleAttributeSelectorChange" prop when a value is selected', () => { + const wrapper = shallow(); + const select = wrapper.find('[data-test-subj="ExternalAttributeSelect"]'); + const event = { target: { value: 'kbn_saml' } }; + select.simulate('change', event); + + expect(handleAttributeSelectorChange).toHaveBeenCalledWith( + 'kbn_saml', + baseProps.elasticsearchRoles[0] + ); + }); + + it('handles fallback when no "handleAuthProviderChange" provided', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(EuiComboBox).prop('onChange')!([])).toEqual(undefined); + }); + + it('should call the "handleAttributeSelectorChange" prop when field text value is changed', () => { + const wrapper = shallow(); + const input = wrapper.find(EuiFieldText); + const event = { target: { value: 'kbn_saml' } }; + input.simulate('change', event); + + expect(handleAttributeSelectorChange).toHaveBeenCalledWith( + 'kbn_saml', + baseProps.elasticsearchRoles[0] + ); + }); + + it('should call the "handleAttributeSelectorChange" prop when attribute value is selected', () => { + const wrapper = shallow(); + const select = wrapper.find('[data-test-subj="ElasticsearchRoleSelect"]'); + const event = { target: { value: 'kbn_saml' } }; + select.simulate('change', event); + + expect(handleAttributeSelectorChange).toHaveBeenCalledWith( + 'kbn_saml', + baseProps.elasticsearchRoles[0] + ); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/attribute_selector.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/attribute_selector.tsx new file mode 100644 index 00000000000000..60d660a2f68627 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/attribute_selector.tsx @@ -0,0 +1,189 @@ +/* + * 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 { + EuiComboBox, + EuiComboBoxOptionOption, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiPanel, + EuiSelect, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; + +import { + ANY_AUTH_PROVIDER, + ANY_AUTH_PROVIDER_OPTION_LABEL, + AUTH_ANY_PROVIDER_LABEL, + AUTH_INDIVIDUAL_PROVIDER_LABEL, + ATTRIBUTE_SELECTOR_TITLE, + AUTH_PROVIDER_LABEL, + EXTERNAL_ATTRIBUTE_LABEL, + ATTRIBUTE_VALUE_LABEL, +} from './constants'; + +export type AttributeName = keyof AttributeExamples | 'role'; + +interface Props { + attributeName: AttributeName; + attributeValue?: string; + attributes: string[]; + selectedAuthProviders?: string[]; + availableAuthProviders?: string[]; + elasticsearchRoles: string[]; + disabled: boolean; + multipleAuthProvidersConfig: boolean; + handleAttributeSelectorChange(value: string, elasticsearchRole: string): void; + handleAttributeValueChange(value: string): void; + handleAuthProviderChange?(value: string[]): void; +} + +interface AttributeExamples { + username: string; + email: string; + metadata: string; +} + +interface ParentOption extends EuiComboBoxOptionOption { + label: string; + options: ChildOption[]; +} + +interface ChildOption extends EuiComboBoxOptionOption { + value: string; + label: string; +} + +const attributeValueExamples: AttributeExamples = { + username: 'elastic,*_system', + email: 'user@example.com,*@example.org', + metadata: '{"_reserved": true}', +}; + +const getAuthProviderOptions = (availableAuthProviders: string[]) => { + return [ + { + label: AUTH_ANY_PROVIDER_LABEL, + options: [{ value: ANY_AUTH_PROVIDER, label: ANY_AUTH_PROVIDER_OPTION_LABEL }], + }, + { + label: AUTH_INDIVIDUAL_PROVIDER_LABEL, + options: availableAuthProviders.map((authProvider) => ({ + value: authProvider, + label: authProvider, + })), + }, + ]; +}; + +const getSelectedOptions = (selectedAuthProviders: string[], availableAuthProviders: string[]) => { + const groupedOptions: ParentOption[] = getAuthProviderOptions(availableAuthProviders); + const childOptions: ChildOption[] = []; + const options = groupedOptions.reduce((acc, n) => [...acc, ...n.options], childOptions); + return options.filter((o) => o.value && selectedAuthProviders.includes(o.value)); +}; + +export const AttributeSelector: React.FC = ({ + attributeName, + attributeValue = '', + attributes, + availableAuthProviders, + selectedAuthProviders = [ANY_AUTH_PROVIDER], + elasticsearchRoles, + disabled, + multipleAuthProvidersConfig, + handleAttributeSelectorChange, + handleAttributeValueChange, + handleAuthProviderChange = () => null, +}) => { + return ( + + +

{ATTRIBUTE_SELECTOR_TITLE}

+
+ + {availableAuthProviders && multipleAuthProvidersConfig && ( + + + + { + handleAuthProviderChange(options.map((o) => (o as ChildOption).value)); + }} + fullWidth + isDisabled={disabled} + /> + + + + + )} + + + + ({ value: attribute, text: attribute }))} + onChange={(e) => { + handleAttributeSelectorChange(e.target.value, elasticsearchRoles[0]); + }} + fullWidth + disabled={disabled} + /> + + + + + {attributeName === 'role' ? ( + ({ + value: elasticsearchRole, + text: elasticsearchRole, + }))} + onChange={(e) => { + handleAttributeValueChange(e.target.value); + }} + fullWidth + disabled={disabled} + /> + ) : ( + { + handleAttributeValueChange(e.target.value); + }} + fullWidth + disabled={disabled} + /> + )} + + + +
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts new file mode 100644 index 00000000000000..1fbbc172dcf699 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const ANY_AUTH_PROVIDER = '*'; + +export const ANY_AUTH_PROVIDER_OPTION_LABEL = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.anyDropDownOptionLabel', + { + defaultMessage: 'Any', + } +); + +export const ADD_ROLE_MAPPING_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.addRoleMappingButtonLabel', + { + defaultMessage: 'Add mapping', + } +); + +export const AUTH_ANY_PROVIDER_LABEL = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.anyAuthProviderLabel', + { + defaultMessage: 'Any current or future Auth Provider', + } +); + +export const AUTH_INDIVIDUAL_PROVIDER_LABEL = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.individualAuthProviderLabel', + { + defaultMessage: 'Select individual auth providers', + } +); + +export const ATTRIBUTE_SELECTOR_TITLE = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.attributeSelectorTitle', + { + defaultMessage: 'Attribute mapping', + } +); + +export const ROLE_LABEL = i18n.translate('xpack.enterpriseSearch.roleMapping.roleLabel', { + defaultMessage: 'Role', +}); + +export const ALL_LABEL = i18n.translate('xpack.enterpriseSearch.roleMapping.allLabel', { + defaultMessage: 'All', +}); + +export const AUTH_PROVIDER_LABEL = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.authProviderLabel', + { + defaultMessage: 'Auth provider', + } +); + +export const EXTERNAL_ATTRIBUTE_LABEL = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.externalAttributeLabel', + { + defaultMessage: 'External attribute', + } +); + +export const ATTRIBUTE_VALUE_LABEL = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.attributeValueLabel', + { + defaultMessage: 'Attribute value', + } +); + +export const DELETE_ROLE_MAPPING_TITLE = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.deleteRoleMappingTitle', + { + defaultMessage: 'Remove this role mapping', + } +); + +export const DELETE_ROLE_MAPPING_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.deleteRoleMappingDescription', + { + defaultMessage: 'Please note that deleting a mapping is permanent and cannot be undone', + } +); + +export const DELETE_ROLE_MAPPING_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.deleteRoleMappingButton', + { + defaultMessage: 'Delete mapping', + } +); + +export const FILTER_ROLE_MAPPINGS_PLACEHOLDER = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.filterRoleMappingsPlaceholder', + { + defaultMessage: 'Filter roles...', + } +); + +export const MANAGE_ROLE_MAPPING_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.manageRoleMappingButtonLabel', + { + defaultMessage: 'Manage', + } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/delete_mapping_callout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/delete_mapping_callout.test.tsx new file mode 100644 index 00000000000000..c7556ee20e26a3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/delete_mapping_callout.test.tsx @@ -0,0 +1,31 @@ +/* + * 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 { shallow } from 'enzyme'; + +import { EuiButton, EuiCallOut } from '@elastic/eui'; + +import { DeleteMappingCallout } from './delete_mapping_callout'; + +describe('DeleteMappingCallout', () => { + const handleDeleteMapping = jest.fn(); + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiCallOut)).toHaveLength(1); + expect(wrapper.find(EuiButton).prop('onClick')).toEqual(handleDeleteMapping); + }); + + it('handles button click', () => { + const wrapper = shallow(); + wrapper.find(EuiButton).simulate('click'); + + expect(handleDeleteMapping).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/delete_mapping_callout.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/delete_mapping_callout.tsx new file mode 100644 index 00000000000000..cb3c27038c5665 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/delete_mapping_callout.tsx @@ -0,0 +1,29 @@ +/* + * 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 { EuiButton, EuiCallOut } from '@elastic/eui'; + +import { + DELETE_ROLE_MAPPING_TITLE, + DELETE_ROLE_MAPPING_DESCRIPTION, + DELETE_ROLE_MAPPING_BUTTON, +} from './constants'; + +interface Props { + handleDeleteMapping(): void; +} + +export const DeleteMappingCallout: React.FC = ({ handleDeleteMapping }) => ( + +

{DELETE_ROLE_MAPPING_DESCRIPTION}

+ + {DELETE_ROLE_MAPPING_BUTTON} + +
+); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/index.ts new file mode 100644 index 00000000000000..e6320dbb7feef8 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/index.ts @@ -0,0 +1,12 @@ +/* + * 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 { AddRoleMappingButton } from './add_role_mapping_button'; +export { AttributeSelector } from './attribute_selector'; +export { DeleteMappingCallout } from './delete_mapping_callout'; +export { RoleMappingsTable } from './role_mappings_table'; +export { RoleSelector } from './role_selector'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx new file mode 100644 index 00000000000000..22498bbc50c21f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx @@ -0,0 +1,90 @@ +/* + * 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 { shallow } from 'enzyme'; + +import { EuiFieldSearch, EuiTableRow } from '@elastic/eui'; + +import { wsRoleMapping, asRoleMapping } from './__mocks__/roles'; + +import { ALL_LABEL, ANY_AUTH_PROVIDER_OPTION_LABEL } from './constants'; + +import { RoleMappingsTable } from './role_mappings_table'; + +describe('RoleMappingsTable', () => { + const getRoleMappingPath = jest.fn(); + const roleMappings = [ + { + ...wsRoleMapping, + accessItems: [ + { + name: 'foo', + }, + ], + }, + ]; + + const props = { + accessItemKey: 'groups' as 'groups' | 'engines', + accessHeader: 'access', + roleMappings, + addMappingButton: