diff --git a/src/platform/packages/shared/kbn-unified-doc-viewer/src/services/types.ts b/src/platform/packages/shared/kbn-unified-doc-viewer/src/services/types.ts index 4801f0143f83d..9d82594cbee32 100644 --- a/src/platform/packages/shared/kbn-unified-doc-viewer/src/services/types.ts +++ b/src/platform/packages/shared/kbn-unified-doc-viewer/src/services/types.ts @@ -8,7 +8,7 @@ */ import type { DataView } from '@kbn/data-views-plugin/public'; -import type { AggregateQuery, Query } from '@kbn/es-query'; +import type { AggregateQuery, Query, TimeRange } from '@kbn/es-query'; import type { DataTableRecord, DataTableColumnsMeta } from '@kbn/discover-utils/types'; import type { RestorableStateProviderProps } from '@kbn/restorable-state'; import type { ReactElement } from 'react'; @@ -43,6 +43,15 @@ export type DocViewFilterFn = ( mode: '+' | '-' ) => void; +export interface DocViewActions { + openInNewTab?: (params: { + query?: Query | AggregateQuery; + tabLabel?: string; + timeRange?: TimeRange; + }) => void; + updateESQLQuery?: (queryOrUpdater: string | ((prevQuery: string) => string)) => void; +} + export interface DocViewRenderProps { hit: DataTableRecord; dataView: DataView; diff --git a/src/platform/packages/shared/kbn-unified-doc-viewer/src/types.ts b/src/platform/packages/shared/kbn-unified-doc-viewer/src/types.ts index 5c8fcec8963a6..171c94dbfd6c9 100644 --- a/src/platform/packages/shared/kbn-unified-doc-viewer/src/types.ts +++ b/src/platform/packages/shared/kbn-unified-doc-viewer/src/types.ts @@ -9,6 +9,7 @@ export type { DocView, + DocViewActions, DocViewFilterFn, DocViewRenderProps, DocViewerComponent, diff --git a/src/platform/packages/shared/kbn-unified-doc-viewer/types.ts b/src/platform/packages/shared/kbn-unified-doc-viewer/types.ts index d19472c020c48..c52f1253ef6b1 100644 --- a/src/platform/packages/shared/kbn-unified-doc-viewer/types.ts +++ b/src/platform/packages/shared/kbn-unified-doc-viewer/types.ts @@ -8,4 +8,10 @@ */ export type { ElasticRequestState } from '.'; -export type { DocViewFilterFn, DocViewRenderProps, DocView, DocViewerComponent } from './src/types'; +export type { + DocViewFilterFn, + DocViewRenderProps, + DocView, + DocViewerComponent, + DocViewActions, +} from './src/types'; diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/observability/log_document_profile/accessors/get_doc_viewer.tsx b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/observability/log_document_profile/accessors/get_doc_viewer.tsx index f110fa1e37c44..b473f8861a5a0 100644 --- a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/observability/log_document_profile/accessors/get_doc_viewer.tsx +++ b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/observability/log_document_profile/accessors/get_doc_viewer.tsx @@ -18,7 +18,7 @@ import { UnifiedDocViewerLogsOverview, type UnifiedDocViewerLogsOverviewApi, } from '@kbn/unified-doc-viewer-plugin/public'; -import type { DocViewRenderProps } from '@kbn/unified-doc-viewer/types'; +import type { DocViewRenderProps, DocViewActions } from '@kbn/unified-doc-viewer/types'; import React, { useEffect, useRef, useState } from 'react'; import type { BehaviorSubject } from 'rxjs'; import { filter, skip } from 'rxjs'; @@ -67,6 +67,7 @@ export const createGetDocViewer = logsAIInsightFeature={logsAIInsightFeature} streamsFeature={streamsFeature} indexes={indexes} + docViewActions={params.actions} {...props} /> ); @@ -84,6 +85,7 @@ interface LogOverviewTabProps extends DocViewRenderProps { logsAIInsightFeature: ObservabilityLogsAIInsightFeature | undefined; streamsFeature: ObservabilityStreamsFeature | undefined; indexes: ObservabilityIndexes; + docViewActions?: DocViewActions; } const LogOverviewTab = ({ @@ -92,6 +94,7 @@ const LogOverviewTab = ({ logsAIInsightFeature, streamsFeature, indexes, + docViewActions, ...props }: LogOverviewTabProps) => { const [logsOverviewApi, setLogsOverviewApi] = useState( @@ -102,6 +105,7 @@ const LogOverviewTab = ({ return ( ( - + ), }); diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/observability/traces_document_profile/document_profile/accessors/doc_viewer.tsx b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/observability/traces_document_profile/document_profile/accessors/doc_viewer.tsx index 6c5602f830e31..55c5beb026f90 100644 --- a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/observability/traces_document_profile/document_profile/accessors/doc_viewer.tsx +++ b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/observability/traces_document_profile/document_profile/accessors/doc_viewer.tsx @@ -31,7 +31,11 @@ export const createGetDocViewer = title: tabTitle, order: 0, render: (props) => ( - + ), }); diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/content_framework/section/section.test.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/content_framework/section/section.test.tsx index 742e778e5c2c8..24e452962ddef 100644 --- a/src/platform/plugins/shared/unified_doc_viewer/public/components/content_framework/section/section.test.tsx +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/content_framework/section/section.test.tsx @@ -8,7 +8,8 @@ */ import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, createEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; import userEvent from '@testing-library/user-event'; import { ContentFrameworkSection, type ContentFrameworkSectionProps } from './section'; @@ -71,6 +72,83 @@ describe('ContentFrameworkSection', () => { expect(defaultProps.actions?.[1].onClick).toHaveBeenCalled(); }); + it('prefers onClick over href on plain left click', () => { + const onClick = jest.fn(); + render( + + ); + + const button = screen.getByTestId('unifiedDocViewerSectionActionButton-openInDiscover'); + const clickEvent = createEvent.click(button, { button: 0 }); + fireEvent(button, clickEvent); + + expect(onClick).toHaveBeenCalledTimes(1); + expect(clickEvent.defaultPrevented).toBe(true); + }); + + it('does not intercept modifier click when href is present', () => { + const onClick = jest.fn(); + render( + + ); + + const button = screen.getByTestId('unifiedDocViewerSectionActionButton-openInDiscover'); + const ctrlClickEvent = createEvent.click(button, { button: 0, ctrlKey: true }); + fireEvent(button, ctrlClickEvent); + + expect(onClick).not.toHaveBeenCalled(); + expect(ctrlClickEvent.defaultPrevented).toBe(false); + }); + + it('does not intercept middle click when href is present', () => { + const onClick = jest.fn(); + render( + + ); + + const button = screen.getByTestId('unifiedDocViewerSectionActionButton-openInDiscoverIcon'); + const middleClickEvent = createEvent.click(button, { button: 1 }); + fireEvent(button, middleClickEvent); + + expect(onClick).not.toHaveBeenCalled(); + expect(middleClickEvent.defaultPrevented).toBe(false); + }); + it('renders children inside the panel', () => { render(); expect(screen.getByText('Section children')).toBeInTheDocument(); diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/content_framework/section/section_actions.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/content_framework/section/section_actions.tsx index dc0bc7885cf9c..0017659ac71e1 100644 --- a/src/platform/plugins/shared/unified_doc_viewer/public/components/content_framework/section/section_actions.tsx +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/content_framework/section/section_actions.tsx @@ -20,13 +20,17 @@ interface BaseAction { } export type Action = - | (BaseAction & { onClick: () => void; href?: never }) - | (BaseAction & { href: string; onClick?: never }); + | (BaseAction & { onClick: () => void; href?: string }) + | (BaseAction & { href: string; onClick?: () => void }); export interface SectionActionsProps { actions: Action[]; } +function isPlainLeftClick(e: React.MouseEvent) { + return e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey; +} + export const SectionActions = ({ actions }: SectionActionsProps) => { if (!actions.length) return null; const size = 'xs'; @@ -35,7 +39,20 @@ export const SectionActions = ({ actions }: SectionActionsProps) => { {actions.map((action, idx) => { const { icon, ariaLabel, dataTestSubj, label, onClick, href } = action; - const buttonProps = onClick ? { onClick } : { href }; + const handleClick = onClick + ? (e: React.MouseEvent) => { + // If we have an href, keep native link behaviour for right clicks and modifier clicks. + // Plain left click should run the provided handler instead. + if (href && !isPlainLeftClick(e)) return; + if (href) e.preventDefault(); + onClick(); + } + : undefined; + + const buttonProps = { + href, + onClick: handleClick, + }; return ( diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_logs_overview/logs_overview.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_logs_overview/logs_overview.tsx index bd8908471ac63..f620b61cd9fbd 100644 --- a/src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_logs_overview/logs_overview.tsx +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_logs_overview/logs_overview.tsx @@ -25,6 +25,7 @@ import type { import type { LogDocument, ObservabilityIndexes } from '@kbn/discover-utils/src'; import { getStacktraceFields } from '@kbn/discover-utils/src'; import { css } from '@emotion/react'; +import type { DocViewActions } from '@kbn/unified-doc-viewer/src/services/types'; import { LogsOverviewHeader } from './logs_overview_header'; import { FieldActionsProvider } from '../../hooks/use_field_actions'; import { getUnifiedDocViewerServices } from '../../plugin'; @@ -39,6 +40,7 @@ import { TraceWaterfall } from '../observability/traces/components/trace_waterfa import { DataSourcesProvider } from '../../hooks/use_data_sources'; import { SimilarErrors } from './sub_components/similar_errors'; import { hasErrorFields } from './utils/has_error_fields'; +import { DocViewerExtensionActionsProvider } from '../../hooks/use_doc_viewer_extension_actions'; export type LogsOverviewProps = DocViewRenderProps & { renderAIAssistant?: ObservabilityLogsAIAssistantFeature['render']; @@ -47,6 +49,7 @@ export type LogsOverviewProps = DocViewRenderProps & { renderFlyoutStreamProcessingLink?: ObservabilityStreamsFeature['renderFlyoutStreamProcessingLink']; indexes: ObservabilityIndexes; showTraceWaterfall?: boolean; + docViewActions?: DocViewActions; }; export interface LogsOverviewApi { @@ -69,6 +72,7 @@ export const LogsOverview = forwardRef( renderFlyoutStreamProcessingLink, indexes, showTraceWaterfall = true, + docViewActions, }, ref ) => { @@ -131,24 +135,28 @@ export const LogsOverview = forwardRef( dataView={dataView} /> - {showSimilarErrors ? : null} -
{renderFlyoutStreamField && renderFlyoutStreamField({ dataView, doc: hit })}
- - {isStacktraceAvailable && ( - - )} - {traceId && showTraceWaterfall ? ( - - ) : null} + + {showSimilarErrors ? : null} +
+ {renderFlyoutStreamField && renderFlyoutStreamField({ dataView, doc: hit })} +
+ + {isStacktraceAvailable && ( + + )} + {traceId && showTraceWaterfall ? ( + + ) : null} +
{LogsOverviewAIAssistant && } diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_logs_overview/sub_components/similar_errors/index.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_logs_overview/sub_components/similar_errors/index.tsx index 20fd28e0c0b46..caa28f523c8d0 100644 --- a/src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_logs_overview/sub_components/similar_errors/index.tsx +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_logs_overview/sub_components/similar_errors/index.tsx @@ -17,12 +17,12 @@ import { } from '@kbn/discover-utils'; import { getFieldValueWithFallback } from '@kbn/discover-utils/src/utils'; import { ContentFrameworkSection } from '../../../content_framework/lazy_content_framework_section'; -import type { ContentFrameworkSectionProps } from '../../../content_framework/section/section'; import { useDataSourcesContext } from '../../../../hooks/use_data_sources'; -import { useGetGenerateDiscoverLink } from '../../../../hooks/use_generate_discover_link'; import { getEsqlQuery } from './get_esql_query'; import { SimilarErrorsOccurrencesChart } from './similar_errors_occurrences_chart'; import { buildSectionDescription, type FieldInfo } from './build_section_description'; +import { useDiscoverLinkAndEsqlQuery } from '../../../../hooks/use_discover_link_and_esql_query'; +import { useOpenInDiscoverSectionAction } from '../../../../hooks/use_open_in_discover_section_action'; const createFieldInfo = (value: unknown, field: string | undefined): FieldInfo | undefined => { return value && field ? { value, field } : undefined; @@ -35,22 +35,12 @@ const sectionTitle = i18n.translate( } ); -const discoverBtnLabel = i18n.translate( - 'unifiedDocViewer.docViewerLogsOverview.subComponents.similarErrors.openInDiscover.button', - { defaultMessage: 'Open in Discover' } -); -const discoverBtnAria = i18n.translate( - 'unifiedDocViewer.observability.traces.similarErrors.openInDiscover.label', - { defaultMessage: 'Open in Discover link' } -); - export interface SimilarErrorsProps { hit: DataTableRecord; } export function SimilarErrors({ hit }: SimilarErrorsProps) { const { indexes } = useDataSourcesContext(); - const { generateDiscoverLink } = useGetGenerateDiscoverLink({ indexPattern: indexes.logs }); const hitFlattened = hit.flattened; const { field: serviceNameField, value: serviceNameValue } = getFieldValueWithFallback( hitFlattened, @@ -98,41 +88,39 @@ export function SimilarErrors({ hit }: SimilarErrorsProps) { ] ); - const esqlQuery = getEsqlQuery({ - serviceName: serviceNameValue ? String(serviceNameValue) : undefined, - culprit: culpritValue ? String(culpritValue) : undefined, - message: - messageValue && messageField - ? { fieldName: messageField, value: String(messageValue) } - : undefined, - type: - typeValue && typeField - ? { - fieldName: typeField, - value: Array.isArray(typeValue) ? typeValue.map(String) : String(typeValue), - } - : undefined, + const esqlQueryWhereClause = useMemo(() => { + return getEsqlQuery({ + serviceName: serviceNameValue ? String(serviceNameValue) : undefined, + culprit: culpritValue ? String(culpritValue) : undefined, + message: + messageValue && messageField + ? { fieldName: messageField, value: String(messageValue) } + : undefined, + type: + typeValue && typeField + ? { + fieldName: typeField, + value: Array.isArray(typeValue) ? typeValue.map(String) : String(typeValue), + } + : undefined, + }); + }, [serviceNameValue, culpritValue, messageValue, messageField, typeValue, typeField]); + + const { discoverUrl, esqlQueryString } = useDiscoverLinkAndEsqlQuery({ + indexPattern: indexes.logs, + whereClause: esqlQueryWhereClause, }); - const discoverUrl = useMemo( - () => generateDiscoverLink(esqlQuery), - [generateDiscoverLink, esqlQuery] - ); + const openInDiscoverSectionAction = useOpenInDiscoverSectionAction({ + href: discoverUrl, + esql: esqlQueryString, + tabLabel: sectionTitle, + dataTestSubj: 'docViewerSimilarErrorsOpenInDiscoverButton', + }); - const sectionActions: ContentFrameworkSectionProps['actions'] = useMemo( - () => - discoverUrl - ? [ - { - dataTestSubj: 'docViewerSimilarErrorsOpenInDiscoverButton', - label: discoverBtnLabel, - href: discoverUrl, - icon: 'discoverApp', - ariaLabel: discoverBtnAria, - }, - ] - : [], - [discoverUrl] + const actions = useMemo( + () => (openInDiscoverSectionAction ? [openInDiscoverSectionAction] : []), + [openInDiscoverSectionAction] ); const hasAtLeastOneErrorField = culpritValue || messageValue || typeValue; @@ -145,11 +133,11 @@ export function SimilarErrors({ hit }: SimilarErrorsProps) { id="similarErrors" data-test-subj="docViewerSimilarErrorsSection" title={sectionTitle} - actions={sectionActions} + actions={actions} description={sectionDescription} >
diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/generic/doc_viewer_overview/overview.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/generic/doc_viewer_overview/overview.tsx index 1f4f9c509ac74..bc1d17164b4df 100644 --- a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/generic/doc_viewer_overview/overview.tsx +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/generic/doc_viewer_overview/overview.tsx @@ -14,6 +14,7 @@ import type { DocViewRenderProps } from '@kbn/unified-doc-viewer/types'; import React, { useMemo, useState } from 'react'; import { css } from '@emotion/react'; import { SERVICE_NAME, SPAN_ID, TRACE_ID, TRANSACTION_ID } from '@kbn/apm-types'; +import type { DocViewActions } from '@kbn/unified-doc-viewer/src/services/types'; import { DataSourcesProvider } from '../../../../hooks/use_data_sources'; import { getTabContentAvailableHeight, @@ -25,11 +26,13 @@ import { TraceWaterfall } from '../../traces/components/trace_waterfall'; import { ErrorsTable } from '../../traces/components/errors'; import { TraceContextLogEvents } from '../../traces/components/trace_context_log_events'; import { isTransaction } from '../../traces/helpers'; +import { DocViewerExtensionActionsProvider } from '../../../../hooks/use_doc_viewer_extension_actions'; export type OverviewProps = DocViewRenderProps & { indexes: ObservabilityIndexes; showWaterfall?: boolean; showActions?: boolean; + docViewActions?: DocViewActions; }; export function Overview({ @@ -41,6 +44,7 @@ export function Overview({ showWaterfall = true, dataView, decreaseAvailableHeightBy = DEFAULT_MARGIN_BOTTOM, + docViewActions, }: OverviewProps) { const [containerRef, setContainerRef] = useState(null); const flattenedHit = useMemo(() => getFlattenedTraceDocumentOverview(hit), [hit]); @@ -59,41 +63,47 @@ export function Overview({ return ( - -
- - - - {showWaterfall ? ( - + +
+ + + + {showWaterfall ? ( + + ) : null} + + + + - ) : null} - - - - -
-
+
+
+
); } diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/errors/errors_table.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/errors/errors_table.tsx index b75601bd2a7d5..2b642b3b1f682 100644 --- a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/errors/errors_table.tsx +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/errors/errors_table.tsx @@ -25,12 +25,20 @@ import { getColumns } from './get_columns'; import { useFetchErrorsByTraceId } from './use_fetch_errors_by_trace_id'; import { useDataSourcesContext } from '../../../../../hooks/use_data_sources'; import { useGetGenerateDiscoverLink } from '../../../../../hooks/use_generate_discover_link'; -import { OPEN_IN_DISCOVER_LABEL, OPEN_IN_DISCOVER_ARIA_LABEL } from '../../common/constants'; import { createTraceContextWhereClauseForErrors } from '../../common/create_trace_context_where_clause'; import { ScrollableSectionWrapper, type ScrollableSectionWrapperApi, } from '../../../../doc_viewer_logs_overview/scrollable_section_wrapper'; +import { useDiscoverLinkAndEsqlQuery } from '../../../../../hooks/use_discover_link_and_esql_query'; +import { useOpenInDiscoverSectionAction } from '../../../../../hooks/use_open_in_discover_section_action'; + +const sectionTitle = i18n.translate( + 'unifiedDocViewer.observability.traces.docViewerSpanOverview.errors', + { + defaultMessage: 'Errors', + } +); export interface Props { traceId: string; @@ -53,14 +61,26 @@ export const ErrorsTable = forwardRef( docId, }); - const { columns, openInDiscoverLink } = useMemo(() => { - const cols = getColumns({ traceId, docId, generateDiscoverLink, source: response.source }); + const { discoverUrl, esqlQueryString } = useDiscoverLinkAndEsqlQuery({ + indexPattern: indexes.apm.errors, + whereClause: createTraceContextWhereClauseForErrors({ traceId, spanId: docId }), + }); - const link = generateDiscoverLink( - createTraceContextWhereClauseForErrors({ traceId, spanId: docId }) - ); + const openInDiscoverSectionAction = useOpenInDiscoverSectionAction({ + href: discoverUrl, + esql: esqlQueryString, + tabLabel: sectionTitle, + dataTestSubj: 'docViewerErrorsOpenInDiscoverButton', + }); + const actions = useMemo( + () => (openInDiscoverSectionAction ? [openInDiscoverSectionAction] : []), + [openInDiscoverSectionAction] + ); - return { columns: cols, openInDiscoverLink: link }; + const { columns } = useMemo(() => { + const cols = getColumns({ traceId, docId, generateDiscoverLink, source: response.source }); + + return { columns: cols }; }, [traceId, docId, generateDiscoverLink, response.source]); if (loading || (!error && response.traceErrors.length === 0)) { @@ -73,29 +93,12 @@ export const ErrorsTable = forwardRef( {error ? ( diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/logs_flyout/index.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/logs_flyout/index.tsx index d125fc2bb5655..c6e092588ff2a 100644 --- a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/logs_flyout/index.tsx +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/logs_flyout/index.tsx @@ -12,7 +12,7 @@ import type { DocViewRenderProps } from '@kbn/unified-doc-viewer/types'; import React from 'react'; import LogsOverview from '../../../../../../doc_viewer_logs_overview'; import { useDataSourcesContext } from '../../../../../../../hooks/use_data_sources'; - +import { useDocViewerExtensionActionsContext } from '../../../../../../../hooks/use_doc_viewer_extension_actions'; export { useLogFlyoutData } from './use_log_flyout_data'; export type { UseLogFlyoutDataParams, LogFlyoutData } from './use_log_flyout_data'; @@ -25,8 +25,15 @@ export interface LogFlyoutContentProps { export function LogFlyoutContent({ hit, logDataView }: LogFlyoutContentProps) { const { indexes } = useDataSourcesContext(); + const actions = useDocViewerExtensionActionsContext(); return ( - + ); } diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/span_flyout/index.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/span_flyout/index.tsx index 2900bd2740058..5dc6ed9da0f59 100644 --- a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/span_flyout/index.tsx +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/span_flyout/index.tsx @@ -13,7 +13,7 @@ import React, { useEffect, useState } from 'react'; import type { OverviewApi } from '../../../../doc_viewer_overview/overview'; import { Overview, type TraceOverviewSections } from '../../../../doc_viewer_overview/overview'; import { useDataSourcesContext } from '../../../../../../../hooks/use_data_sources'; - +import { useDocViewerExtensionActionsContext } from '../../../../../../../hooks/use_doc_viewer_extension_actions'; export { useSpanFlyoutData } from './use_span_flyout_data'; export type { UseSpanFlyoutDataParams, SpanFlyoutData } from './use_span_flyout_data'; @@ -28,6 +28,7 @@ export interface SpanFlyoutContentProps { export function SpanFlyoutContent({ hit, dataView, activeSection }: SpanFlyoutContentProps) { const { indexes } = useDataSourcesContext(); const [flyoutRef, setFlyoutRef] = useState(null); + const actions = useDocViewerExtensionActionsContext(); useEffect(() => { if (activeSection && flyoutRef) { @@ -38,6 +39,7 @@ export function SpanFlyoutContent({ hit, dataView, activeSection }: SpanFlyoutCo return ( generateDiscoverLink(esqlQuery), - [generateDiscoverLink, esqlQuery] - ); + const openInDiscoverSectionAction = useOpenInDiscoverSectionAction({ + href: discoverUrl, + esql: esqlQueryString, + tabLabel: sectionTitle, + dataTestSubj: 'docViewerSimilarSpansOpenInDiscoverButton', + }); - const sectionActions: ContentFrameworkSectionProps['actions'] = useMemo( - () => - discoverUrl - ? [ - { - dataTestSubj: 'docViewerSimilarSpansOpenInDiscoverButton', - label: discoverBtnLabel, - href: discoverUrl, - icon: 'discoverApp', - ariaLabel: discoverBtnAria, - }, - ] - : [], - [, discoverUrl] + const actions = useMemo( + () => (openInDiscoverSectionAction ? [openInDiscoverSectionAction] : []), + [openInDiscoverSectionAction] ); return ( @@ -92,7 +77,7 @@ export function SimilarSpans({ id="similarSpans" data-test-subj="docViewerSimilarSpansSection" title={sectionTitle} - actions={sectionActions} + actions={actions} > { + const whereClause = useMemo(() => { if (type === 'incoming') { - return generateDiscoverLink(getIncomingSpanLinksESQL(traceId, docId)); + return getIncomingSpanLinksESQL(traceId, docId); } if (spanLinks.length) { - return generateDiscoverLink(getOutgoingSpanLinksESQL(spanLinks)); + return getOutgoingSpanLinksESQL(spanLinks); } - }, [docId, generateDiscoverLink, spanLinks, traceId, type]); + }, [docId, spanLinks, traceId, type]); + + const { discoverUrl, esqlQueryString } = useDiscoverLinkAndEsqlQuery({ + indexPattern: indexes.apm.traces, + whereClause, + }); + + const openInDiscoverSectionAction = useOpenInDiscoverSectionAction({ + href: discoverUrl, + esql: esqlQueryString, + tabLabel: sectionTitle, + dataTestSubj: 'docViewerSpanLinksOpenInDiscoverButton', + }); + const actions = useMemo( + () => (openInDiscoverSectionAction ? [openInDiscoverSectionAction] : []), + [openInDiscoverSectionAction] + ); if ( loading || @@ -124,27 +146,12 @@ export function SpanLinks({ docId, traceId, processorEvent }: Props) { {error ? ( @@ -197,10 +204,7 @@ export function SpanLinks({ docId, traceId, processorEvent }: Props) { ); } -export function getIncomingSpanLinksESQL( - traceId: string, - docId: string -): Record | undefined { +export function getIncomingSpanLinksESQL(traceId: string, docId: string) { return where( `QSTR("${OTEL_LINKS_TRACE_ID}:${traceId} AND ${OTEL_LINKS_SPAN_ID}:${docId}") OR QSTR("${SPAN_LINKS_TRACE_ID}:${traceId} AND ${SPAN_LINKS_SPAN_ID}:${docId}")` ); diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/trace_context_log_events/index.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/trace_context_log_events/index.tsx index b82def1c3ce32..37f1b64605b79 100644 --- a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/trace_context_log_events/index.tsx +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/trace_context_log_events/index.tsx @@ -13,9 +13,9 @@ import { ContentFrameworkSection } from '../../../../content_framework/lazy_cont import { getUnifiedDocViewerServices } from '../../../../../plugin'; import { useDataSourcesContext } from '../../../../../hooks/use_data_sources'; import { useLogsQuery } from '../../hooks/use_logs_query'; -import { useGetGenerateDiscoverLink } from '../../../../../hooks/use_generate_discover_link'; import { createTraceContextWhereClause } from '../../common/create_trace_context_where_clause'; -import { OPEN_IN_DISCOVER_LABEL, OPEN_IN_DISCOVER_ARIA_LABEL } from '../../common/constants'; +import { useDiscoverLinkAndEsqlQuery } from '../../../../../hooks/use_discover_link_and_esql_query'; +import { useOpenInDiscoverSectionAction } from '../../../../../hooks/use_open_in_discover_section_action'; const logsTitle = i18n.translate('unifiedDocViewer.observability.traces.section.logs.title', { defaultMessage: 'Logs', @@ -41,7 +41,6 @@ export function TraceContextLogEvents({ const { data: dataService, discoverShared } = getUnifiedDocViewerServices(); const { indexes } = useDataSourcesContext(); const { from, to } = dataService.query.timefilter.timefilter.getTime(); - const { generateDiscoverLink } = useGetGenerateDiscoverLink({ indexPattern: indexes.logs }); const timeRange = useMemo(() => ({ from, to }), [from, to]); const query = useLogsQuery({ traceId, spanId, transactionId }); @@ -54,9 +53,22 @@ export function TraceContextLogEvents({ [timeRange.from, timeRange.to] ); - const openInDiscoverLink = useMemo(() => { - return generateDiscoverLink(createTraceContextWhereClause({ traceId, spanId, transactionId })); - }, [generateDiscoverLink, traceId, spanId, transactionId]); + const { discoverUrl, esqlQueryString } = useDiscoverLinkAndEsqlQuery({ + indexPattern: indexes.logs, + whereClause: createTraceContextWhereClause({ traceId, spanId, transactionId }), + }); + + const openInDiscoverSectionAction = useOpenInDiscoverSectionAction({ + href: discoverUrl, + esql: esqlQueryString, + tabLabel: logsTitle, + dataTestSubj: 'unifiedDocViewerLogsOpenInDiscoverButton', + }); + + const actions = useMemo( + () => (openInDiscoverSectionAction ? [openInDiscoverSectionAction] : []), + [openInDiscoverSectionAction] + ); const LogEvents = discoverShared.features.registry.getById('observability-log-events'); @@ -72,19 +84,7 @@ export function TraceContextLogEvents({ description={logsDescription} id="traceContextLogEvents" forceState="closed" - actions={ - openInDiscoverLink - ? [ - { - icon: 'discoverApp', - label: OPEN_IN_DISCOVER_LABEL, - ariaLabel: OPEN_IN_DISCOVER_ARIA_LABEL, - href: openInDiscoverLink, - dataTestSubj: 'unifiedDocViewerLogsOpenInDiscoverButton', - }, - ] - : undefined - } + actions={actions} >
{ - return generateDiscoverLink({ [TRACE_ID_FIELD]: traceId }); - }, [generateDiscoverLink, traceId]); - + const openInDiscoverSectionAction = useOpenInDiscoverSectionAction({ + href: discoverUrl, + esql: esqlQueryString, + tabLabel: sectionTitle, + dataTestSubj: 'unifiedDocViewerObservabilityTracesOpenInDiscoverButton', + }); const actionId = 'traceWaterfallFullScreenAction'; + const actions = useMemo( + () => [ + { + icon: 'fullScreen', + onClick: () => setShowFullScreenWaterfall(true), + label: fullScreenButtonLabel, + ariaLabel: fullScreenButtonLabel, + id: actionId, + dataTestSubj: 'unifiedDocViewerObservabilityTracesTraceFullScreenButton', + }, + ...(openInDiscoverSectionAction ? [openInDiscoverSectionAction] : []), + ], + [openInDiscoverSectionAction] + ); + if (!FocusedTraceWaterfall) return null; return ( @@ -80,27 +99,7 @@ export function TraceWaterfall({ traceId, docId, serviceName, dataView }: Props) id="trace-waterfall" title={sectionTitle} description={sectionTip} - actions={[ - { - icon: 'fullScreen', - onClick: () => setShowFullScreenWaterfall(true), - label: fullScreenButtonLabel, - ariaLabel: fullScreenButtonLabel, - id: actionId, - dataTestSubj: 'unifiedDocViewerObservabilityTracesTraceFullScreenButton', - }, - ...(openInDiscoverLink - ? [ - { - icon: 'discoverApp', - label: OPEN_IN_DISCOVER_LABEL, - ariaLabel: OPEN_IN_DISCOVER_ARIA_LABEL, - href: openInDiscoverLink, - dataTestSubj: 'unifiedDocViewerObservabilityTracesOpenInDiscoverButton', - }, - ] - : []), - ]} + actions={actions} > {docId ? ( ( showWaterfall = true, dataView, decreaseAvailableHeightBy = DEFAULT_MARGIN_BOTTOM, + docViewActions, }, ref ) => { @@ -104,52 +108,54 @@ export const Overview = forwardRef( return ( - -
- - - - - {showWaterfall ? ( - + +
+ + + + + {showWaterfall ? ( + + ) : null} + + - ) : null} - - - {docId ? : null} -
-
+ {docId ? : null} +
+
+
); } diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/hooks/use_discover_link_and_esql_query/index.ts b/src/platform/plugins/shared/unified_doc_viewer/public/hooks/use_discover_link_and_esql_query/index.ts new file mode 100644 index 0000000000000..2f0a0d22b706b --- /dev/null +++ b/src/platform/plugins/shared/unified_doc_viewer/public/hooks/use_discover_link_and_esql_query/index.ts @@ -0,0 +1,32 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { from, type QueryOperator } from '@kbn/esql-composer'; +import { useGetGenerateDiscoverLink } from '../use_generate_discover_link'; + +export interface UseDiscoverLinkAndEsqlQueryParams { + indexPattern?: string; + whereClause?: QueryOperator; +} + +export function useDiscoverLinkAndEsqlQuery({ + indexPattern, + whereClause, +}: UseDiscoverLinkAndEsqlQueryParams) { + const { generateDiscoverLink } = useGetGenerateDiscoverLink({ indexPattern }); + + if (!indexPattern || !whereClause) { + return { discoverUrl: undefined, esqlQueryString: undefined }; + } + + const esqlQueryString = from(indexPattern).pipe(whereClause).toString(); + const discoverUrl = generateDiscoverLink(whereClause); + + return { discoverUrl, esqlQueryString }; +} diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/hooks/use_discover_link_and_esql_query/use_discover_link_and_esql_query.test.ts b/src/platform/plugins/shared/unified_doc_viewer/public/hooks/use_discover_link_and_esql_query/use_discover_link_and_esql_query.test.ts new file mode 100644 index 0000000000000..c4a54eca34e91 --- /dev/null +++ b/src/platform/plugins/shared/unified_doc_viewer/public/hooks/use_discover_link_and_esql_query/use_discover_link_and_esql_query.test.ts @@ -0,0 +1,52 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { renderHook } from '@testing-library/react'; +import { from, where } from '@kbn/esql-composer'; +import { useDiscoverLinkAndEsqlQuery } from '.'; +import { useGetGenerateDiscoverLink } from '../use_generate_discover_link'; + +jest.mock('../use_generate_discover_link', () => ({ + useGetGenerateDiscoverLink: jest.fn(), +})); + +describe('useDiscoverLinkAndEsqlQuery', () => { + const mockUseGetGenerateDiscoverLink = jest.mocked(useGetGenerateDiscoverLink); + + beforeEach(() => { + mockUseGetGenerateDiscoverLink.mockReset(); + }); + + it('returns undefined values when indexPattern or whereClause are missing', () => { + const generateDiscoverLink = jest.fn(() => 'http://discover/url'); + mockUseGetGenerateDiscoverLink.mockReturnValue({ generateDiscoverLink }); + + const { result } = renderHook(() => + useDiscoverLinkAndEsqlQuery({ indexPattern: undefined, whereClause: undefined }) + ); + + expect(result.current).toEqual({ discoverUrl: undefined, esqlQueryString: undefined }); + expect(generateDiscoverLink).not.toHaveBeenCalled(); + }); + + it('returns discoverUrl and esqlQueryString when inputs are provided', () => { + const DISCOVER_URL = 'http://discover/url'; + const generateDiscoverLink = jest.fn(() => DISCOVER_URL); + mockUseGetGenerateDiscoverLink.mockReturnValue({ generateDiscoverLink }); + + const indexPattern = 'traces-*'; + const whereClause = where('trace.id == ?traceId', { traceId: 'abc123' }); + + const { result } = renderHook(() => useDiscoverLinkAndEsqlQuery({ indexPattern, whereClause })); + + expect(generateDiscoverLink).toHaveBeenCalledWith(whereClause); + expect(result.current.discoverUrl).toBe(DISCOVER_URL); + expect(result.current.esqlQueryString).toBe(from(indexPattern).pipe(whereClause).toString()); + }); +}); diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/hooks/use_doc_viewer_extension_actions/index.ts b/src/platform/plugins/shared/unified_doc_viewer/public/hooks/use_doc_viewer_extension_actions/index.ts new file mode 100644 index 0000000000000..fd17849a9ef79 --- /dev/null +++ b/src/platform/plugins/shared/unified_doc_viewer/public/hooks/use_doc_viewer_extension_actions/index.ts @@ -0,0 +1,21 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ +import createContainer from 'constate'; +import type { DocViewActions } from '@kbn/unified-doc-viewer/src/services/types'; + +interface UseDocViewerExtensionActionsParams { + actions?: DocViewActions; +} + +const useDocViewerExtensionActions = ({ actions }: UseDocViewerExtensionActionsParams) => { + return actions; +}; + +export const [DocViewerExtensionActionsProvider, useDocViewerExtensionActionsContext] = + createContainer(useDocViewerExtensionActions); diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/hooks/use_open_in_discover_section_action/index.ts b/src/platform/plugins/shared/unified_doc_viewer/public/hooks/use_open_in_discover_section_action/index.ts new file mode 100644 index 0000000000000..b0c7bcca21917 --- /dev/null +++ b/src/platform/plugins/shared/unified_doc_viewer/public/hooks/use_open_in_discover_section_action/index.ts @@ -0,0 +1,71 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { useCallback, useMemo } from 'react'; +import type { Action } from '../../components/content_framework/section/section_actions'; +import { + OPEN_IN_DISCOVER_LABEL, + OPEN_IN_DISCOVER_ARIA_LABEL, +} from '../../components/observability/traces/common/constants'; +import { useDocViewerExtensionActionsContext } from '../use_doc_viewer_extension_actions'; + +interface UseOpenInDiscoverSectionActionParams { + tabLabel: string; + dataTestSubj: string; + href?: string; + esql?: string; +} + +export function useOpenInDiscoverSectionAction( + params: UseOpenInDiscoverSectionActionParams +): Action | undefined { + const { href, esql, tabLabel, dataTestSubj } = params; + const actions = useDocViewerExtensionActionsContext(); + const openInNewTab = actions?.openInNewTab; + const canOpenInNewTab = openInNewTab && esql; + + const onClick = useCallback(() => { + if (canOpenInNewTab) { + openInNewTab({ + query: { esql }, + tabLabel, + }); + } + }, [canOpenInNewTab, openInNewTab, esql, tabLabel]); + + return useMemo(() => { + if (!href && !canOpenInNewTab) { + return undefined; + } + + const actionBase = { + dataTestSubj, + label: OPEN_IN_DISCOVER_LABEL, + icon: 'discoverApp', + ariaLabel: OPEN_IN_DISCOVER_ARIA_LABEL, + }; + + if (href) { + return { + ...actionBase, + href, + onClick: canOpenInNewTab ? onClick : undefined, + }; + } + + if (!canOpenInNewTab) { + return undefined; + } + + return { + ...actionBase, + onClick, + }; + }, [canOpenInNewTab, dataTestSubj, href, onClick]); +} diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/hooks/use_open_in_discover_section_action/use_open_in_discover_section_action.test.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/hooks/use_open_in_discover_section_action/use_open_in_discover_section_action.test.tsx new file mode 100644 index 0000000000000..e94d822046b96 --- /dev/null +++ b/src/platform/plugins/shared/unified_doc_viewer/public/hooks/use_open_in_discover_section_action/use_open_in_discover_section_action.test.tsx @@ -0,0 +1,151 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { renderHook } from '@testing-library/react'; +import { + OPEN_IN_DISCOVER_LABEL, + OPEN_IN_DISCOVER_ARIA_LABEL, +} from '../../components/observability/traces/common/constants'; +import { DocViewerExtensionActionsProvider } from '../use_doc_viewer_extension_actions'; +import { useOpenInDiscoverSectionAction } from '.'; + +describe('useOpenInDiscoverSectionAction', () => { + const tabLabel = 'Some section'; + const dataTestSubj = 'openInDiscover'; + + it('returns undefined when href is missing and openInNewTab cannot be used', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook( + () => + useOpenInDiscoverSectionAction({ + href: undefined, + esql: undefined, + tabLabel, + dataTestSubj, + }), + { wrapper } + ); + + expect(result.current).toBeUndefined(); + }); + + it('returns an href action without onClick when only href is provided', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const href = 'http://discover/url'; + const { result } = renderHook( + () => + useOpenInDiscoverSectionAction({ + href, + esql: undefined, + tabLabel, + dataTestSubj, + }), + { wrapper } + ); + + expect(result.current).toEqual( + expect.objectContaining({ + href, + label: OPEN_IN_DISCOVER_LABEL, + ariaLabel: OPEN_IN_DISCOVER_ARIA_LABEL, + icon: 'discoverApp', + dataTestSubj, + onClick: undefined, + }) + ); + }); + + it('returns an action with onClick (no href) when openInNewTab is available and esql is provided', () => { + const openInNewTab = jest.fn(); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const esql = 'FROM traces-* | WHERE trace.id == "abc123"'; + const { result } = renderHook( + () => + useOpenInDiscoverSectionAction({ + href: undefined, + esql, + tabLabel, + dataTestSubj, + }), + { wrapper } + ); + + expect(result.current).toEqual( + expect.objectContaining({ + label: OPEN_IN_DISCOVER_LABEL, + ariaLabel: OPEN_IN_DISCOVER_ARIA_LABEL, + icon: 'discoverApp', + dataTestSubj, + onClick: expect.any(Function), + }) + ); + + result.current?.onClick?.(); + + expect(openInNewTab).toHaveBeenCalledWith({ + query: { esql }, + tabLabel, + }); + }); + + it('returns an href action with onClick when href and openInNewTab+esql are provided', () => { + const openInNewTab = jest.fn(); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const href = 'http://discover/url'; + const esql = 'FROM traces-* | WHERE trace.id == "abc123"'; + const { result } = renderHook( + () => + useOpenInDiscoverSectionAction({ + href, + esql, + tabLabel, + dataTestSubj, + }), + { wrapper } + ); + + expect(result.current).toEqual( + expect.objectContaining({ + href, + label: OPEN_IN_DISCOVER_LABEL, + ariaLabel: OPEN_IN_DISCOVER_ARIA_LABEL, + icon: 'discoverApp', + dataTestSubj, + onClick: expect.any(Function), + }) + ); + + result.current?.onClick?.(); + expect(openInNewTab).toHaveBeenCalledWith({ + query: { esql }, + tabLabel, + }); + }); +});