diff --git a/x-pack/packages/kbn-slo-schema/src/rest_specs/slo.ts b/x-pack/packages/kbn-slo-schema/src/rest_specs/slo.ts index f8f1d60edb7ad..9d37c36c94534 100644 --- a/x-pack/packages/kbn-slo-schema/src/rest_specs/slo.ts +++ b/x-pack/packages/kbn-slo-schema/src/rest_specs/slo.ts @@ -6,24 +6,22 @@ */ import * as t from 'io-ts'; - import { budgetingMethodSchema, dateType, historicalSummarySchema, indicatorSchema, indicatorTypesArraySchema, + kqlCustomIndicatorSchema, + metricCustomIndicatorSchema, objectiveSchema, optionalSettingsSchema, + previewDataSchema, settingsSchema, sloIdSchema, summarySchema, tagsSchema, timeWindowSchema, - metricCustomIndicatorSchema, - kqlCustomIndicatorSchema, - apmTransactionErrorRateIndicatorSchema, - apmTransactionDurationIndicatorSchema, } from '../schema'; const createSLOParamsSchema = t.type({ @@ -44,6 +42,14 @@ const createSLOResponseSchema = t.type({ id: sloIdSchema, }); +const getPreviewDataParamsSchema = t.type({ + body: t.type({ + indicator: indicatorSchema, + }), +}); + +const getPreviewDataResponseSchema = t.array(previewDataSchema); + const deleteSLOParamsSchema = t.type({ path: t.type({ id: sloIdSchema, @@ -156,20 +162,22 @@ type FetchHistoricalSummaryParams = t.TypeOf; type HistoricalSummaryResponse = t.OutputOf; +type GetPreviewDataParams = t.TypeOf; +type GetPreviewDataResponse = t.TypeOf; + type BudgetingMethod = t.TypeOf; -type MetricCustomIndicatorSchema = t.TypeOf; -type KQLCustomIndicatorSchema = t.TypeOf; -type APMTransactionErrorRateIndicatorSchema = t.TypeOf< - typeof apmTransactionErrorRateIndicatorSchema ->; -type APMTransactionDurationIndicatorSchema = t.TypeOf; +type Indicator = t.OutputOf; +type MetricCustomIndicator = t.OutputOf; +type KQLCustomIndicator = t.OutputOf; export { createSLOParamsSchema, deleteSLOParamsSchema, findSLOParamsSchema, findSLOResponseSchema, + getPreviewDataParamsSchema, + getPreviewDataResponseSchema, getSLODiagnosisParamsSchema, getSLOParamsSchema, getSLOResponseSchema, @@ -188,6 +196,8 @@ export type { CreateSLOResponse, FindSLOParams, FindSLOResponse, + GetPreviewDataParams, + GetPreviewDataResponse, GetSLOResponse, FetchHistoricalSummaryParams, FetchHistoricalSummaryResponse, @@ -198,8 +208,7 @@ export type { UpdateSLOInput, UpdateSLOParams, UpdateSLOResponse, - MetricCustomIndicatorSchema, - KQLCustomIndicatorSchema, - APMTransactionDurationIndicatorSchema, - APMTransactionErrorRateIndicatorSchema, + Indicator, + MetricCustomIndicator, + KQLCustomIndicator, }; diff --git a/x-pack/packages/kbn-slo-schema/src/schema/common.ts b/x-pack/packages/kbn-slo-schema/src/schema/common.ts index f702540bf20c7..250525ce2192c 100644 --- a/x-pack/packages/kbn-slo-schema/src/schema/common.ts +++ b/x-pack/packages/kbn-slo-schema/src/schema/common.ts @@ -52,6 +52,11 @@ const historicalSummarySchema = t.intersection([ summarySchema, ]); +const previewDataSchema = t.type({ + date: dateType, + sliValue: t.number, +}); + const dateRangeSchema = t.type({ from: dateType, to: dateType }); export type { SummarySchema }; @@ -63,6 +68,7 @@ export { dateType, errorBudgetSchema, historicalSummarySchema, + previewDataSchema, statusSchema, summarySchema, }; diff --git a/x-pack/plugins/enterprise_search/public/applications/esre/components/esre_guide/esre_docs_section.tsx b/x-pack/plugins/enterprise_search/public/applications/esre/components/esre_guide/esre_docs_section.tsx new file mode 100644 index 0000000000000..b932ed76cd795 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/esre/components/esre_guide/esre_docs_section.tsx @@ -0,0 +1,123 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText, EuiTitle } from '@elastic/eui'; + +import { FormattedMessage } from '@kbn/i18n-react'; + +export const EsreDocsSection: React.FC = () => ( + + + + + +

+ +

+
+
+ + +

+ +

+
+
+
+
+ + + + + + + +

+ +

+
+
+ + +

+ +

+
+
+
+
+
+ + + + + +

+ +

+
+
+ + +

+ +

+
+
+
+
+
+ + + + + +

+ +

+
+
+ + +

+ +

+
+
+
+
+
+
+
+
+); diff --git a/x-pack/plugins/enterprise_search/public/applications/esre/components/esre_guide/esre_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/esre/components/esre_guide/esre_guide.tsx index 4286c136f2fda..b06759071301a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/esre/components/esre_guide/esre_guide.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/esre/components/esre_guide/esre_guide.tsx @@ -7,15 +7,37 @@ import React from 'react'; +import { + EuiImage, + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiHorizontalRule, + EuiFlexGrid, + useIsWithinBreakpoints, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import analyticsIllustration from '../../../../assets/images/analytics.svg'; +import scalableIllustration from '../../../../assets/images/scalable.svg'; +import simplifyIllustration from '../../../../assets/images/simplify.svg'; import { SetEsreChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { EnterpriseSearchEsrePageTemplate } from '../layout/page_template'; +import { EsreDocsSection } from './esre_docs_section'; +import { MeasurePerformanceSection } from './measure_performance_section'; +import { RankAggregationSection } from './rank_aggregation_section'; +import { SemanticSearchSection } from './semantic_search_section'; + export const EsreGuide: React.FC = () => { + const isMobile = useIsWithinBreakpoints(['xs']); + return ( { }} > -

ESRE placeholder

+ + + + + + + + + + + + + + + + + + + +

+ +

+
+
+ + + + + + + + + + + + + + + +
+
); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/esre/components/esre_guide/esre_guide_accordion.tsx b/x-pack/plugins/enterprise_search/public/applications/esre/components/esre_guide/esre_guide_accordion.tsx new file mode 100644 index 0000000000000..35af242e9e5a0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/esre/components/esre_guide/esre_guide_accordion.tsx @@ -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. + */ + +import React from 'react'; + +import { + EuiAccordion, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiTitle, + EuiText, + IconType, + EuiPanel, +} from '@elastic/eui'; + +export interface EsreGuideAccordionProps { + id: string; + icon: IconType; + title: string; + description: string; + initialIsOpen?: boolean; +} + +export const EsreGuideAccordion: React.FC = ({ + id, + icon, + title, + description, + initialIsOpen = false, + children, +}) => { + return ( + + + + + + + + + +

{title}

+
+
+ + +

{description}

+
+
+
+
+ + } + > + {children} +
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/esre/components/esre_guide/measure_performance_section.tsx b/x-pack/plugins/enterprise_search/public/applications/esre/components/esre_guide/measure_performance_section.tsx new file mode 100644 index 0000000000000..c3b839b754da8 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/esre/components/esre_guide/measure_performance_section.tsx @@ -0,0 +1,85 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSteps, EuiText, EuiTitle } from '@elastic/eui'; +import { EuiContainedStepProps } from '@elastic/eui/src/components/steps/steps'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; + +const steps: EuiContainedStepProps[] = [ + { + title: i18n.translate('xpack.enterpriseSearch.esre.measurePerformanceSection.step1.title', { + defaultMessage: 'Create a collection', + }), + children: ( + + ), + status: 'incomplete', + }, + { + title: i18n.translate('xpack.enterpriseSearch.esre.measurePerformanceSection.step2.title', { + defaultMessage: 'Integrate the analytics tracker', + }), + children: ( + + ), + status: 'incomplete', + }, + { + title: i18n.translate('xpack.enterpriseSearch.esre.measurePerformanceSection.step3.title', { + defaultMessage: 'Review your dashboard', + }), + children: ( + + ), + status: 'incomplete', + }, +]; + +export const MeasurePerformanceSection: React.FC = () => ( + + + + + +

+ +

+
+
+ + +

+ +

+
+
+
+
+ + + + + +
+); diff --git a/x-pack/plugins/enterprise_search/public/applications/esre/components/esre_guide/rank_aggregation_section.tsx b/x-pack/plugins/enterprise_search/public/applications/esre/components/esre_guide/rank_aggregation_section.tsx new file mode 100644 index 0000000000000..1e17a1228da20 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/esre/components/esre_guide/rank_aggregation_section.tsx @@ -0,0 +1,88 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiText, EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import linearCombinationIllustration from '../../../../assets/images/linear.svg'; +import rrfRankingIllustration from '../../../../assets/images/rrf.svg'; + +import { EsreGuideAccordion } from './esre_guide_accordion'; + +export const RankAggregationSection: React.FC = () => ( + + + + + +

+ +

+
+
+ + +

+ +

+
+
+
+
+ + + + + <> +

Placeholder

+ +
+
+ + + <> +

Placeholder

+ +
+
+
+
+
+); diff --git a/x-pack/plugins/enterprise_search/public/applications/esre/components/esre_guide/semantic_search_section.tsx b/x-pack/plugins/enterprise_search/public/applications/esre/components/esre_guide/semantic_search_section.tsx new file mode 100644 index 0000000000000..2aec9fe706a35 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/esre/components/esre_guide/semantic_search_section.tsx @@ -0,0 +1,106 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiText, EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import elserIllustration from '../../../../assets/images/elser.svg'; +import nlpEnrichmentIllustration from '../../../../assets/images/nlp.svg'; +import vectorSearchIllustration from '../../../../assets/images/vector.svg'; + +import { EsreGuideAccordion } from './esre_guide_accordion'; + +export const SemanticSearchSection: React.FC = () => ( + + + + + +

+ +

+
+
+ + +

+ +

+
+
+
+
+ + + + + <> +

Placeholder

+ +
+
+ + + <> +

Placeholder

+ +
+
+ + + <> +

Placeholder

+ +
+
+
+
+
+); diff --git a/x-pack/plugins/enterprise_search/public/assets/images/analytics.svg b/x-pack/plugins/enterprise_search/public/assets/images/analytics.svg new file mode 100644 index 0000000000000..11f59ea0f7807 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/assets/images/analytics.svg @@ -0,0 +1,353 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/x-pack/plugins/enterprise_search/public/assets/images/elser.svg b/x-pack/plugins/enterprise_search/public/assets/images/elser.svg new file mode 100644 index 0000000000000..fc16f98934e59 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/assets/images/elser.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/x-pack/plugins/enterprise_search/public/assets/images/linear.svg b/x-pack/plugins/enterprise_search/public/assets/images/linear.svg new file mode 100644 index 0000000000000..bcedba45d9776 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/assets/images/linear.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/x-pack/plugins/enterprise_search/public/assets/images/nlp.svg b/x-pack/plugins/enterprise_search/public/assets/images/nlp.svg new file mode 100644 index 0000000000000..90fa46f16ed8a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/assets/images/nlp.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/x-pack/plugins/enterprise_search/public/assets/images/rrf.svg b/x-pack/plugins/enterprise_search/public/assets/images/rrf.svg new file mode 100644 index 0000000000000..dbf4b4cea6e88 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/assets/images/rrf.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/x-pack/plugins/enterprise_search/public/assets/images/scalable.svg b/x-pack/plugins/enterprise_search/public/assets/images/scalable.svg new file mode 100644 index 0000000000000..343bc813f2ce2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/assets/images/scalable.svg @@ -0,0 +1,367 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/x-pack/plugins/enterprise_search/public/assets/images/simplify.svg b/x-pack/plugins/enterprise_search/public/assets/images/simplify.svg new file mode 100644 index 0000000000000..a639ce10dcb0a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/assets/images/simplify.svg @@ -0,0 +1,289 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/x-pack/plugins/enterprise_search/public/assets/images/vector.svg b/x-pack/plugins/enterprise_search/public/assets/images/vector.svg new file mode 100644 index 0000000000000..0a25945853b5d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/assets/images/vector.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/x-pack/plugins/observability/public/data/slo/indicator.ts b/x-pack/plugins/observability/public/data/slo/indicator.ts index 62ff1c0b8287f..227ddf89fc667 100644 --- a/x-pack/plugins/observability/public/data/slo/indicator.ts +++ b/x-pack/plugins/observability/public/data/slo/indicator.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { KQLCustomIndicatorSchema, SLOWithSummaryResponse } from '@kbn/slo-schema'; +import { KQLCustomIndicator, SLOWithSummaryResponse } from '@kbn/slo-schema'; export const buildApmAvailabilityIndicator = ( params: Partial = {} @@ -53,5 +53,5 @@ export const buildCustomKqlIndicator = ( timestampField: '@timestamp', ...params, }, - } as KQLCustomIndicatorSchema; + } as KQLCustomIndicator; }; diff --git a/x-pack/plugins/observability/public/hooks/slo/query_key_factory.ts b/x-pack/plugins/observability/public/hooks/slo/query_key_factory.ts index 73bdd9528dd75..f4c557105ce62 100644 --- a/x-pack/plugins/observability/public/hooks/slo/query_key_factory.ts +++ b/x-pack/plugins/observability/public/hooks/slo/query_key_factory.ts @@ -5,6 +5,8 @@ * 2.0. */ +import type { Indicator } from '@kbn/slo-schema'; + interface SloKeyFilter { name: string; page: number; @@ -31,6 +33,7 @@ export const sloKeys = { historicalSummaries: () => [...sloKeys.all, 'historicalSummary'] as const, historicalSummary: (sloIds: string[]) => [...sloKeys.historicalSummaries(), sloIds] as const, globalDiagnosis: () => [...sloKeys.all, 'globalDiagnosis'] as const, + preview: (indicator?: Indicator) => [...sloKeys.all, 'preview', indicator] as const, }; export const compositeSloKeys = { diff --git a/x-pack/plugins/observability/public/hooks/slo/use_get_preview_data.ts b/x-pack/plugins/observability/public/hooks/slo/use_get_preview_data.ts new file mode 100644 index 0000000000000..89d1fe4e5ef8c --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/slo/use_get_preview_data.ts @@ -0,0 +1,62 @@ +/* + * 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 { GetPreviewDataResponse, Indicator } from '@kbn/slo-schema'; +import { + QueryObserverResult, + RefetchOptions, + RefetchQueryFilters, + useQuery, +} from '@tanstack/react-query'; +import { useKibana } from '../../utils/kibana_react'; +import { sloKeys } from './query_key_factory'; + +export interface UseGetPreviewData { + data: GetPreviewDataResponse | undefined; + isInitialLoading: boolean; + isRefetching: boolean; + isLoading: boolean; + isSuccess: boolean; + isError: boolean; + refetch: ( + options?: (RefetchOptions & RefetchQueryFilters) | undefined + ) => Promise>; +} + +export function useGetPreviewData(indicator?: Indicator): UseGetPreviewData { + const { http } = useKibana().services; + + const { isInitialLoading, isLoading, isError, isSuccess, isRefetching, data, refetch } = useQuery( + { + queryKey: sloKeys.preview(indicator), + queryFn: async ({ signal }) => { + const response = await http.post( + '/internal/observability/slos/_preview', + { + body: JSON.stringify({ indicator }), + signal, + } + ); + + return response; + }, + retry: false, + refetchOnWindowFocus: false, + enabled: Boolean(indicator), + } + ); + + return { + data, + isLoading, + isRefetching, + isInitialLoading, + isSuccess, + isError, + refetch, + }; +} diff --git a/x-pack/plugins/observability/public/pages/slo_edit/components/common/data_preview_chart.tsx b/x-pack/plugins/observability/public/pages/slo_edit/components/common/data_preview_chart.tsx new file mode 100644 index 0000000000000..3f99a9f7638b8 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/slo_edit/components/common/data_preview_chart.tsx @@ -0,0 +1,105 @@ +/* + * 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 { AreaSeries, Axis, Chart, Position, ScaleType, Settings } from '@elastic/charts'; +import { EuiFlexItem, EuiIcon, EuiLoadingChart, EuiPanel } from '@elastic/eui'; +import numeral from '@elastic/numeral'; +import { i18n } from '@kbn/i18n'; +import { CreateSLOInput } from '@kbn/slo-schema'; +import moment from 'moment'; +import React from 'react'; +import { useFormContext } from 'react-hook-form'; +import { useKibana } from '../../../../utils/kibana_react'; +import { useDebouncedGetPreviewData } from '../../hooks/use_preview'; +export function DataPreviewChart() { + const { watch, getFieldState } = useFormContext(); + const { charts, uiSettings } = useKibana().services; + + const { data: previewData, isLoading: isPreviewLoading } = useDebouncedGetPreviewData( + watch('indicator') + ); + + const theme = charts.theme.useChartsTheme(); + const baseTheme = charts.theme.useChartsBaseTheme(); + const dateFormat = uiSettings.get('dateFormat'); + const percentFormat = uiSettings.get('format:percent:defaultPattern'); + + if (getFieldState('indicator').invalid) { + return null; + } + + return ( + + {isPreviewLoading && } + {!isPreviewLoading && !!previewData && ( + + + + } + /> + + numeral(d).format(percentFormat)} + /> + + moment(d).format(dateFormat)} + position={Position.Bottom} + timeAxisLayerCount={2} + gridLine={{ visible: true }} + style={{ + tickLine: { size: 0.0001, padding: 4, visible: true }, + tickLabel: { + alignment: { + horizontal: Position.Left, + vertical: Position.Bottom, + }, + padding: 0, + offset: { x: 0, y: 0 }, + }, + }} + /> + ({ + date: new Date(datum.date).getTime(), + value: datum.sliValue >= 0 ? datum.sliValue : null, + }))} + /> + + + )} + + ); +} diff --git a/x-pack/plugins/observability/public/pages/slo_edit/components/custom_kql/custom_kql_indicator_type_form.tsx b/x-pack/plugins/observability/public/pages/slo_edit/components/custom_kql/custom_kql_indicator_type_form.tsx index c0b8ee400efb9..4f3ecc301e08a 100644 --- a/x-pack/plugins/observability/public/pages/slo_edit/components/custom_kql/custom_kql_indicator_type_form.tsx +++ b/x-pack/plugins/observability/public/pages/slo_edit/components/custom_kql/custom_kql_indicator_type_form.tsx @@ -21,6 +21,7 @@ import { Field, useFetchIndexPatternFields, } from '../../../../hooks/slo/use_fetch_index_pattern_fields'; +import { DataPreviewChart } from '../common/data_preview_chart'; import { QueryBuilder } from '../common/query_builder'; import { IndexSelection } from '../custom_common/index_selection'; @@ -31,7 +32,6 @@ interface Option { export function CustomKqlIndicatorTypeForm() { const { control, watch, getFieldState } = useFormContext(); - const { isLoading, data: indexFields } = useFetchIndexPatternFields( watch('indicator.params.index') ); @@ -86,12 +86,7 @@ export function CustomKqlIndicatorTypeForm() { !!watch('indicator.params.index') && !!field.value && timestampFields.some((timestampField) => timestampField.name === field.value) - ? [ - { - value: field.value, - label: field.value, - }, - ] + ? [{ value: field.value, label: field.value }] : [] } singleSelection={{ asPlainText: true }} @@ -187,6 +182,8 @@ export function CustomKqlIndicatorTypeForm() { } /> + + ); } diff --git a/x-pack/plugins/observability/public/pages/slo_edit/hooks/use_preview.ts b/x-pack/plugins/observability/public/pages/slo_edit/hooks/use_preview.ts new file mode 100644 index 0000000000000..47d32b8d9ad3f --- /dev/null +++ b/x-pack/plugins/observability/public/pages/slo_edit/hooks/use_preview.ts @@ -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 { Indicator } from '@kbn/slo-schema'; +import { debounce } from 'lodash'; +import { useCallback, useEffect, useState } from 'react'; +import { useGetPreviewData } from '../../../hooks/slo/use_get_preview_data'; + +export function useDebouncedGetPreviewData(indicator: Indicator) { + const serializedIndicator = JSON.stringify(indicator); + const [indicatorState, setIndicatorState] = useState(serializedIndicator); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const store = useCallback( + debounce((value: string) => setIndicatorState(value), 800), + [] + ); + useEffect(() => { + if (indicatorState !== serializedIndicator) { + store(serializedIndicator); + } + }, [indicatorState, serializedIndicator, store]); + + return useGetPreviewData(JSON.parse(indicatorState)); +} diff --git a/x-pack/plugins/observability/public/pages/slo_edit/hooks/use_section_form_validation.ts b/x-pack/plugins/observability/public/pages/slo_edit/hooks/use_section_form_validation.ts index 1f1eeca035425..4187590b8688b 100644 --- a/x-pack/plugins/observability/public/pages/slo_edit/hooks/use_section_form_validation.ts +++ b/x-pack/plugins/observability/public/pages/slo_edit/hooks/use_section_form_validation.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { CreateSLOInput, MetricCustomIndicatorSchema } from '@kbn/slo-schema'; +import { CreateSLOInput, MetricCustomIndicator } from '@kbn/slo-schema'; import { FormState, UseFormGetFieldState, UseFormGetValues, UseFormWatch } from 'react-hook-form'; import { isObject } from 'lodash'; @@ -22,9 +22,7 @@ export function useSectionFormValidation({ getFieldState, getValues, formState, switch (watch('indicator.type')) { case 'sli.metric.custom': const isGoodParamsValid = () => { - const data = getValues( - 'indicator.params.good' - ) as MetricCustomIndicatorSchema['params']['good']; + const data = getValues('indicator.params.good') as MetricCustomIndicator['params']['good']; const isEquationValid = !getFieldState('indicator.params.good.equation').invalid; const areMetricsValid = isObject(data) && (data.metrics ?? []).every((metric) => Boolean(metric.field)); @@ -34,7 +32,7 @@ export function useSectionFormValidation({ getFieldState, getValues, formState, const isTotalParamsValid = () => { const data = getValues( 'indicator.params.total' - ) as MetricCustomIndicatorSchema['params']['total']; + ) as MetricCustomIndicator['params']['total']; const isEquationValid = !getFieldState('indicator.params.total.equation').invalid; const areMetricsValid = isObject(data) && (data.metrics ?? []).every((metric) => Boolean(metric.field)); diff --git a/x-pack/plugins/observability/public/pages/slo_edit/slo_edit.test.tsx b/x-pack/plugins/observability/public/pages/slo_edit/slo_edit.test.tsx index 520f719447db9..4f55f8121579e 100644 --- a/x-pack/plugins/observability/public/pages/slo_edit/slo_edit.test.tsx +++ b/x-pack/plugins/observability/public/pages/slo_edit/slo_edit.test.tsx @@ -66,6 +66,12 @@ const mockKibana = () => { application: { navigateToUrl: mockNavigate, }, + charts: { + theme: { + useChartsTheme: () => {}, + useChartsBaseTheme: () => {}, + }, + }, data: { dataViews: { find: jest.fn().mockReturnValue([]), diff --git a/x-pack/plugins/observability/server/errors/errors.ts b/x-pack/plugins/observability/server/errors/errors.ts index 7ac7290385dde..dbbb873925636 100644 --- a/x-pack/plugins/observability/server/errors/errors.ts +++ b/x-pack/plugins/observability/server/errors/errors.ts @@ -20,6 +20,7 @@ export class SLOIdConflict extends ObservabilityError {} export class CompositeSLONotFound extends ObservabilityError {} export class CompositeSLOIdConflict extends ObservabilityError {} +export class InvalidQueryError extends ObservabilityError {} export class InternalQueryError extends ObservabilityError {} export class NotSupportedError extends ObservabilityError {} export class IllegalArgumentError extends ObservabilityError {} diff --git a/x-pack/plugins/observability/server/routes/slo/route.ts b/x-pack/plugins/observability/server/routes/slo/route.ts index 849630d368aa5..b65216f227407 100644 --- a/x-pack/plugins/observability/server/routes/slo/route.ts +++ b/x-pack/plugins/observability/server/routes/slo/route.ts @@ -11,6 +11,7 @@ import { deleteSLOParamsSchema, fetchHistoricalSummaryParamsSchema, findSLOParamsSchema, + getPreviewDataParamsSchema, getSLODiagnosisParamsSchema, getSLOParamsSchema, manageSLOParamsSchema, @@ -41,6 +42,7 @@ import type { IndicatorTypes } from '../../domain/models'; import type { ObservabilityRequestHandlerContext } from '../../types'; import { ManageSLO } from '../../services/slo/manage_slo'; import { getGlobalDiagnosis, getSloDiagnosis } from '../../services/slo/get_diagnosis'; +import { GetPreviewData } from '../../services/slo/get_preview_data'; const transformGenerators: Record = { 'sli.apm.transactionDuration': new ApmTransactionDurationTransformGenerator(), @@ -303,6 +305,25 @@ const getSloDiagnosisRoute = createObservabilityServerRoute({ }, }); +const getPreviewData = createObservabilityServerRoute({ + endpoint: 'POST /internal/observability/slos/_preview', + options: { + tags: ['access:slo_read'], + }, + params: getPreviewDataParamsSchema, + handler: async ({ context, params }) => { + const hasCorrectLicense = await isLicenseAtLeastPlatinum(context); + + if (!hasCorrectLicense) { + throw badRequest('Platinum license or higher is needed to make use of this feature.'); + } + + const esClient = (await context.core).elasticsearch.client.asCurrentUser; + const service = new GetPreviewData(esClient); + return await service.execute(params.body); + }, +}); + export const sloRouteRepository = { ...createSLORoute, ...deleteSLORoute, @@ -314,4 +335,5 @@ export const sloRouteRepository = { ...updateSLORoute, ...getDiagnosisRoute, ...getSloDiagnosisRoute, + ...getPreviewData, }; diff --git a/x-pack/plugins/observability/server/services/slo/get_preview_data.ts b/x-pack/plugins/observability/server/services/slo/get_preview_data.ts new file mode 100644 index 0000000000000..e71d7e6398577 --- /dev/null +++ b/x-pack/plugins/observability/server/services/slo/get_preview_data.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. + */ + +import { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query'; +import { GetPreviewDataParams, GetPreviewDataResponse } from '@kbn/slo-schema'; +import { computeSLI } from '../../domain/services'; +import { InvalidQueryError } from '../../errors'; + +export class GetPreviewData { + constructor(private esClient: ElasticsearchClient) {} + + public async execute(params: GetPreviewDataParams): Promise { + switch (params.indicator.type) { + case 'sli.kql.custom': + const filterQuery = getElastichsearchQueryOrThrow(params.indicator.params.filter); + const goodQuery = getElastichsearchQueryOrThrow(params.indicator.params.good); + const totalQuery = getElastichsearchQueryOrThrow(params.indicator.params.total); + const timestampField = params.indicator.params.timestampField; + + try { + const result = await this.esClient.search({ + index: params.indicator.params.index, + query: { + bool: { + filter: [{ range: { [timestampField]: { gte: 'now-60m' } } }, filterQuery], + }, + }, + aggs: { + perMinute: { + date_histogram: { + field: timestampField, + fixed_interval: '1m', + }, + aggs: { + good: { filter: goodQuery }, + total: { filter: totalQuery }, + }, + }, + }, + }); + + // @ts-ignore buckets is not improperly typed + return result.aggregations?.perMinute.buckets.map((bucket) => ({ + date: bucket.key_as_string, + sliValue: computeSLI(bucket.good.doc_count, bucket.total.doc_count), + })); + } catch (err) { + throw new InvalidQueryError(`Invalid ES query`); + } + + default: + return []; + } + } +} + +function getElastichsearchQueryOrThrow(kuery: string) { + try { + return toElasticsearchQuery(fromKueryExpression(kuery)); + } catch (err) { + throw new InvalidQueryError(`Invalid kuery: ${kuery}`); + } +} diff --git a/x-pack/plugins/profiling/common/callee.test.ts b/x-pack/plugins/profiling/common/callee.test.ts index 4e3ef4b286e31..3cf6fb7484372 100644 --- a/x-pack/plugins/profiling/common/callee.test.ts +++ b/x-pack/plugins/profiling/common/callee.test.ts @@ -12,7 +12,7 @@ import { events, stackTraces, stackFrames, executables } from './__fixtures__/st const totalSamples = sum([...events.values()]); const totalFrames = sum([...stackTraces.values()].map((trace) => trace.FrameIDs.length)); -const tree = createCalleeTree(events, stackTraces, stackFrames, executables, totalFrames); +const tree = createCalleeTree(events, stackTraces, stackFrames, executables, totalFrames, 1.0); describe('Callee operations', () => { test('inclusive count of root equals total sampled stacktraces', () => { diff --git a/x-pack/plugins/profiling/common/callee.ts b/x-pack/plugins/profiling/common/callee.ts index 5f373dc25d25b..04206843463fc 100644 --- a/x-pack/plugins/profiling/common/callee.ts +++ b/x-pack/plugins/profiling/common/callee.ts @@ -43,7 +43,8 @@ export function createCalleeTree( stackTraces: Map, stackFrames: Map, executables: Map, - totalFrames: number + totalFrames: number, + samplingRate: number ): CalleeTree { const tree: CalleeTree = { Size: 1, @@ -62,6 +63,9 @@ export function createCalleeTree( CountExclusive: new Array(totalFrames), }; + // The inverse of the sampling rate is the number with which to multiply the number of + // samples to get an estimate of the actual number of samples the backend received. + const scalingFactor = 1.0 / samplingRate; tree.Edges[0] = new Map(); tree.FileID[0] = ''; @@ -97,10 +101,12 @@ export function createCalleeTree( // e.g. when stopping the host agent or on network errors. const stackTrace = stackTraces.get(stackTraceID) ?? emptyStackTrace; const lenStackTrace = stackTrace.FrameIDs.length; - const samples = events.get(stackTraceID) ?? 0; + const samples = (events.get(stackTraceID) ?? 0) * scalingFactor; let currentNode = 0; + // Increment the count by the number of samples observed, multiplied with the inverse of the + // samplingrate (this essentially means scaling up the total samples). It would incur tree.CountInclusive[currentNode] += samples; tree.CountExclusive[currentNode] = 0; diff --git a/x-pack/plugins/profiling/common/columnar_view_model.test.ts b/x-pack/plugins/profiling/common/columnar_view_model.test.ts index c41e2b0aef4ea..a40f2225b6c19 100644 --- a/x-pack/plugins/profiling/common/columnar_view_model.test.ts +++ b/x-pack/plugins/profiling/common/columnar_view_model.test.ts @@ -14,8 +14,8 @@ import { events, stackTraces, stackFrames, executables } from './__fixtures__/st const totalFrames = sum([...stackTraces.values()].map((trace) => trace.FrameIDs.length)); -const tree = createCalleeTree(events, stackTraces, stackFrames, executables, totalFrames); -const graph = createFlameGraph(createBaseFlameGraph(tree, 60)); +const tree = createCalleeTree(events, stackTraces, stackFrames, executables, totalFrames, 1.0); +const graph = createFlameGraph(createBaseFlameGraph(tree, 1.0, 60)); describe('Columnar view model operations', () => { test('color values are generated by default', () => { diff --git a/x-pack/plugins/profiling/common/flamegraph.test.ts b/x-pack/plugins/profiling/common/flamegraph.test.ts index 5f13d8f9db89b..a5e22e783ce65 100644 --- a/x-pack/plugins/profiling/common/flamegraph.test.ts +++ b/x-pack/plugins/profiling/common/flamegraph.test.ts @@ -12,8 +12,8 @@ import { createBaseFlameGraph, createFlameGraph } from './flamegraph'; import { events, stackTraces, stackFrames, executables } from './__fixtures__/stacktraces'; const totalFrames = sum([...stackTraces.values()].map((trace) => trace.FrameIDs.length)); -const tree = createCalleeTree(events, stackTraces, stackFrames, executables, totalFrames); -const baseFlamegraph = createBaseFlameGraph(tree, 60); +const tree = createCalleeTree(events, stackTraces, stackFrames, executables, totalFrames, 1.0); +const baseFlamegraph = createBaseFlameGraph(tree, 1.0, 60); const flamegraph = createFlameGraph(baseFlamegraph); describe('Flamegraph operations', () => { diff --git a/x-pack/plugins/profiling/common/flamegraph.ts b/x-pack/plugins/profiling/common/flamegraph.ts index ae9adc37679f2..16fb8c1a396c5 100644 --- a/x-pack/plugins/profiling/common/flamegraph.ts +++ b/x-pack/plugins/profiling/common/flamegraph.ts @@ -28,12 +28,18 @@ export interface BaseFlameGraph { CountExclusive: number[]; TotalSeconds: number; + SamplingRate: number; } // createBaseFlameGraph encapsulates the tree representation into a serialized form. -export function createBaseFlameGraph(tree: CalleeTree, totalSeconds: number): BaseFlameGraph { +export function createBaseFlameGraph( + tree: CalleeTree, + samplingRate: number, + totalSeconds: number +): BaseFlameGraph { const graph: BaseFlameGraph = { Size: tree.Size, + SamplingRate: samplingRate, Edges: new Array(tree.Size), FileID: tree.FileID.slice(0, tree.Size), @@ -76,6 +82,7 @@ export interface ElasticFlameGraph extends BaseFlameGraph { export function createFlameGraph(base: BaseFlameGraph): ElasticFlameGraph { const graph: ElasticFlameGraph = { Size: base.Size, + SamplingRate: base.SamplingRate, Edges: base.Edges, FileID: base.FileID, @@ -137,6 +144,7 @@ export function createFlameGraph(base: BaseFlameGraph): ElasticFlameGraph { FunctionOffset: graph.FunctionOffset[i], SourceFilename: graph.SourceFilename[i], SourceLine: graph.SourceLine[i], + SamplingRate: graph.SamplingRate, }); graph.Label[i] = getCalleeLabel(metadata); } diff --git a/x-pack/plugins/profiling/common/functions.test.ts b/x-pack/plugins/profiling/common/functions.test.ts index c17687453c2fd..491fd06ecb2b0 100644 --- a/x-pack/plugins/profiling/common/functions.test.ts +++ b/x-pack/plugins/profiling/common/functions.test.ts @@ -20,7 +20,8 @@ describe('TopN function operations', () => { stackFrames, executables, 0, - maxTopN + maxTopN, + 1.0 ); expect(topNFunctions.TotalCount).toEqual(totalSamples); diff --git a/x-pack/plugins/profiling/common/functions.ts b/x-pack/plugins/profiling/common/functions.ts index 50dd7b3b793d3..6e5f97a6cdbfe 100644 --- a/x-pack/plugins/profiling/common/functions.ts +++ b/x-pack/plugins/profiling/common/functions.ts @@ -35,6 +35,7 @@ type TopNFunction = Pick< export interface TopNFunctions { TotalCount: number; TopN: TopNFunction[]; + SamplingRate: number; } export function createTopNFunctions( @@ -43,7 +44,8 @@ export function createTopNFunctions( stackFrames: Map, executables: Map, startIndex: number, - endIndex: number + endIndex: number, + samplingRate: number ): TopNFunctions { // The `count` associated with a frame provides the total number of // traces in which that node has appeared at least once. However, a @@ -52,12 +54,14 @@ export function createTopNFunctions( // far in each trace. let totalCount = 0; const topNFunctions = new Map(); + // The factor to apply to sampled events to scale the estimated result correctly. + const scalingFactor = 1.0 / samplingRate; // Collect metadata and inclusive + exclusive counts for each distinct frame. for (const [stackTraceID, count] of events) { const uniqueFrameGroupsPerEvent = new Set(); - - totalCount += count; + const scaledCount = count * scalingFactor; + totalCount += scaledCount; // It is possible that we do not have a stacktrace for an event, // e.g. when stopping the host agent or on network errors. @@ -107,12 +111,12 @@ export function createTopNFunctions( if (!uniqueFrameGroupsPerEvent.has(frameGroupID)) { uniqueFrameGroupsPerEvent.add(frameGroupID); - topNFunction.CountInclusive += count; + topNFunction.CountInclusive += scaledCount; } if (i === lenStackTrace - 1) { // Leaf frame: sum up counts for exclusive CPU. - topNFunction.CountExclusive += count; + topNFunction.CountExclusive += scaledCount; } } } @@ -146,10 +150,10 @@ export function createTopNFunctions( CountInclusive: frameAndCount.CountInclusive, Id: frameAndCount.FrameGroupID, })); - return { TotalCount: totalCount, TopN: framesAndCountsAndIds, + SamplingRate: samplingRate, }; } diff --git a/x-pack/plugins/profiling/common/profiling.ts b/x-pack/plugins/profiling/common/profiling.ts index a2e1af7d4ae60..22480a26f8ab2 100644 --- a/x-pack/plugins/profiling/common/profiling.ts +++ b/x-pack/plugins/profiling/common/profiling.ts @@ -159,6 +159,8 @@ export interface StackFrameMetadata { // unused atm due to lack of symbolization metadata SourcePackageURL: string; // unused atm due to lack of symbolization metadata + + SamplingRate: number; } export function createStackFrameMetadata( @@ -181,6 +183,7 @@ export function createStackFrameMetadata( metadata.SourceFilename = options.SourceFilename ?? ''; metadata.SourcePackageHash = options.SourcePackageHash ?? ''; metadata.SourcePackageURL = options.SourcePackageURL ?? ''; + metadata.SamplingRate = options.SamplingRate ?? 1.0; // Unknown/invalid offsets are currently set to 0. // diff --git a/x-pack/plugins/profiling/common/stack_traces.ts b/x-pack/plugins/profiling/common/stack_traces.ts index 5384e60639b9d..2b7ce4f700f29 100644 --- a/x-pack/plugins/profiling/common/stack_traces.ts +++ b/x-pack/plugins/profiling/common/stack_traces.ts @@ -55,6 +55,7 @@ export interface StackTraceResponse { ['stack_frames']?: ProfilingStackFrames; ['executables']?: ProfilingExecutables; ['total_frames']: number; + ['sampling_rate']: number; } export enum StackTracesDisplayOption { diff --git a/x-pack/plugins/profiling/public/components/flamegraph/flamegraph_tooltip.tsx b/x-pack/plugins/profiling/public/components/flamegraph/flamegraph_tooltip.tsx index 9a4f53be00e8c..6f0de5ee500ac 100644 --- a/x-pack/plugins/profiling/public/components/flamegraph/flamegraph_tooltip.tsx +++ b/x-pack/plugins/profiling/public/components/flamegraph/flamegraph_tooltip.tsx @@ -38,6 +38,7 @@ interface Props { comparisonCountExclusive?: number; comparisonTotalSamples?: number; comparisonTotalSeconds?: number; + samplingRate?: number; onShowMoreClick?: () => void; } @@ -54,6 +55,7 @@ export function FlameGraphTooltip({ comparisonCountExclusive, comparisonTotalSamples, comparisonTotalSeconds, + samplingRate, onShowMoreClick, }: Props) { const theme = useEuiTheme(); @@ -78,6 +80,8 @@ export function FlameGraphTooltip({ }) : undefined; + const prependString = samplingRate === 1.0 ? ' ' : '~'; + return ( @@ -100,6 +104,7 @@ export function FlameGraphTooltip({ formatValue={asPercentage} showDifference formatDifferenceAsPercentage + prependValue={prependString} /> )} @@ -132,6 +138,7 @@ export function FlameGraphTooltip({ } showDifference formatDifferenceAsPercentage={false} + prependValue={prependString} /> {onShowMoreClick && ( <> diff --git a/x-pack/plugins/profiling/public/components/flamegraph/index.tsx b/x-pack/plugins/profiling/public/components/flamegraph/index.tsx index 1d5ac238ce017..b16da9a6db8e8 100644 --- a/x-pack/plugins/profiling/public/components/flamegraph/index.tsx +++ b/x-pack/plugins/profiling/public/components/flamegraph/index.tsx @@ -176,6 +176,7 @@ export function FlameGraph({ frame={selected} totalSeconds={primaryFlamegraph?.TotalSeconds ?? 0} totalSamples={totalSamples} + samplingRate={primaryFlamegraph?.SamplingRate ?? 1.0} /> )} diff --git a/x-pack/plugins/profiling/public/components/flamegraph/tooltip_row.tsx b/x-pack/plugins/profiling/public/components/flamegraph/tooltip_row.tsx index 5f3511169ecdc..de2054371f5d2 100644 --- a/x-pack/plugins/profiling/public/components/flamegraph/tooltip_row.tsx +++ b/x-pack/plugins/profiling/public/components/flamegraph/tooltip_row.tsx @@ -17,6 +17,7 @@ export function TooltipRow({ formatDifferenceAsPercentage, showDifference, formatValue, + prependValue = '', }: { value: number; label: string | React.ReactElement; @@ -24,8 +25,11 @@ export function TooltipRow({ formatDifferenceAsPercentage: boolean; showDifference: boolean; formatValue?: (value: number) => string; + prependValue?: string; }) { - const valueLabel = formatValue ? formatValue(Math.abs(value)) : value.toString(); + const valueLabel = `${prependValue}${ + formatValue ? formatValue(Math.abs(value)) : value.toString() + }`; const comparisonLabel = formatValue && isNumber(comparison) ? formatValue(comparison) : comparison?.toString(); diff --git a/x-pack/plugins/profiling/public/components/frame_information_window/get_impact_rows.tsx b/x-pack/plugins/profiling/public/components/frame_information_window/get_impact_rows.tsx index fb5478b65aa2e..85a15206166c5 100644 --- a/x-pack/plugins/profiling/public/components/frame_information_window/get_impact_rows.tsx +++ b/x-pack/plugins/profiling/public/components/frame_information_window/get_impact_rows.tsx @@ -20,11 +20,13 @@ export function getImpactRows({ countExclusive, totalSamples, totalSeconds, + isApproximate = false, }: { countInclusive: number; countExclusive: number; totalSamples: number; totalSeconds: number; + isApproximate: boolean; }) { const { percentage, diff --git a/x-pack/plugins/profiling/public/components/frame_information_window/index.tsx b/x-pack/plugins/profiling/public/components/frame_information_window/index.tsx index 41717078fe3f6..db3fc5d10c9dd 100644 --- a/x-pack/plugins/profiling/public/components/frame_information_window/index.tsx +++ b/x-pack/plugins/profiling/public/components/frame_information_window/index.tsx @@ -30,9 +30,10 @@ export interface Props { }; totalSamples: number; totalSeconds: number; + samplingRate: number; } -export function FrameInformationWindow({ frame, totalSamples, totalSeconds }: Props) { +export function FrameInformationWindow({ frame, totalSamples, totalSeconds, samplingRate }: Props) { const coPilotService = useCoPilot(); const promptParams = useMemo(() => { @@ -84,11 +85,16 @@ export function FrameInformationWindow({ frame, totalSamples, totalSeconds }: Pr sourceLine, }); + // Are the results sampled? If yes, prepend a '~'. + const isApproximate = (samplingRate ?? 1.0) === 1.0; + const prependString = isApproximate ? undefined : '~'; + const impactRows = getImpactRows({ countInclusive, countExclusive, totalSamples, totalSeconds, + isApproximate, }); return ( @@ -138,7 +144,7 @@ export function FrameInformationWindow({ frame, totalSamples, totalSeconds }: Pr - + diff --git a/x-pack/plugins/profiling/public/components/frame_information_window/key_value_list.tsx b/x-pack/plugins/profiling/public/components/frame_information_window/key_value_list.tsx index 752be18349be1..4dc6909173e63 100644 --- a/x-pack/plugins/profiling/public/components/frame_information_window/key_value_list.tsx +++ b/x-pack/plugins/profiling/public/components/frame_information_window/key_value_list.tsx @@ -10,20 +10,21 @@ import React from 'react'; interface Props { rows: Array<{ label: string | React.ReactNode; value: React.ReactNode }>; + prependString?: string; } -export function KeyValueList({ rows }: Props) { +export function KeyValueList({ rows, prependString = '' }: Props) { return ( {rows.map((row, index) => ( - <> + {row.label}: - {row.value} + {`${prependString}row.value`} @@ -32,7 +33,7 @@ export function KeyValueList({ rows }: Props) { ) : undefined} - + ))} ); diff --git a/x-pack/plugins/profiling/public/components/topn_functions/index.tsx b/x-pack/plugins/profiling/public/components/topn_functions/index.tsx index d86f5fe62d66f..0b5736c72fe39 100644 --- a/x-pack/plugins/profiling/public/components/topn_functions/index.tsx +++ b/x-pack/plugins/profiling/public/components/topn_functions/index.tsx @@ -45,24 +45,36 @@ interface Row { }; } +function getTotalSamplesLabel(samplingRate?: number) { + if (samplingRate === undefined) { + return i18n.translate('xpack.profiling.functionsView.totalSampleCountLabel', { + defaultMessage: 'Total sample estimate:', + }); + } + return i18n.translate('xpack.profiling.functionsView.totalSampleCountLabelWithSamplingRate', { + defaultMessage: 'Total sample (estimate sample rate: {samplingRate}):', + values: { samplingRate }, + }); +} + function TotalSamplesStat({ totalSamples, newSamples, + samplingRateA, + samplingRateB, }: { totalSamples: number; newSamples: number | undefined; + samplingRateA: number; + samplingRateB: number | undefined; }) { const value = totalSamples.toLocaleString(); - const sampleHeader = i18n.translate('xpack.profiling.functionsView.totalSampleCountLabel', { - defaultMessage: ' Total sample estimate: ', - }); - if (newSamples === undefined || newSamples === 0) { return ( {value}} - description={sampleHeader} + description={getTotalSamplesLabel(samplingRateA)} /> ); } @@ -75,10 +87,10 @@ function TotalSamplesStat({ title={ {value} - + } - description={sampleHeader} + description={getTotalSamplesLabel(samplingRateB)} /> ); } @@ -87,12 +99,14 @@ function SampleStat({ samples, diffSamples, totalSamples, + isSampled, }: { samples: number; diffSamples?: number; totalSamples: number; + isSampled: boolean; }) { - const samplesLabel = samples.toLocaleString(); + const samplesLabel = `${isSampled ? '~ ' : ''}${samples.toLocaleString()}`; if (diffSamples === undefined || diffSamples === 0 || totalSamples === 0) { return <>{samplesLabel}; @@ -114,7 +128,7 @@ function SampleStat({ ); } -function CPUStat({ cpu, diffCPU }: { cpu: number; diffCPU?: number }) { +function CPUStat({ cpu, diffCPU }: { cpu: number; diffCPU?: number; isSampled?: boolean }) { const cpuLabel = `${cpu.toFixed(2)}%`; if (diffCPU === undefined || diffCPU === 0) { @@ -162,7 +176,7 @@ export function TopNFunctionsTable({ comparisonScaleFactor, }: Props) { const [selectedRow, setSelectedRow] = useState(); - + const isEstimatedA = (topNFunctions?.SamplingRate ?? 1.0) !== 1.0; const totalCount: number = useMemo(() => { if (!topNFunctions || !topNFunctions.TotalCount) { return 0; @@ -268,7 +282,12 @@ export function TopNFunctionsTable({ }), render: (_, { samples, diff }) => { return ( - + ); }, align: 'right', @@ -394,12 +413,13 @@ export function TopNFunctionsTable({ }, [sortDirection] ).slice(0, 100); - return ( <> @@ -439,6 +459,7 @@ export function TopNFunctionsTable({ }} totalSeconds={totalSeconds ?? 0} totalSamples={selectedRow.samples} + samplingRate={topNFunctions?.SamplingRate ?? 1.0} /> )} diff --git a/x-pack/plugins/profiling/server/routes/flamechart.ts b/x-pack/plugins/profiling/server/routes/flamechart.ts index 3cd0591e63179..3b33267892e0e 100644 --- a/x-pack/plugins/profiling/server/routes/flamechart.ts +++ b/x-pack/plugins/profiling/server/routes/flamechart.ts @@ -49,12 +49,18 @@ export function registerFlameChartSearchRoute({ const totalSeconds = timeTo - timeFrom; const t0 = Date.now(); - const { stackTraceEvents, stackTraces, executables, stackFrames, totalFrames } = - await searchStackTraces({ - client: profilingElasticsearchClient, - filter, - sampleSize: targetSampleSize, - }); + const { + stackTraceEvents, + stackTraces, + executables, + stackFrames, + totalFrames, + samplingRate, + } = await searchStackTraces({ + client: profilingElasticsearchClient, + filter, + sampleSize: targetSampleSize, + }); logger.info(`querying stacktraces took ${Date.now() - t0} ms`); const flamegraph = await withProfilingSpan('create_flamegraph', async () => { @@ -64,12 +70,13 @@ export function registerFlameChartSearchRoute({ stackTraces, stackFrames, executables, - totalFrames + totalFrames, + samplingRate ); logger.info(`creating callee tree took ${Date.now() - t1} ms`); const t2 = Date.now(); - const fg = createBaseFlameGraph(tree, totalSeconds); + const fg = createBaseFlameGraph(tree, samplingRate, totalSeconds); logger.info(`creating flamegraph took ${Date.now() - t2} ms`); return fg; diff --git a/x-pack/plugins/profiling/server/routes/functions.ts b/x-pack/plugins/profiling/server/routes/functions.ts index 59a13a5df4a20..52790c7a9b35b 100644 --- a/x-pack/plugins/profiling/server/routes/functions.ts +++ b/x-pack/plugins/profiling/server/routes/functions.ts @@ -52,13 +52,12 @@ export function registerTopNFunctionsSearchRoute({ }); const t0 = Date.now(); - const { stackTraceEvents, stackTraces, executables, stackFrames } = await searchStackTraces( - { + const { stackTraceEvents, stackTraces, executables, stackFrames, samplingRate } = + await searchStackTraces({ client: profilingElasticsearchClient, filter, sampleSize: targetSampleSize, - } - ); + }); logger.info(`querying stacktraces took ${Date.now() - t0} ms`); const t1 = Date.now(); @@ -69,7 +68,8 @@ export function registerTopNFunctionsSearchRoute({ stackFrames, executables, startIndex, - endIndex + endIndex, + samplingRate ); }); logger.info(`creating topN functions took ${Date.now() - t1} ms`); diff --git a/x-pack/plugins/profiling/server/routes/search_stacktraces.test.ts b/x-pack/plugins/profiling/server/routes/search_stacktraces.test.ts index b8adc472fe9f5..ede7a7a32f10d 100644 --- a/x-pack/plugins/profiling/server/routes/search_stacktraces.test.ts +++ b/x-pack/plugins/profiling/server/routes/search_stacktraces.test.ts @@ -12,6 +12,7 @@ describe('Stack trace response operations', () => { test('empty stack trace response', () => { const original: StackTraceResponse = { total_frames: 0, + sampling_rate: 1.0, }; const expected = { @@ -72,6 +73,7 @@ describe('Stack trace response operations', () => { def: 'def.c', }, total_frames: 1, + sampling_rate: 1.0, }; const expected = { @@ -121,6 +123,7 @@ describe('Stack trace response operations', () => { ['def', { FileName: 'def.c' }], ]), totalFrames: 1, + samplingRate: 1.0, }; const decoded = decodeStackTraceResponse(original); @@ -146,6 +149,7 @@ describe('Stack trace response operations', () => { stack_trace_events: { a: 1, }, + sampling_rate: 1.0, stack_traces: { a: { file_ids: ['abc'], diff --git a/x-pack/plugins/profiling/server/routes/search_stacktraces.ts b/x-pack/plugins/profiling/server/routes/search_stacktraces.ts index c1145ba57a5d1..452b07c526bac 100644 --- a/x-pack/plugins/profiling/server/routes/search_stacktraces.ts +++ b/x-pack/plugins/profiling/server/routes/search_stacktraces.ts @@ -102,6 +102,7 @@ export function decodeStackTraceResponse(response: StackTraceResponse) { stackFrames, executables, totalFrames: response.total_frames, + samplingRate: response.sampling_rate, }; } diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 07d06e4be4e6b..ed9afb7fa9518 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -69,7 +69,7 @@ export const allowedExperimentalValues = Object.freeze({ /** * Enables the `upload` endpoint response action (v8.9) */ - responseActionUploadEnabled: false, + responseActionUploadEnabled: true, /** * Enables top charts on Alerts Page diff --git a/x-pack/plugins/security_solution/cypress/e2e/investigations/alerts/expandable_flyout/alert_details_right_panel_overview_tab.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/investigations/alerts/expandable_flyout/alert_details_right_panel_overview_tab.cy.ts index ba66d5a87068f..faec335d14039 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/investigations/alerts/expandable_flyout/alert_details_right_panel_overview_tab.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/investigations/alerts/expandable_flyout/alert_details_right_panel_overview_tab.cy.ts @@ -41,6 +41,7 @@ import { DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_MITRE_ATTACK_TITLE, DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_REASON_DETAILS, DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_REASON_TITLE, + DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_SESSION_PREVIEW, } from '../../../../screens/expandable_flyout/alert_details_right_panel_overview_tab'; import { clickCorrelationsViewAllButton, @@ -324,11 +325,19 @@ describe( }); describe('visualizations section', () => { - it('should display analyzer preview', () => { + it('should display analyzer and session previews', () => { toggleOverviewTabDescriptionSection(); toggleOverviewTabVisualizationsSection(); + + cy.log('analyzer graph preview'); + cy.get(DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_ANALYZER_TREE).scrollIntoView(); cy.get(DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_ANALYZER_TREE).should('be.visible'); + + cy.log('session view preview'); + + cy.get(DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_SESSION_PREVIEW).scrollIntoView(); + cy.get(DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_SESSION_PREVIEW).should('be.visible'); }); }); } diff --git a/x-pack/plugins/security_solution/cypress/screens/expandable_flyout/alert_details_right_panel_overview_tab.ts b/x-pack/plugins/security_solution/cypress/screens/expandable_flyout/alert_details_right_panel_overview_tab.ts index d3e1fe10d26f2..922d57b9c49fc 100644 --- a/x-pack/plugins/security_solution/cypress/screens/expandable_flyout/alert_details_right_panel_overview_tab.ts +++ b/x-pack/plugins/security_solution/cypress/screens/expandable_flyout/alert_details_right_panel_overview_tab.ts @@ -42,6 +42,7 @@ import { MITRE_ATTACK_TITLE_TEST_ID, REASON_DETAILS_TEST_ID, REASON_TITLE_TEST_ID, + SESSION_PREVIEW_TEST_ID, VISUALIZATIONS_SECTION_HEADER_TEST_ID, } from '../../../public/flyout/right/components/test_ids'; @@ -132,3 +133,6 @@ export const DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_VISUALIZATIONS_SECTION_HEADER getDataTestSubjectSelector(VISUALIZATIONS_SECTION_HEADER_TEST_ID); export const DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_ANALYZER_TREE = getDataTestSubjectSelector(ANALYZER_TREE_TEST_ID); + +export const DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_SESSION_PREVIEW = + getDataTestSubjectSelector(SESSION_PREVIEW_TEST_ID); diff --git a/x-pack/plugins/security_solution/public/flyout/left/index.tsx b/x-pack/plugins/security_solution/public/flyout/left/index.tsx index c92483a0c20aa..8c77babd483df 100644 --- a/x-pack/plugins/security_solution/public/flyout/left/index.tsx +++ b/x-pack/plugins/security_solution/public/flyout/left/index.tsx @@ -20,6 +20,7 @@ import { useLeftPanelContext } from './context'; export type LeftPanelPaths = 'visualize' | 'insights' | 'investigation'; export const LeftPanelKey: LeftPanelProps['key'] = 'document-details-left'; +export const LeftPanelVisualizeTabPath: LeftPanelProps['path'] = ['visualize']; export const LeftPanelInsightsTabPath: LeftPanelProps['path'] = ['insights']; export const LeftPanelInvestigationTabPath: LeftPanelProps['path'] = ['investigation']; diff --git a/x-pack/plugins/security_solution/public/flyout/right/components/analyzer_tree.test.tsx b/x-pack/plugins/security_solution/public/flyout/right/components/analyzer_tree.test.tsx index b07fe9b17bc2d..cf05f61ace9eb 100644 --- a/x-pack/plugins/security_solution/public/flyout/right/components/analyzer_tree.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/right/components/analyzer_tree.test.tsx @@ -11,25 +11,54 @@ import { ANALYZER_TREE_TEST_ID, ANALYZER_TREE_LOADING_TEST_ID, ANALYZER_TREE_ERROR_TEST_ID, + ANALYZER_TREE_VIEW_DETAILS_BUTTON_TEST_ID, } from './test_ids'; import { ANALYZER_PREVIEW_TITLE } from './translations'; import * as mock from '../mocks/mock_analyzer_data'; +import type { AnalyzerTreeProps } from './analyzer_tree'; import { AnalyzerTree } from './analyzer_tree'; +import { ExpandableFlyoutContext } from '@kbn/expandable-flyout/src/context'; +import { TestProviders } from '@kbn/timelines-plugin/public/mock'; +import { RightPanelContext } from '../context'; +import { LeftPanelKey, LeftPanelVisualizeTabPath } from '../../left'; -const defaultProps = { +const defaultProps: AnalyzerTreeProps = { statsNodes: mock.mockStatsNodes, loading: false, error: false, }; + +const flyoutContextValue = { + openLeftPanel: jest.fn(), +} as unknown as ExpandableFlyoutContext; + +const panelContextValue = { + eventId: 'event id', + indexName: 'indexName', + browserFields: {}, + dataFormattedForFieldBrowser: [], +} as unknown as RightPanelContext; + +const renderAnalyzerTree = (children: React.ReactNode) => + render( + + + + {children} + + + + ); + describe('', () => { it('should render the component when data is passed', () => { - const { getByTestId, getByText } = render(); + const { getByTestId, getByText } = renderAnalyzerTree(); expect(getByText(ANALYZER_PREVIEW_TITLE)).toBeInTheDocument(); expect(getByTestId(ANALYZER_TREE_TEST_ID)).toBeInTheDocument(); }); it('should render blank when data is not passed', () => { - const { queryByTestId, queryByText } = render( + const { queryByTestId, queryByText } = renderAnalyzerTree( ); expect(queryByText(ANALYZER_PREVIEW_TITLE)).not.toBeInTheDocument(); @@ -37,13 +66,30 @@ describe('', () => { }); it('should render loading spinner when loading is true', () => { - const { getByTestId } = render(); + const { getByTestId } = renderAnalyzerTree(); expect(getByTestId(ANALYZER_TREE_LOADING_TEST_ID)).toBeInTheDocument(); }); it('should display error message when error is true', () => { - const { getByTestId, getByText } = render(); + const { getByTestId, getByText } = renderAnalyzerTree( + + ); expect(getByText('Unable to display analyzer preview.')).toBeInTheDocument(); expect(getByTestId(ANALYZER_TREE_ERROR_TEST_ID)).toBeInTheDocument(); }); + + it('should navigate to left section Visualize tab when clicking on title', () => { + const { getByTestId } = renderAnalyzerTree(); + + getByTestId(ANALYZER_TREE_VIEW_DETAILS_BUTTON_TEST_ID).click(); + expect(flyoutContextValue.openLeftPanel).toHaveBeenCalledWith({ + id: LeftPanelKey, + path: LeftPanelVisualizeTabPath, + params: { + id: panelContextValue.eventId, + indexName: panelContextValue.indexName, + scopeId: panelContextValue.scopeId, + }, + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/right/components/analyzer_tree.tsx b/x-pack/plugins/security_solution/public/flyout/right/components/analyzer_tree.tsx index 99d5924083a12..d1c15dc48b492 100644 --- a/x-pack/plugins/security_solution/public/flyout/right/components/analyzer_tree.tsx +++ b/x-pack/plugins/security_solution/public/flyout/right/components/analyzer_tree.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { EuiPanel, EuiButtonEmpty, @@ -12,11 +12,15 @@ import { EuiLoadingSpinner, EuiEmptyPrompt, } from '@elastic/eui'; +import { useExpandableFlyoutContext } from '@kbn/expandable-flyout'; +import { useRightPanelContext } from '../context'; +import { LeftPanelKey, LeftPanelVisualizeTabPath } from '../../left'; import { ANALYZER_PREVIEW_TITLE, ANALYZER_PREVIEW_TEXT } from './translations'; import { ANALYZER_TREE_TEST_ID, ANALYZER_TREE_LOADING_TEST_ID, ANALYZER_TREE_ERROR_TEST_ID, + ANALYZER_TREE_VIEW_DETAILS_BUTTON_TEST_ID, } from './test_ids'; import type { StatsNode } from '../../../common/containers/alerts/use_alert_prevalence_from_process_tree'; import { getTreeNodes } from '../utils/analyzer_helpers'; @@ -41,8 +45,22 @@ export interface AnalyzerTreeProps { * Analyzer tree that represent a summary view of analyzer. It shows current process, and its parent and child processes */ export const AnalyzerTree: React.FC = ({ statsNodes, loading, error }) => { + const { eventId, indexName, scopeId } = useRightPanelContext(); + const { openLeftPanel } = useExpandableFlyoutContext(); const items = useMemo(() => getTreeNodes(statsNodes ?? []), [statsNodes]); + const goToAnalyserTab = useCallback(() => { + openLeftPanel({ + id: LeftPanelKey, + path: LeftPanelVisualizeTabPath, + params: { + id: eventId, + indexName, + scopeId, + }, + }); + }, [eventId, openLeftPanel, indexName, scopeId]); + if (loading) { return ; } @@ -63,7 +81,12 @@ export const AnalyzerTree: React.FC = ({ statsNodes, loading, return ( - {}}> + {ANALYZER_PREVIEW_TITLE} diff --git a/x-pack/plugins/security_solution/public/flyout/right/components/prevalence_overview.test.tsx b/x-pack/plugins/security_solution/public/flyout/right/components/prevalence_overview.test.tsx index fc0e234c52e1b..13f51e47c6a78 100644 --- a/x-pack/plugins/security_solution/public/flyout/right/components/prevalence_overview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/right/components/prevalence_overview.test.tsx @@ -133,6 +133,7 @@ describe('', () => { params: { id: panelContextValue.eventId, indexName: panelContextValue.indexName, + scopeId: panelContextValue.scopeId, }, }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/right/components/prevalence_overview.tsx b/x-pack/plugins/security_solution/public/flyout/right/components/prevalence_overview.tsx index a0480468d328b..77f5065bad450 100644 --- a/x-pack/plugins/security_solution/public/flyout/right/components/prevalence_overview.tsx +++ b/x-pack/plugins/security_solution/public/flyout/right/components/prevalence_overview.tsx @@ -33,9 +33,10 @@ export const PrevalenceOverview: FC = () => { params: { id: eventId, indexName, + scopeId, }, }); - }, [eventId, openLeftPanel, indexName]); + }, [eventId, openLeftPanel, indexName, scopeId]); const { empty, prevalenceRows } = usePrevalence({ eventId, diff --git a/x-pack/plugins/security_solution/public/flyout/right/components/session_preview.test.tsx b/x-pack/plugins/security_solution/public/flyout/right/components/session_preview.test.tsx new file mode 100644 index 0000000000000..42b8129919782 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/right/components/session_preview.test.tsx @@ -0,0 +1,116 @@ +/* + * 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 { render, screen } from '@testing-library/react'; +import { useProcessData } from '../hooks/use_process_data'; +import { SessionPreview } from './session_preview'; +import { TestProviders } from '../../../common/mock'; +import React from 'react'; +import { ExpandableFlyoutContext } from '@kbn/expandable-flyout/src/context'; +import { RightPanelContext } from '../context'; +import { SESSION_PREVIEW_VIEW_DETAILS_BUTTON_TEST_ID } from './test_ids'; +import { LeftPanelKey, LeftPanelVisualizeTabPath } from '../../left'; + +jest.mock('../hooks/use_process_data'); + +const flyoutContextValue = { + openLeftPanel: jest.fn(), +} as unknown as ExpandableFlyoutContext; + +const panelContextValue = { + eventId: 'event id', + indexName: 'indexName', + browserFields: {}, + dataFormattedForFieldBrowser: [], +} as unknown as RightPanelContext; + +const renderSessionPreview = () => + render( + + + + + + + + ); + +describe('SessionPreview', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it('renders session preview with all data', () => { + jest.mocked(useProcessData).mockReturnValue({ + processName: 'process1', + userName: 'user1', + startAt: '2022-01-01T00:00:00.000Z', + ruleName: 'rule1', + ruleId: 'id', + workdir: '/path/to/workdir', + command: 'command1', + }); + + renderSessionPreview(); + + expect(screen.getByText('user1')).toBeInTheDocument(); + expect(screen.getByText('started')).toBeInTheDocument(); + expect(screen.getByText('process1')).toBeInTheDocument(); + expect(screen.getByText('at')).toBeInTheDocument(); + expect(screen.getByText('Jan 1, 2022 @ 00:00:00.000')).toBeInTheDocument(); + expect(screen.getByText('with rule')).toBeInTheDocument(); + expect(screen.getByText('rule1')).toBeInTheDocument(); + expect(screen.getByText('by')).toBeInTheDocument(); + expect(screen.getByText('/path/to/workdir command1')).toBeInTheDocument(); + }); + + it('renders session preview without optional data', () => { + jest.mocked(useProcessData).mockReturnValue({ + processName: 'process1', + userName: 'user1', + startAt: null, + ruleName: null, + ruleId: null, + command: null, + workdir: null, + }); + + renderSessionPreview(); + + expect(screen.getByText('user1')).toBeInTheDocument(); + expect(screen.getByText('started')).toBeInTheDocument(); + expect(screen.getByText('process1')).toBeInTheDocument(); + expect(screen.queryByText('at')).not.toBeInTheDocument(); + expect(screen.queryByText('with rule')).not.toBeInTheDocument(); + expect(screen.queryByText('by')).not.toBeInTheDocument(); + }); + + it('should navigate to left section Visualize tab when clicking on title', () => { + jest.mocked(useProcessData).mockReturnValue({ + processName: 'process1', + userName: 'user1', + startAt: '2022-01-01T00:00:00.000Z', + ruleName: 'rule1', + ruleId: 'id', + workdir: '/path/to/workdir', + command: 'command1', + }); + + const { getByTestId } = renderSessionPreview(); + + getByTestId(SESSION_PREVIEW_VIEW_DETAILS_BUTTON_TEST_ID).click(); + expect(flyoutContextValue.openLeftPanel).toHaveBeenCalledWith({ + id: LeftPanelKey, + path: LeftPanelVisualizeTabPath, + params: { + id: panelContextValue.eventId, + indexName: panelContextValue.indexName, + scopeId: panelContextValue.scopeId, + }, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/right/components/session_preview.tsx b/x-pack/plugins/security_solution/public/flyout/right/components/session_preview.tsx new file mode 100644 index 0000000000000..0d79d2b51f25b --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/right/components/session_preview.tsx @@ -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 { EuiButtonEmpty, EuiCode, EuiIcon, EuiPanel, useEuiTheme } from '@elastic/eui'; +import React, { useMemo, type FC, useCallback } from 'react'; +import { useExpandableFlyoutContext } from '@kbn/expandable-flyout'; +import { SIGNAL_RULE_NAME_FIELD_NAME } from '../../../timelines/components/timeline/body/renderers/constants'; +import { useRightPanelContext } from '../context'; +import { PreferenceFormattedDate } from '../../../common/components/formatted_date'; + +import { useProcessData } from '../hooks/use_process_data'; +import { SESSION_PREVIEW_TEST_ID, SESSION_PREVIEW_VIEW_DETAILS_BUTTON_TEST_ID } from './test_ids'; +import { + SESSION_PREVIEW_COMMAND_TEXT, + SESSION_PREVIEW_PROCESS_TEXT, + SESSION_PREVIEW_RULE_TEXT, + SESSION_PREVIEW_TIME_TEXT, + SESSION_PREVIEW_TITLE, +} from './translations'; +import { LeftPanelKey, LeftPanelVisualizeTabPath } from '../../left'; +import { RenderRuleName } from '../../../timelines/components/timeline/body/renderers/formatted_field_helpers'; + +/** + * One-off helper to make sure that inline values are rendered consistently + */ +const ValueContainer: FC<{ text?: string }> = ({ text, children }) => ( + <> + {text && ( + <> +   + {text} +   + + )} + {children} + +); + +/** + * Renders session preview under visualistions section in the flyout right EuiPanel + */ +export const SessionPreview: FC = () => { + const { eventId, indexName, scopeId } = useRightPanelContext(); + const { openLeftPanel } = useExpandableFlyoutContext(); + + const goToSessionViewTab = useCallback(() => { + openLeftPanel({ + id: LeftPanelKey, + path: LeftPanelVisualizeTabPath, + params: { + id: eventId, + indexName, + scopeId, + }, + }); + }, [eventId, openLeftPanel, indexName, scopeId]); + + const { processName, userName, startAt, ruleName, ruleId, workdir, command } = useProcessData(); + const { euiTheme } = useEuiTheme(); + + const emphasisStyles = useMemo( + () => ({ fontWeight: euiTheme.font.weight.bold }), + [euiTheme.font.weight.bold] + ); + + const processNameFragment = useMemo(() => { + return ( + processName && ( + + {processName} + + ) + ); + }, [emphasisStyles, processName]); + + const timeFragment = useMemo(() => { + return ( + startAt && ( + + + + ) + ); + }, [startAt]); + + const ruleFragment = useMemo(() => { + return ( + ruleName && + ruleId && ( + + + + ) + ); + }, [ruleName, ruleId, scopeId, eventId]); + + const commandFragment = useMemo(() => { + return ( + command && ( + + + {workdir} {command} + + + ) + ); + }, [command, workdir]); + + return ( + + + + {SESSION_PREVIEW_TITLE} + + +
+ + +   + {userName} + + {processNameFragment} + {timeFragment} + {ruleFragment} + {commandFragment} +
+
+
+ ); +}; diff --git a/x-pack/plugins/security_solution/public/flyout/right/components/test_ids.ts b/x-pack/plugins/security_solution/public/flyout/right/components/test_ids.ts index 002effd64ee30..48d4ba287785b 100644 --- a/x-pack/plugins/security_solution/public/flyout/right/components/test_ids.ts +++ b/x-pack/plugins/security_solution/public/flyout/right/components/test_ids.ts @@ -129,5 +129,10 @@ export const VISUALIZATIONS_SECTION_HEADER_TEST_ID = 'securitySolutionDocumentDetailsVisualizationsTitleHeader'; export const ANALYZER_PREVIEW_TEST_ID = 'securitySolutionDocumentDetailsAnalayzerPreview'; export const ANALYZER_TREE_TEST_ID = 'securitySolutionDocumentDetailsAnalayzerTree'; +export const ANALYZER_TREE_VIEW_DETAILS_BUTTON_TEST_ID = + 'securitySolutionDocumentDetailsAnalayzerTreeViewDetailsButton'; export const ANALYZER_TREE_LOADING_TEST_ID = 'securitySolutionDocumentDetailsAnalayzerTreeLoading'; export const ANALYZER_TREE_ERROR_TEST_ID = 'securitySolutionDocumentDetailsAnalayzerTreeError'; +export const SESSION_PREVIEW_TEST_ID = 'securitySolutionDocumentDetailsSessionPreview'; +export const SESSION_PREVIEW_VIEW_DETAILS_BUTTON_TEST_ID = + 'securitySolutionDocumentDetailsSessionPreviewViewDetailsButton'; diff --git a/x-pack/plugins/security_solution/public/flyout/right/components/translations.ts b/x-pack/plugins/security_solution/public/flyout/right/components/translations.ts index 80b7bf54a1f18..7952c4dbe8847 100644 --- a/x-pack/plugins/security_solution/public/flyout/right/components/translations.ts +++ b/x-pack/plugins/security_solution/public/flyout/right/components/translations.ts @@ -296,3 +296,38 @@ export const INVESTIGATION_GUIDE_TITLE = i18n.translate( defaultMessage: 'Investigation guide', } ); + +export const SESSION_PREVIEW_TITLE = i18n.translate( + 'xpack.securitySolution.flyout.documentDetails.sessionPreview.title', + { + defaultMessage: 'Session viewer preview', + } +); + +export const SESSION_PREVIEW_PROCESS_TEXT = i18n.translate( + 'xpack.securitySolution.flyout.documentDetails.sessionPreview.processText', + { + defaultMessage: 'started', + } +); + +export const SESSION_PREVIEW_TIME_TEXT = i18n.translate( + 'xpack.securitySolution.flyout.documentDetails.sessionPreview.timeText', + { + defaultMessage: 'at', + } +); + +export const SESSION_PREVIEW_RULE_TEXT = i18n.translate( + 'xpack.securitySolution.flyout.documentDetails.sessionPreview.ruleText', + { + defaultMessage: 'with rule', + } +); + +export const SESSION_PREVIEW_COMMAND_TEXT = i18n.translate( + 'xpack.securitySolution.flyout.documentDetails.sessionPreview.commandText', + { + defaultMessage: 'by', + } +); diff --git a/x-pack/plugins/security_solution/public/flyout/right/components/visualizations_section.test.tsx b/x-pack/plugins/security_solution/public/flyout/right/components/visualizations_section.test.tsx index 2b447967ca80b..d5d3a48340652 100644 --- a/x-pack/plugins/security_solution/public/flyout/right/components/visualizations_section.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/right/components/visualizations_section.test.tsx @@ -13,6 +13,7 @@ import { VisualizationsSection } from './visualizations_section'; import { mockContextValue, mockDataFormattedForFieldBrowser } from '../mocks/mock_context'; import { RightPanelContext } from '../context'; import { useAlertPrevalenceFromProcessTree } from '../../../common/containers/alerts/use_alert_prevalence_from_process_tree'; +import { ExpandableFlyoutContext } from '@kbn/expandable-flyout/src/context'; jest.mock('../../../common/containers/alerts/use_alert_prevalence_from_process_tree', () => ({ useAlertPrevalenceFromProcessTree: jest.fn(), @@ -35,10 +36,16 @@ describe('', () => { }); it('should render visualizations component', () => { + const flyoutContextValue = { + openLeftPanel: jest.fn(), + } as unknown as ExpandableFlyoutContext; + const { getByTestId, getAllByRole } = render( - - - + + + + + ); expect(getByTestId(VISUALIZATIONS_SECTION_HEADER_TEST_ID)).toBeInTheDocument(); diff --git a/x-pack/plugins/security_solution/public/flyout/right/components/visualizations_section.tsx b/x-pack/plugins/security_solution/public/flyout/right/components/visualizations_section.tsx index c6c84a6a6b34d..a8e3e56c0a5cb 100644 --- a/x-pack/plugins/security_solution/public/flyout/right/components/visualizations_section.tsx +++ b/x-pack/plugins/security_solution/public/flyout/right/components/visualizations_section.tsx @@ -6,10 +6,12 @@ */ import React from 'react'; +import { EuiSpacer } from '@elastic/eui'; import { ExpandableSection } from './expandable_section'; import { VISUALIZATIONS_SECTION_TEST_ID } from './test_ids'; import { VISUALIZATIONS_TITLE } from './translations'; import { AnalyzerPreview } from './analyzer_preview'; +import { SessionPreview } from './session_preview'; export interface VisualizatioinsSectionProps { /** @@ -30,6 +32,10 @@ export const VisualizationsSection: React.FC = ({ title={VISUALIZATIONS_TITLE} data-test-subj={VISUALIZATIONS_SECTION_TEST_ID} > + + + + ); diff --git a/x-pack/plugins/security_solution/public/flyout/right/hooks/use_process_data.test.tsx b/x-pack/plugins/security_solution/public/flyout/right/hooks/use_process_data.test.tsx new file mode 100644 index 0000000000000..ac6c628ed090e --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/right/hooks/use_process_data.test.tsx @@ -0,0 +1,84 @@ +/* + * 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 { getUserDisplayName, useProcessData } from './use_process_data'; +import { renderHook } from '@testing-library/react-hooks'; +import type { FC } from 'react'; +import { RightPanelContext } from '../context'; +import React from 'react'; + +describe('getUserDisplayName', () => { + const getFieldsData = jest.fn(); + + it('should return userName', () => { + getFieldsData.mockImplementation((field: string) => { + if (field === 'process.entry_leader.user.name') { + return 'userName'; + } + }); + + expect(getUserDisplayName(getFieldsData)).toEqual('userName'); + }); + + it('should return unknown', () => { + getFieldsData.mockImplementation((field: string) => undefined); + + expect(getUserDisplayName(getFieldsData)).toEqual('unknown'); + }); + + it('should return root', () => { + getFieldsData.mockImplementation((field: string) => { + if (field === 'process.entry_leader.user.name') { + return undefined; + } + if (field === 'process.entry_leader.user.id') { + return '0'; + } + }); + + expect(getUserDisplayName(getFieldsData)).toEqual('root'); + }); + + it('should return uid+userId', () => { + getFieldsData.mockImplementation((field: string) => { + if (field === 'process.entry_leader.user.name') { + return undefined; + } + if (field === 'process.entry_leader.user.id') { + return 'userId'; + } + }); + + expect(getUserDisplayName(getFieldsData)).toEqual('uid: userId'); + }); +}); + +const panelContextValue = { + getFieldsData: jest.fn().mockReturnValue('test'), +} as unknown as RightPanelContext; + +const ProviderComponent: FC = ({ children }) => ( + {children} +); + +describe('useProcessData', () => { + it('should return values for session preview component', () => { + const hookResult = renderHook(() => useProcessData(), { + wrapper: ProviderComponent, + }); + + expect(hookResult.result.current).toEqual({ + command: 'test', + processName: 'test', + ruleName: 'test', + ruleId: 'test', + startAt: 'test', + userName: 'test', + workdir: 'test', + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/right/hooks/use_process_data.ts b/x-pack/plugins/security_solution/public/flyout/right/hooks/use_process_data.ts new file mode 100644 index 0000000000000..72ca71badaf07 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/right/hooks/use_process_data.ts @@ -0,0 +1,58 @@ +/* + * 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 { useMemo } from 'react'; +import type { GetFieldsData } from '../../../common/hooks/use_get_fields_data'; +import { getField } from '../../shared/utils'; +import { useRightPanelContext } from '../context'; + +const FIELD_USER_NAME = 'process.entry_leader.user.name' as const; +const FIELD_USER_ID = 'process.entry_leader.user.id' as const; +const FIELD_PROCESS_NAME = 'process.entry_leader.name' as const; +const FIELD_START_AT = 'process.entry_leader.start' as const; +const FIELD_RULE_NAME = 'kibana.alert.rule.name' as const; +const FIELD_RULE_ID = 'kibana.alert.rule.uuid' as const; +const FIELD_WORKING_DIRECTORY = 'process.group_leader.working_directory' as const; +const FIELD_COMMAND = 'process.command_line' as const; + +/** + * Returns user name with some fallback logic in case it is not available. The idea was borrowed from session viewer + */ +export const getUserDisplayName = (getFieldsData: GetFieldsData): string => { + const userName = getField(getFieldsData(FIELD_USER_NAME)); + const userId = getField(getFieldsData(FIELD_USER_ID)); + + if (userName) { + return userName; + } + + if (!userId) { + return 'unknown'; + } + + return userId === '0' ? 'root' : `uid: ${userId}`; +}; + +/** + * Returns memoized process-related values for the session preview component + */ +export const useProcessData = () => { + const { getFieldsData } = useRightPanelContext(); + + return useMemo( + () => ({ + userName: getUserDisplayName(getFieldsData), + processName: getField(getFieldsData(FIELD_PROCESS_NAME)), + startAt: getField(getFieldsData(FIELD_START_AT)), + ruleName: getField(getFieldsData(FIELD_RULE_NAME)), + ruleId: getField(getFieldsData(FIELD_RULE_ID)), + workdir: getField(getFieldsData(FIELD_WORKING_DIRECTORY)), + command: getField(getFieldsData(FIELD_COMMAND)), + }), + [getFieldsData] + ); +}; diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/integration_tests/console_commands_definition.test.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/integration_tests/console_commands_definition.test.tsx index 4b1d26a6e243f..25211b3ce136c 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/integration_tests/console_commands_definition.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/integration_tests/console_commands_definition.test.tsx @@ -65,6 +65,7 @@ describe('When displaying Endpoint Response Actions', () => { 'suspend-process --pid', 'get-file --path', 'execute --command', + 'upload --file', ]); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/response_actions/view/response_actions_list_page.test.tsx b/x-pack/plugins/security_solution/public/management/pages/response_actions/view/response_actions_list_page.test.tsx index 5a98765b46d44..ebde74b17be76 100644 --- a/x-pack/plugins/security_solution/public/management/pages/response_actions/view/response_actions_list_page.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/response_actions/view/response_actions_list_page.test.tsx @@ -423,7 +423,7 @@ describe('Response actions history page', () => { }); expect(history.location.search).toEqual( - '?commands=isolate%2Crelease%2Ckill-process%2Csuspend-process%2Cprocesses%2Cget-file%2Cexecute' + '?commands=isolate%2Crelease%2Ckill-process%2Csuspend-process%2Cprocesses%2Cget-file%2Cexecute%2Cupload' ); });