From 90bc9ce26f858e251b11ece3bd01562aab0df28d Mon Sep 17 00:00:00 2001 From: Adam Tackett <105462877+TackAdam@users.noreply.github.com> Date: Fri, 6 Dec 2024 14:16:43 -0800 Subject: [PATCH] [Manual Backport 2.x ] 2257 to 2.x Traces - Gantt chart / Span list rework (#2283) * Traces - Gantt chart / Span list rework (#2257) * fix refresh on timepicker change, update gannt chart, mini map, span list nesting Signed-off-by: Adam Tackett * ui changes and new tab for spans Signed-off-by: Adam Tackett * ui updates, additional bug fixes Signed-off-by: Adam Tackett * add eui grid full screen, testing Signed-off-by: Adam Tackett * add back table height for tree, cap left margin Signed-off-by: Adam Tackett * add some jest testing Signed-off-by: Adam Tackett * address comments Signed-off-by: Adam Tackett * test Signed-off-by: Adam Tackett * test remove Signed-off-by: Adam Tackett * add cypress testing, conditional check for new nav Signed-off-by: Adam Tackett * fix width of tables Signed-off-by: Adam Tackett --------- Signed-off-by: Adam Tackett Co-authored-by: Adam Tackett (cherry picked from commit ab396b94a14f922220e5ec071bc4f696208650b3) * fix loaddash error, update snapshots Signed-off-by: Adam Tackett * add missing format change Signed-off-by: Adam Tackett --------- Signed-off-by: Adam Tackett Co-authored-by: Adam Tackett --- .../trace_analytics_traces.spec.js | 98 +- .cypress/utils/constants.js | 1 + .../__snapshots__/flyout.test.tsx.snap | 1006 +++++++++++------ .../flyout_components/trace_detail_render.tsx | 2 +- .../components/common/helper_functions.tsx | 39 +- .../components/services/services_content.tsx | 3 + .../service_breakdown_panel.test.tsx.snap | 246 ++-- .../span_detail_panel.test.tsx.snap | 588 +++------- .../span_detail_table.test.tsx.snap | 898 ++++++++++++--- .../__snapshots__/trace_view.test.tsx.snap | 215 ++-- .../__tests__/span_detail_panel.test.tsx | 182 ++- .../__tests__/span_detail_table.test.tsx | 272 ++++- .../traces/service_breakdown_panel.tsx | 94 +- .../components/traces/span_detail_panel.tsx | 300 ++++- .../components/traces/span_detail_table.tsx | 611 ++++++---- .../components/traces/trace_view.tsx | 16 +- .../components/traces/traces_content.tsx | 3 + public/components/trace_analytics/index.scss | 24 + .../components/visualizations/plotly/plot.tsx | 4 + 19 files changed, 3020 insertions(+), 1582 deletions(-) diff --git a/.cypress/integration/trace_analytics_test/trace_analytics_traces.spec.js b/.cypress/integration/trace_analytics_test/trace_analytics_traces.spec.js index acfff9d339..96229d932b 100644 --- a/.cypress/integration/trace_analytics_test/trace_analytics_traces.spec.js +++ b/.cypress/integration/trace_analytics_test/trace_analytics_traces.spec.js @@ -5,7 +5,7 @@ /// -import { delay, setTimeFilter, SPAN_ID, TRACE_ID } from '../../utils/constants'; +import { setTimeFilter, SPAN_ID, TRACE_ID, SPAN_ID_TREE_VIEW } from '../../utils/constants'; describe('Testing traces table empty state', () => { beforeEach(() => { @@ -154,6 +154,102 @@ describe('Testing traces table', () => { }); }); +describe('Testing traces tree view', () => { + beforeEach(() => { + cy.visit('app/observability-traces#/traces', { + onBeforeLoad: (win) => { + win.sessionStorage.clear(); + }, + }); + cy.get("[data-test-subj='indexPattern-switch-link']").click(); + cy.get("[data-test-subj='data_prepper-mode']").click(); + setTimeFilter(); + cy.contains('02feb3a4f611abd81f2a53244d1278ae').click(); + cy.get('h1.overview-content').contains('02feb3a4f611abd81f2a53244d1278ae').should('exist'); + }); + + it('Verifies tree view and table toggle functionality with expand/collapse logic', () => { + cy.get('.euiButtonGroup').contains('Tree view').click(); + cy.contains('Expand all').should('exist'); + cy.contains("Collapse all").should('exist') + //Waiting time for render to complete + cy.get("[data-test-subj='treeExpandAll']").click(); + cy.get("[data-test-subj='treeCollapseAll']").click(); + + cy.get("[data-test-subj='spanId-link']").then((initialSpanIds) => { + const initialCount = initialSpanIds.length; + expect(initialCount).to.equal(6); + + cy.get("[data-test-subj='treeExpandAll']").click(); + + cy.get("[data-test-subj='spanId-link']").then((expandedSpanIds) => { + const expandedCount = expandedSpanIds.length; + expect(expandedCount).to.equal(10); + }); + + cy.get("[data-test-subj='treeCollapseAll']").click(); + + cy.get("[data-test-subj='spanId-link']").then((collapsedSpanIds) => { + const collapsedCount = collapsedSpanIds.length; + expect(collapsedCount).to.equal(6); // Collapsed rows should match the initial count + }); + }); + }); + + it('Verifies tree view expand arrow functionality', () => { + cy.get('.euiButtonGroup').contains('Tree view').click(); + cy.contains('Expand all').should('exist'); + cy.contains("Collapse all").should('exist') + // Waiting time for render to complete + cy.get("[data-test-subj='treeExpandAll']").click(); + cy.get("[data-test-subj='treeCollapseAll']").click(); + + cy.get("[data-test-subj='spanId-link']").then((initialSpanIds) => { + const initialCount = initialSpanIds.length; + expect(initialCount).to.equal(6); + + // Find and click the first tree view expand arrow + cy.get("[data-test-subj='treeViewExpandArrow']").first().click(); + + // Check the number of Span IDs after expanding the arrow (should be 7) + cy.get("[data-test-subj='spanId-link']").then((expandedSpanIds) => { + const expandedCount = expandedSpanIds.length; + expect(expandedCount).to.equal(7); + }); + }); + }); + + it('Verifies span flyout', () => { + cy.get('.euiButtonGroup').contains('Tree view').click(); + cy.contains('Expand all').should('exist'); + cy.contains("Collapse all").should('exist') + // Waiting time for render to complete + cy.get("[data-test-subj='treeExpandAll']").click(); + cy.get("[data-test-subj='treeCollapseAll']").click(); + + // Open flyout for a span + cy.get("[data-test-subj='spanId-link']") + .contains(SPAN_ID_TREE_VIEW) + .click() + cy.contains('Span detail').should('exist'); + cy.contains('Span attributes').should('exist'); + }); + + it('Handles toggling between full screen and regular modes', () => { + cy.get('.euiButtonGroup').contains('Tree view').click(); + cy.contains('Expand all').should('exist'); + cy.contains("Collapse all").should('exist') + // Waiting time for render to complete + cy.get("[data-test-subj='treeExpandAll']").click(); + cy.get("[data-test-subj='treeCollapseAll']").click(); + + cy.get('[data-test-subj="fullScreenButton"]').click(); + cy.get('.euiButtonEmpty__text').should('contain.text', 'Exit full screen'); + cy.get('[data-test-subj="fullScreenButton"]').click(); + cy.get('.euiButtonEmpty__text').should('contain.text', 'Full screen'); + }); +}); + describe('Testing switch mode to jaeger', () => { beforeEach(() => { cy.visit('app/observability-traces#/traces', { diff --git a/.cypress/utils/constants.js b/.cypress/utils/constants.js index 8d1dd4e58a..e6f5f48a89 100644 --- a/.cypress/utils/constants.js +++ b/.cypress/utils/constants.js @@ -17,6 +17,7 @@ export const DATASOURCES_PATH = { // trace analytics export const TRACE_ID = '8832ed6abbb2a83516461960c89af49d'; export const SPAN_ID = 'a673bc074b438374'; +export const SPAN_ID_TREE_VIEW = 'fe4076542b41d40b'; export const SERVICE_NAME = 'frontend-client'; export const SERVICE_SPAN_ID = 'e275ac9d21929e9b'; export const AUTH_SERVICE_SPAN_ID = '277a5934acf55dcf'; diff --git a/public/components/application_analytics/__tests__/__snapshots__/flyout.test.tsx.snap b/public/components/application_analytics/__tests__/__snapshots__/flyout.test.tsx.snap index 957d350206..c4199b133c 100644 --- a/public/components/application_analytics/__tests__/__snapshots__/flyout.test.tsx.snap +++ b/public/components/application_analytics/__tests__/__snapshots__/flyout.test.tsx.snap @@ -700,24 +700,23 @@ exports[`Trace Detail Render Flyout component render trace detail 1`] = `
- +
- -
- - +
@@ -747,6 +746,7 @@ exports[`Trace Detail Render Flyout component render trace detail 1`] = ` - +
- -
- - -
- - Spans - - - (0) - -
-
-
-
-
- -
+
- - - Select view of spans - - -
- +
- - - - - + + +
+ + +
- -
+
+ +
- - - Span list - - - - - - -
-
-
+ > + + + Select view of spans + + +
+ + + + + + + + + + + + + + + +
+ + +
+
+
+
+
+ +
+
- -
- -
-
-
- - +
+ + +
+ + +
+ + +
+ +
- - -
+ > + + +
+ + +
+
+
+
diff --git a/public/components/application_analytics/components/flyout_components/trace_detail_render.tsx b/public/components/application_analytics/components/flyout_components/trace_detail_render.tsx index 9e13a4ca6e..9f353ca92c 100644 --- a/public/components/application_analytics/components/flyout_components/trace_detail_render.tsx +++ b/public/components/application_analytics/components/flyout_components/trace_detail_render.tsx @@ -30,7 +30,6 @@ export const TraceDetailRender = ({ openSpanFlyout, mode, dataSourceMDSId, - }: TraceDetailRenderProps) => { const [fields, setFields] = useState({}); const [serviceBreakdownData, setServiceBreakdownData] = useState([]); @@ -78,6 +77,7 @@ export const TraceDetailRender = ({ openSpanFlyout={openSpanFlyout} mode={mode} dataSourceMDSId={dataSourceMDSId} + isApplicationFlyout={true} /> diff --git a/public/components/trace_analytics/components/common/helper_functions.tsx b/public/components/trace_analytics/components/common/helper_functions.tsx index 7361fadff4..ec7ad6e17b 100644 --- a/public/components/trace_analytics/components/common/helper_functions.tsx +++ b/public/components/trace_analytics/components/common/helper_functions.tsx @@ -5,7 +5,14 @@ /* eslint-disable radix */ import dateMath from '@elastic/datemath'; -import { EuiEmptyPrompt, EuiSmallButtonEmpty, EuiSpacer, EuiText } from '@elastic/eui'; +import { + EuiButtonIcon, + EuiEmptyPrompt, + EuiOverlayMask, + EuiSmallButtonEmpty, + EuiSpacer, + EuiText, +} from '@elastic/eui'; import { SpacerSize } from '@elastic/eui/src/components/spacer/spacer'; import { isEmpty, round } from 'lodash'; import React from 'react'; @@ -582,3 +589,33 @@ export const generateServiceUrl = (service: string, dataSourceId: string) => { return `${url}&datasourceId=`; }; + +interface FullScreenWrapperProps { + children: React.ReactNode; + onClose: () => void; + isFullScreen: boolean; +} + +// EUI Data grid full screen button is currently broken, this is a workaround +export const FullScreenWrapper: React.FC = ({ + children, + onClose, + isFullScreen, +}) => { + if (!isFullScreen) return <>{children}; + + return ( + +
+ +
{children}
+
+
+ ); +}; diff --git a/public/components/trace_analytics/components/services/services_content.tsx b/public/components/trace_analytics/components/services/services_content.tsx index 20bfddfb68..4535eeda21 100644 --- a/public/components/trace_analytics/components/services/services_content.tsx +++ b/public/components/trace_analytics/components/services/services_content.tsx @@ -100,6 +100,9 @@ export function ServicesContent(props: ServicesProps) { jaegerIndicesExist, dataPrepperIndicesExist, isServiceTrendEnabled, + startTime, + endTime, + props.dataSourceMDSId, ]); const refresh = async (currService?: string, overrideQuery?: string) => { diff --git a/public/components/trace_analytics/components/traces/__tests__/__snapshots__/service_breakdown_panel.test.tsx.snap b/public/components/trace_analytics/components/traces/__tests__/__snapshots__/service_breakdown_panel.test.tsx.snap index a8539945a7..f7af80a8ac 100644 --- a/public/components/trace_analytics/components/traces/__tests__/__snapshots__/service_breakdown_panel.test.tsx.snap +++ b/public/components/trace_analytics/components/traces/__tests__/__snapshots__/service_breakdown_panel.test.tsx.snap @@ -35,24 +35,23 @@ exports[`Service breakdown panel component renders empty service breakdown panel
- +
- -
- - +
@@ -121,14 +120,16 @@ exports[`Service breakdown panel component renders service breakdown panel 1`] =
- +
- -
- - +
- +
-
-
-
-
- -
- - - - - - - -
-
- -
-
- inventory -
-
-
+ + + +
-
+ + +
+ inventory +
+
-
+
-
+
-
-
-
- -
- - -
- - -
- +
-
- -
- 100 - % -
-
+ 100 + %
-
+
-
+
-
+
diff --git a/public/components/trace_analytics/components/traces/__tests__/__snapshots__/span_detail_panel.test.tsx.snap b/public/components/trace_analytics/components/traces/__tests__/__snapshots__/span_detail_panel.test.tsx.snap index df0c49f223..f1478639c4 100644 --- a/public/components/trace_analytics/components/traces/__tests__/__snapshots__/span_detail_panel.test.tsx.snap +++ b/public/components/trace_analytics/components/traces/__tests__/__snapshots__/span_detail_panel.test.tsx.snap @@ -1,328 +1,76 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Service breakdown panel component renders service breakdown panel 1`] = ` -HTTP GET 4dec6080-61af-11eb-aee3-ef2f84ffa4a2", - ], - }, - Object { - "hovertemplate": "%{x}", - "marker": Object { - "color": "#7492e7", - }, - "orientation": "h", - "text": Array [ - "Error", - ], - "textfont": Object { - "color": Array [ - "#c14125", - ], - }, - "textposition": "outside", - "type": "bar", - "width": 0.4, - "x": Array [ - 19.91, - ], - "y": Array [ - "inventory
HTTP GET 4dec6080-61af-11eb-aee3-ef2f84ffa4a2", - ], - }, - ], - "ganttMaxX": 19.91, - "table": Array [ - Object { - "end_time": "2020-11-10T17:55:45.239564396Z", - "error": "Error", - "latency": 19.91, - "service_name": "inventory", - "span_id": "32c641131b569afa", - "start_time": "2020-11-10T17:55:45.219652629Z", - "vs_benchmark": 0, - }, - ], - } - } - mode="data_prepper" - setData={[MockFunction]} -> +exports[`SpanDetailPanel component renders correctly with default props 1`] = ` + -
- -
+ + -
- - -
- - Spans - - - (1) - -
-
-
-
+
-
- -
- - - Select view of spans - - -
- - - - - - - - - - -
-
-
-
+ Reset zoom + +
+ + + +
-
-
+ + -
-
-
+ HTTP GET 4dec6080-61af-11eb-aee3-ef2f84ffa4a2", + "service1", + ], + }, + ] + } + layout={ + Object { + "dragmode": "select", + "height": 100, + "margin": Object { + "b": 30, + "l": 150, + "r": 5, + "t": 30, + }, + "paper_bgcolor": "rgba(0, 0, 0, 0)", + "plot_bgcolor": "rgba(0, 0, 0, 0)", + "shapes": Array [ + Object { + "editable": true, + "fillcolor": "rgba(128, 128, 128, 0.3)", + "line": Object { + "color": "rgba(255, 0, 0, 0.6)", + "width": 1, + }, + "type": "rect", + "x0": 0, + "x1": 22, + "xref": "x", + "y0": 0, + "y1": 1, + "yref": "paper", + }, + ], + "width": 824, + "xaxis": Object { + "color": "#91989c", + "range": Array [ + 0, + 22, ], + "showline": true, + "side": "top", + "ticksuffix": " ms", + }, + "yaxis": Object { + "fixedrange": true, + "visible": false, }, + } + } + onRelayout={[Function]} + onSelectedHandler={[Function]} + /> + + + ", + "hoverinfo": "none", "marker": Object { - "color": "#7492e7", + "color": "#fff", }, "orientation": "h", - "text": Array [ - "Error", - ], - "textfont": Object { - "color": Array [ - "#c14125", - ], - }, + "showlegend": false, + "text": "10.00 ms", "textposition": "outside", "type": "bar", "width": 0.4, "x": Array [ - 19.91, + 10, ], "y": Array [ - "inventory
HTTP GET 4dec6080-61af-11eb-aee3-ef2f84ffa4a2", + "service1", ], }, ] } layout={ Object { - "height": 110, + "height": 85, "margin": Object { "b": 30, - "l": 260, + "l": 150, "r": 5, "t": 30, }, "paper_bgcolor": "rgba(0, 0, 0, 0)", "plot_bgcolor": "rgba(0, 0, 0, 0)", - "width": 800, + "width": 824, "xaxis": Object { "color": "#91989c", "range": Array [ 0, - 23.892, + 22, ], "showline": true, "side": "top", "ticksuffix": " ms", }, "yaxis": Object { + "fixedrange": true, "showgrid": false, "ticktext": Array [ - "inventory
HTTP GET ", + "", ], "tickvals": Array [ - "inventory
HTTP GET 4dec6080-61af-11eb-aee3-ef2f84ffa4a2", + "service1", ], }, } } onClickHandler={[Function]} onHoverHandler={[Function]} + onRelayout={[Function]} onUnhoverHandler={[Function]} - > - HTTP GET 4dec6080-61af-11eb-aee3-ef2f84ffa4a2", - ], - }, - Object { - "hovertemplate": "%{x}", - "marker": Object { - "color": "#7492e7", - }, - "orientation": "h", - "text": Array [ - "Error", - ], - "textfont": Object { - "color": Array [ - "#c14125", - ], - }, - "textposition": "outside", - "type": "bar", - "width": 0.4, - "x": Array [ - 19.91, - ], - "y": Array [ - "inventory
HTTP GET 4dec6080-61af-11eb-aee3-ef2f84ffa4a2", - ], - }, - ] - } - debug={false} - divId="explorerPlotComponent" - layout={ - Object { - "autosize": true, - "barmode": "stack", - "height": 110, - "hovermode": "closest", - "legend": Object { - "orientation": "h", - "traceorder": "normal", - }, - "margin": Object { - "b": 30, - "l": 260, - "r": 5, - "t": 30, - }, - "paper_bgcolor": "rgba(0, 0, 0, 0)", - "plot_bgcolor": "rgba(0, 0, 0, 0)", - "showlegend": false, - "width": 800, - "xaxis": Object { - "color": "#91989c", - "range": Array [ - 0, - 23.892, - ], - "showline": true, - "side": "top", - "ticksuffix": " ms", - }, - "yaxis": Object { - "showgrid": false, - "ticktext": Array [ - "inventory
HTTP GET ", - ], - "tickvals": Array [ - "inventory
HTTP GET 4dec6080-61af-11eb-aee3-ef2f84ffa4a2", - ], - }, - } - } - onClick={[Function]} - onHover={[Function]} - onUnhover={[Function]} - style={ - Object { - "height": "100%", - "width": "100%", - } - } - useResizeHandler={true} - > -
- - -
-
+ /> + + - + `; diff --git a/public/components/trace_analytics/components/traces/__tests__/__snapshots__/span_detail_table.test.tsx.snap b/public/components/trace_analytics/components/traces/__tests__/__snapshots__/span_detail_table.test.tsx.snap index 481f1325ab..b48c8a26b9 100644 --- a/public/components/trace_analytics/components/traces/__tests__/__snapshots__/span_detail_table.test.tsx.snap +++ b/public/components/trace_analytics/components/traces/__tests__/__snapshots__/span_detail_table.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` spec renders the component with data 1`] = ` +exports[`SpanDetailTable renders the component with data 1`] = `
spec renders the component with data 1`] = ` >
spec renders the component with data 1`] = `
`; -exports[` spec renders the empty component 1`] = ` +exports[`SpanDetailTable renders the empty component 1`] = ` - + + Full screen + , + ], + "showColumnSelector": true, + "showFullScreenSelector": false, + "showSortSelector": true, + } } - } - toolbarVisibility={true} - > - - @@ -143,111 +187,585 @@ exports[` spec renders the empty component 1`] = ` onClickOutside={[Function]} returnFocus={true} scrollLock={false} - sideCar={[Function]} > - -
-
+
+
+
+ +
+ } + tabIndex={-1} + /> + + + + + + + + +
+ + + No data matches the selected filter. Clear the filter and/or increase the time range to see more results. + + } + title={ +

+ No matches +

+ } + > +
+ +

+ No matches +

+
+ + + +
+ + +
+ +
+ No data matches the selected filter. Clear the filter and/or increase the time range to see more results. +
+
- -
+ + +
+ + +
+ + + +`; + +exports[`SpanDetailTable renders the jaeger component with data 1`] = ` +
+
+
+
+
+
+
+
+

+ No matches +

+ +
+
+
+ No data matches the selected filter. Clear the filter and/or increase the time range to see more results. +
+
+ +
+
+
+`; + +exports[`SpanDetailTableHierarchy renders the component with data 1`] = ` +
+
+
+
+
+
+
+
+

+ No matches +

+ +
+
+
+ No data matches the selected filter. Clear the filter and/or increase the time range to see more results. +
+
+ +
+
+
+`; + +exports[`SpanDetailTableHierarchy renders the empty component 1`] = ` + + + + Full screen + , + + Expand all + , + + Collapse all + , + ], + "showColumnSelector": true, + "showFullScreenSelector": false, + "showSortSelector": true, + } + } + > + + + + - + noFocusGuards={false} + persistentFocus={false} + returnFocus={true} + sideCar={[Function]} + > +
+ +
+
+
+ +
+ + - - - + + + @@ -324,10 +842,10 @@ exports[` spec renders the empty component 1`] = ` /> - + `; -exports[` spec renders the jaeger component with data 1`] = ` +exports[`SpanDetailTableHierarchy renders the jaeger component with data 1`] = `
spec renders the jaeger component with data 1`] = ` data-focus-lock-disabled="disabled" >
spec renders the jaeger component with data 1`] = ` style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;" tabindex="-1" /> +
+
+

+ No matches +

+ +
+
+
+ No data matches the selected filter. Clear the filter and/or increase the time range to see more results. +
+
+ +
+
`; diff --git a/public/components/trace_analytics/components/traces/__tests__/__snapshots__/trace_view.test.tsx.snap b/public/components/trace_analytics/components/traces/__tests__/__snapshots__/trace_view.test.tsx.snap index 6a72fefc45..8ad986ec45 100644 --- a/public/components/trace_analytics/components/traces/__tests__/__snapshots__/trace_view.test.tsx.snap +++ b/public/components/trace_analytics/components/traces/__tests__/__snapshots__/trace_view.test.tsx.snap @@ -4,120 +4,118 @@ exports[`Trace view component renders trace view 1`] = ` + + +

+ test +

+
+
- - -

- test -

-
-
-
- - - - - - - - - - Trace ID - - - - - Trace group name - - - - - - - - - - - - + + + + + + - Latency - - + + + Trace ID + + + + + Trace group name + + + - + + + - - + - Last updated - - + + + Latency + + + + + + Last updated + + + + - - - - - - - Errors - - + - - - + + + Errors + + + - + + + - - - - - + + @@ -125,9 +123,12 @@ exports[`Trace view component renders trace view 1`] = ` data={Array []} /> - + + + + { - configure({ adapter: new Adapter() }); - - it('renders service breakdown panel', () => { - const data = { - gantt: [ - { - x: [0], - y: ['inventory
HTTP GET 4dec6080-61af-11eb-aee3-ef2f84ffa4a2'], - marker: { color: 'rgba(0, 0, 0, 0)' }, - width: 0.4, - type: 'bar', - orientation: BarOrientation.horizontal, - hoverinfo: 'none', - showlegend: false, - }, - { - x: [19.91], - y: ['inventory
HTTP GET 4dec6080-61af-11eb-aee3-ef2f84ffa4a2'], - text: ['Error'], - textfont: { color: ['#c14125'] }, - textposition: 'outside', - marker: { color: '#7492e7' }, - width: 0.4, - type: 'bar', - orientation: BarOrientation.horizontal, - hovertemplate: '%{x}', - }, - ] as Plotly.Data[], - table: [ - { - service_name: 'inventory', - span_id: '32c641131b569afa', - latency: 19.91, - vs_benchmark: 0, - error: 'Error', - start_time: '2020-11-10T17:55:45.219652629Z', - end_time: '2020-11-10T17:55:45.239564396Z', - }, - ], - ganttMaxX: 19.91, - }; - const wrapper = mount(); +import { EuiSmallButton } from '@elastic/eui'; +import { Plt } from '../../../../visualizations/plotly/plot'; +import { act } from 'react-dom/test-utils'; + +configure({ adapter: new Adapter() }); + +jest.mock('../../../../visualizations/plotly/plot', () => ({ + Plt: (props: any) => ( +
{ + if (props.onSelectedHandler) { + props.onSelectedHandler({ range: { x: [5, 15] } }); + } + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + if (props.onSelectedHandler) { + props.onSelectedHandler({ range: { x: [5, 15] } }); + } + } + }} + /> + ), +})); + +const mockHttp = { + post: jest.fn(), +}; + +const mockData = { + gantt: [ + { + x: [10], + y: ['service1'], + marker: { color: '#fff' }, + width: 0.4, + type: 'bar', + orientation: 'h', + hoverinfo: 'none', + showlegend: false, + }, + ], + table: [ + { + service_name: 'service1', + span_id: 'span1', + latency: 10, + error: 'Error', + start_time: '2023-01-01T00:00:00Z', + end_time: '2023-01-01T00:00:10Z', + }, + ], + ganttMaxX: 20, +}; + +const mockSetData = jest.fn(); +const mockAddSpanFilter = jest.fn(); +const mockProps = { + http: mockHttp, + traceId: 'trace1', + colorMap: { service1: '#7492e7' }, + mode: 'data_prepper', + dataSourceMDSId: 'mock-id', + dataSourceMDSLabel: 'mock-label', + data: mockData, + setData: mockSetData, + addSpanFilter: mockAddSpanFilter, + removeSpanFilter: jest.fn(), +}; + +describe('SpanDetailPanel component', () => { + it('renders correctly with default props', () => { + const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); + + it('renders gantt chart and mini-map correctly', () => { + const wrapper = mount(); + expect(wrapper.find(Plt)).toHaveLength(2); // Gantt chart and mini-map + }); + + it('handles zoom reset button correctly', () => { + const wrapper = mount(); + + // Verify that the reset button is initially disabled + let resetButton = wrapper + .find(EuiSmallButton) + .filterWhere((btn) => btn.text().includes('Reset zoom')); + expect(resetButton.prop('isDisabled')).toBe(true); + + // Simulate a click on the mini-map + const miniMap = wrapper.find('[data-test-subj="mocked-plt"]').at(0); + act(() => { + miniMap.simulate('click'); + }); + + wrapper.update(); + + // Verify that the reset button is now enabled + resetButton = wrapper + .find(EuiSmallButton) + .filterWhere((btn) => btn.text().includes('Reset zoom')); + expect(resetButton.prop('isDisabled')).toBe(false); // Should now be enabled + + // Simulate clicking the reset button + act(() => { + resetButton.prop('onClick')!(); + }); + + wrapper.update(); + + // Verify that the reset button is disabled again after reset + resetButton = wrapper + .find(EuiSmallButton) + .filterWhere((btn) => btn.text().includes('Reset zoom')); + expect(resetButton.prop('isDisabled')).toBe(true); + }); + + it('handles user-defined zoom range via mini-map', () => { + const wrapper = mount(); + + // Find the mini-map and simulate click + const miniMap = wrapper.find('[data-test-subj="mocked-plt"]').at(0); + act(() => { + miniMap.simulate('click'); + }); + + wrapper.update(); + + // After zooming, the reset button should be enabled + const resetButton = wrapper + .find(EuiSmallButton) + .filterWhere((btn) => btn.text().includes('Reset zoom')); + expect(resetButton.prop('isDisabled')).toBe(false); + }); }); diff --git a/public/components/trace_analytics/components/traces/__tests__/span_detail_table.test.tsx b/public/components/trace_analytics/components/traces/__tests__/span_detail_table.test.tsx index 925e6e8a6c..d2cfe70c8b 100644 --- a/public/components/trace_analytics/components/traces/__tests__/span_detail_table.test.tsx +++ b/public/components/trace_analytics/components/traces/__tests__/span_detail_table.test.tsx @@ -11,23 +11,257 @@ import ReactDOM from 'react-dom'; import { act } from 'react-dom/test-utils'; import { HttpResponse } from '../../../../../../../../src/core/public'; import { TEST_JAEGER_SPAN_RESPONSE, TEST_SPAN_RESPONSE } from '../../../../../../test/constants'; -import httpClientMock from '../../../../../../test/__mocks__/httpClientMock'; -import { SpanDetailTable } from '../span_detail_table'; +import { SpanDetailTable, SpanDetailTableHierarchy } from '../span_detail_table'; +import { + EuiDataGridPaginationProps, + EuiDataGridSorting, + EuiDataGridColumnVisibility, +} from '@elastic/eui'; -describe(' spec', () => { - configure({ adapter: new Adapter() }); +jest.mock('../../../../../../test/__mocks__/httpClientMock', () => ({ + post: jest.fn(), +})); + +const httpClientMock = jest.requireMock('../../../../../../test/__mocks__/httpClientMock'); +configure({ adapter: new Adapter() }); + +describe('SpanDetailTable', () => { it('renders the empty component', async () => { - httpClientMock.post = jest.fn(() => - Promise.resolve(({ hits: { hits: [], total: { value: 0 } } } as unknown) as HttpResponse) + httpClientMock.post.mockResolvedValue(({ + hits: { hits: [], total: { value: 0 } }, + } as unknown) as HttpResponse); + + const wrapper = mount( + {}} + mode="data_prepper" + dataSourceMDSId="testDataSource" + /> ); + + wrapper.update(); + + await waitFor(() => { + expect(wrapper).toMatchSnapshot(); + }); + }); + + it('renders the component with data', async () => { + const setCurrentSpan = jest.fn(); + httpClientMock.post.mockResolvedValue((TEST_SPAN_RESPONSE as unknown) as HttpResponse); + + const container = document.createElement('div'); + await act(async () => { + ReactDOM.render( + setCurrentSpan(spanId)} + mode="data_prepper" + dataSourceMDSId="testDataSource" + />, + container + ); + }); + + expect(container).toMatchSnapshot(); + }); + + it('renders the jaeger component with data', async () => { + const setCurrentSpan = jest.fn(); + httpClientMock.post.mockResolvedValue((TEST_JAEGER_SPAN_RESPONSE as unknown) as HttpResponse); + const container = document.createElement('div'); + await act(() => { + ReactDOM.render( + setCurrentSpan(spanId)} + mode="jaeger" + dataSourceMDSId="testDataSource" + />, + container + ); + }); + expect(container).toMatchSnapshot(); + }); + + describe('Pagination functionality', () => { + it('should handle page size changes', async () => { + httpClientMock.post.mockResolvedValue((TEST_SPAN_RESPONSE as unknown) as HttpResponse); + + const wrapper = mount( + + ); + + const pagination = wrapper + .find('EuiDataGrid') + .prop('pagination') as EuiDataGridPaginationProps; + + await act(async () => { + pagination.onChangeItemsPerPage!(50); + }); + + wrapper.update(); + + const updatedPagination = wrapper + .find('EuiDataGrid') + .prop('pagination') as EuiDataGridPaginationProps; + expect(updatedPagination.pageSize).toBe(50); + }); + + it('should handle page changes', async () => { + httpClientMock.post.mockResolvedValue((TEST_SPAN_RESPONSE as unknown) as HttpResponse); + + const wrapper = mount( + + ); + + const pagination = wrapper + .find('EuiDataGrid') + .prop('pagination') as EuiDataGridPaginationProps; + + await act(async () => { + pagination.onChangePage!(1); + }); + + wrapper.update(); + + const updatedPagination = wrapper + .find('EuiDataGrid') + .prop('pagination') as EuiDataGridPaginationProps; + expect(updatedPagination.pageIndex).toBe(1); + }); + }); + + describe('Column visibility', () => { + it('should hide columns specified in hiddenColumns prop', () => { + const wrapper = mount( + + ); + + const columnVisibility = wrapper + .find('EuiDataGrid') + .prop('columnVisibility') as EuiDataGridColumnVisibility; + const visibleColumns = columnVisibility.visibleColumns; + + expect(visibleColumns).not.toContain('spanId'); + expect(visibleColumns).not.toContain('startTime'); + }); + + it('should update visible columns when column visibility changes', async () => { + const wrapper = mount( + + ); + + const newVisibleColumns = ['spanId', 'serviceName']; + + const columnVisibility = wrapper + .find('EuiDataGrid') + .prop('columnVisibility') as EuiDataGridColumnVisibility; + + await act(async () => { + columnVisibility.setVisibleColumns!(newVisibleColumns); + }); + + wrapper.update(); + + const updatedColumnVisibility = wrapper + .find('EuiDataGrid') + .prop('columnVisibility') as EuiDataGridColumnVisibility; + expect(updatedColumnVisibility.visibleColumns).toEqual(newVisibleColumns); + }); + }); + + describe('Sorting functionality', () => { + it('should handle sort changes', async () => { + const wrapper = mount( + + ); + + const newSorting: Array<{ id: string; direction: 'asc' | 'desc' }> = [ + { id: 'startTime', direction: 'desc' }, + ]; + + const sorting = wrapper.find('EuiDataGrid').prop('sorting') as EuiDataGridSorting; + + await act(async () => { + sorting.onSort!(newSorting); + }); + + wrapper.update(); + + const updatedSorting = wrapper.find('EuiDataGrid').prop('sorting') as EuiDataGridSorting; + expect(updatedSorting.columns).toEqual(newSorting); + }); + + it('should disable sorting in Jaeger mode', () => { + const wrapper = mount( + + ); + + expect(wrapper.find('EuiDataGrid').prop('sorting')).toBeUndefined(); + }); + }); +}); + +describe('SpanDetailTableHierarchy', () => { + configure({ adapter: new Adapter() }); + + it('renders the empty component', async () => { + httpClientMock.post.mockResolvedValue(({ + hits: { hits: [], total: { value: 0 } }, + } as unknown) as HttpResponse); const utils = await mount( - {}} - mode='data_prepper' + mode="data_prepper" + dataSourceMDSId="testDataSource" /> ); utils.update(); @@ -38,18 +272,17 @@ describe(' spec', () => { it('renders the component with data', async () => { const setCurrentSpan = jest.fn(); - httpClientMock.post = jest.fn(() => - Promise.resolve((TEST_SPAN_RESPONSE as unknown) as HttpResponse) - ); - let container = document.createElement('div'); + httpClientMock.post.mockResolvedValue((TEST_SPAN_RESPONSE as unknown) as HttpResponse); + const container = document.createElement('div'); await act(() => { ReactDOM.render( - setCurrentSpan(spanId)} - mode='data_prepper' + mode="data_prepper" + dataSourceMDSId="testDataSource" />, container ); @@ -59,18 +292,17 @@ describe(' spec', () => { it('renders the jaeger component with data', async () => { const setCurrentSpan = jest.fn(); - httpClientMock.post = jest.fn(() => - Promise.resolve((TEST_JAEGER_SPAN_RESPONSE as unknown) as HttpResponse) - ); - let container = document.createElement('div'); + httpClientMock.post.mockResolvedValue((TEST_JAEGER_SPAN_RESPONSE as unknown) as HttpResponse); + const container = document.createElement('div'); await act(() => { ReactDOM.render( - setCurrentSpan(spanId)} - mode='jaeger' + mode="jaeger" + dataSourceMDSId="testDataSource" />, container ); diff --git a/public/components/trace_analytics/components/traces/service_breakdown_panel.tsx b/public/components/trace_analytics/components/traces/service_breakdown_panel.tsx index 595117fe0b..c08a59ac0c 100644 --- a/public/components/trace_analytics/components/traces/service_breakdown_panel.tsx +++ b/public/components/trace_analytics/components/traces/service_breakdown_panel.tsx @@ -12,12 +12,20 @@ import { EuiSpacer, EuiText, } from '@elastic/eui'; -import _ from 'lodash'; +import round from 'lodash/round'; import React, { useMemo } from 'react'; import { Plt } from '../../../visualizations/plotly/plot'; import { PanelTitle } from '../common/helper_functions'; -export function ServiceBreakdownPanel(props: { data: Plotly.Data[] }) { +interface ServiceBreakdownData { + labels: string[]; + values: number[]; + marker: { + colors: string[]; + }; +} + +export function ServiceBreakdownPanel(props: { data: ServiceBreakdownData[] }) { const layout = useMemo( () => ({ @@ -29,58 +37,58 @@ export function ServiceBreakdownPanel(props: { data: Plotly.Data[] }) { margin: { l: 5, r: 5, - b: 5, - t: 5, + b: 15, + t: 15, }, } as Partial), [props.data] ); const renderStats = () => { - return props.data.length > 0 ? ( - - - - {props.data[0].marker.colors.map((color, i) => ( - - -
{props.data[0].labels[i]}
-
-
- ))} -
-
- - - - - {props.data[0].values.map((value, i) => ( - - {_.round(value, 2)}% - - ))} + if (props.data.length === 0) return null; + + const { labels, values, marker } = props.data[0]; + + return ( + + {labels.map((label: string, index: number) => ( + + + {label} + + + {round(values[index], 2)}% + - + ))}
- ) : null; + ); }; const stats = useMemo(() => renderStats(), [props.data]); return ( - <> - - - - - - {props.data?.length > 0 ? : null} - - - {stats} - - - - + + + + + + {props.data?.length > 0 ? : null} + + {stats} + + + ); -} +} \ No newline at end of file diff --git a/public/components/trace_analytics/components/traces/span_detail_panel.tsx b/public/components/trace_analytics/components/traces/span_detail_panel.tsx index 7441a3c8f2..09ad0b480a 100644 --- a/public/components/trace_analytics/components/traces/span_detail_panel.tsx +++ b/public/components/trace_analytics/components/traces/span_detail_panel.tsx @@ -11,18 +11,23 @@ import { EuiFlexItem, EuiHorizontalRule, EuiPanel, + EuiSmallButton, EuiSpacer, } from '@elastic/eui'; import debounce from 'lodash/debounce'; import isEmpty from 'lodash/isEmpty'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import useObservable from 'react-use/lib/useObservable'; import { HttpSetup } from '../../../../../../../src/core/public'; import { TraceAnalyticsMode } from '../../../../../common/types/trace_analytics'; import { Plt } from '../../../visualizations/plotly/plot'; import { handleSpansGanttRequest } from '../../requests/traces_request_handler'; import { PanelTitle } from '../common/helper_functions'; import { SpanDetailFlyout } from './span_detail_flyout'; -import { SpanDetailTable } from './span_detail_table'; +import { SpanDetailTable, SpanDetailTableHierarchy } from './span_detail_table'; +import { coreRefs } from '../../../../framework/core_refs'; + +const newNavigation = coreRefs?.chrome?.navGroup.getNavGroupEnabled?.(); export function SpanDetailPanel(props: { http: HttpSetup; @@ -35,7 +40,9 @@ export function SpanDetailPanel(props: { openSpanFlyout?: any; data?: { gantt: any[]; table: any[]; ganttMaxX: number }; setData?: (data: { gantt: any[]; table: any[]; ganttMaxX: number }) => void; + isApplicationFlyout?: boolean; }) { + const { chrome } = coreRefs; const { mode } = props; const storedFilters = sessionStorage.getItem('TraceAnalyticsSpanFilters'); const fromApp = props.page === 'app'; @@ -43,17 +50,63 @@ export function SpanDetailPanel(props: { storedFilters ? JSON.parse(storedFilters) : [] ); const [DSL, setDSL] = useState({}); - let data: { gantt: any[]; table: any[]; ganttMaxX: number }, - setData: (data: { gantt: any[]; table: any[]; ganttMaxX: number }) => void; + let data: { gantt: any[]; table: any[]; ganttMaxX: number }; + let setData: (data: { gantt: any[]; table: any[]; ganttMaxX: number }) => void; + const [localData, localSetData] = useState<{ gantt: any[]; table: any[]; ganttMaxX: number }>({ + gantt: [], + table: [], + ganttMaxX: 0, + }); if (props.data && props.setData) { [data, setData] = [props.data, props.setData]; } else { - [data, setData] = useState<{ gantt: any[]; table: any[]; ganttMaxX: number }>({ - gantt: [], - table: [], - ganttMaxX: 0, - }); + [data, setData] = [localData, localSetData]; } + const fullRange = [0, data.ganttMaxX * 1.1]; + const [selectedRange, setSelectedRange] = useState(fullRange); + const isLocked = useObservable(chrome!.getIsNavDrawerLocked$() ?? false); + const [isFullScreen, setIsFullScreen] = useState(false); + const containerRef = useRef(null); + const [availableWidth, setAvailableWidth] = useState(window.innerWidth); + + useEffect(() => { + const updateAvailableWidth = () => { + if (containerRef.current) { + setAvailableWidth(containerRef.current.getBoundingClientRect().width); + } else { + setAvailableWidth(window.innerWidth); + } + }; + + const handleFullScreenChange = () => { + const isFullscreenActive = !!document.fullscreenElement; + setIsFullScreen(isFullscreenActive); + updateAvailableWidth(); + }; + + // Add event listeners for window resize and full-screen toggling + window.addEventListener('resize', updateAvailableWidth); + document.addEventListener('fullscreenchange', handleFullScreenChange); + + // Initial update + updateAvailableWidth(); + + return () => { + // Clean up event listeners + window.removeEventListener('resize', updateAvailableWidth); + document.removeEventListener('fullscreenchange', handleFullScreenChange); + }; + }, []); + + const dynamicLayoutAdjustment = useMemo(() => { + const adjustment = newNavigation ? 350 : 400; + return isLocked ? availableWidth - adjustment : availableWidth - 150; + }, [isLocked, availableWidth]); + + // Update selectedRange whenever data.ganttMaxX changes to ensure it starts fully zoomed out + useEffect(() => { + setSelectedRange(fullRange); + }, [data.ganttMaxX]); const setSpanFiltersWithStorage = (newFilters: Array<{ field: string; value: any }>) => { setSpanFilters(newFilters); @@ -146,7 +199,15 @@ export function SpanDetailPanel(props: { refresh(); }, [props.colorMap, spanFilters]); - const getSpanDetailLayout = (plotTraces: Plotly.Data[], maxX: number): Partial => { + const getSpanDetailLayout = ( + plotTraces: Plotly.Data[], + _maxX: number + ): Partial => { + const dynamicWidthAdjustment = !isLocked + ? 200 + : newNavigation + ? 390 // If locked and new navigation + : 410; // If locked and new navigation is disabled // get unique labels from traces const yLabels = plotTraces .map((d) => d.y[0]) @@ -154,13 +215,20 @@ export function SpanDetailPanel(props: { // remove uuid when displaying y-ticks const yTexts = yLabels.map((label) => label.substring(0, label.length - 36)); + // Calculate the maximum label length dynamically + const maxLabelLength = Math.max(...yTexts.map((text) => text.length)); + + // Dynamically set left margin based on the longest label + let dynamicLeftMargin = Math.max(150, maxLabelLength * 5); + dynamicLeftMargin = Math.min(dynamicLeftMargin, 500); + return { plot_bgcolor: 'rgba(0, 0, 0, 0)', paper_bgcolor: 'rgba(0, 0, 0, 0)', height: 25 * plotTraces.length + 60, - width: 800, + width: props.isApplicationFlyout ? undefined : availableWidth - dynamicWidthAdjustment, // Allow plotly to render the gantt chart full screen with padding margin: { - l: 260, + l: dynamicLeftMargin, r: 5, b: 30, t: 30, @@ -170,12 +238,13 @@ export function SpanDetailPanel(props: { side: 'top', color: '#91989c', showline: true, - range: [0, maxX * 1.2], + range: selectedRange, // Apply selected range to main chart }, yaxis: { showgrid: false, tickvals: yLabels, ticktext: yTexts, + fixedrange: true, // Prevent panning/scrolling in main chart }, }; }; @@ -183,7 +252,59 @@ export function SpanDetailPanel(props: { const layout = useMemo(() => getSpanDetailLayout(data.gantt, data.ganttMaxX), [ data.gantt, data.ganttMaxX, + selectedRange, + availableWidth, + isLocked, + isFullScreen, ]); + const miniMapLayout = { + ...layout, + height: 100, + dragmode: 'select', // Allow users to define their zoom range + xaxis: { ...layout.xaxis, range: fullRange }, + yaxis: { visible: false, fixedrange: true }, + shapes: [ + { + type: 'rect', + xref: 'x', + yref: 'paper', + x0: selectedRange[0], + x1: selectedRange[1], + y0: 0, + y1: 1, + fillcolor: 'rgba(128, 128, 128, 0.3)', // Highlight the selection area + line: { + width: 1, + color: 'rgba(255, 0, 0, 0.6)', // Border of the selection + }, + editable: true, + }, + ], + }; + + const miniMap = useMemo( + () => ( + ({ + ...trace, + }))} + layout={miniMapLayout} + onSelectedHandler={(event) => { + if (event && event.range) { + const { x } = event.range; + setSelectedRange(x); // Update selected range to reflect user-defined zoom + } + }} + onRelayout={(event) => { + if (event && event['shapes[0].x0'] && event['shapes[0].x1']) { + // Update selected range when the shape (rectangle) is moved + setSelectedRange([event['shapes[0].x0'], event['shapes[0].x1']]); + } + }} + /> + ), + [data.gantt, miniMapLayout, setSelectedRange] + ); const [currentSpan, setCurrentSpan] = useState(''); @@ -234,70 +355,145 @@ export function SpanDetailPanel(props: { id: 'span_list', label: 'Span list', }, + { + id: 'hierarchy_span_list', + label: 'Tree view', + }, ]; const [toggleIdSelected, setToggleIdSelected] = useState(toggleOptions[0].id); const spanDetailTable = useMemo( () => ( - { - if (fromApp) { - props.openSpanFlyout(spanId); - } else { - setCurrentSpan(spanId); - } - }} - dataSourceMDSId={props.dataSourceMDSId} - /> +
+ { + if (fromApp) { + props.openSpanFlyout(spanId); + } else { + setCurrentSpan(spanId); + } + }} + dataSourceMDSId={props.dataSourceMDSId} + /> +
), - [DSL, setCurrentSpan] + [DSL, setCurrentSpan, dynamicLayoutAdjustment] + ); + + const spanDetailTableHierarchy = useMemo( + () => ( +
+ { + if (fromApp) { + props.openSpanFlyout(spanId); + } else { + setCurrentSpan(spanId); + } + }} + dataSourceMDSId={props.dataSourceMDSId} + /> +
+ ), + [DSL, setCurrentSpan, dynamicLayoutAdjustment] ); const ganttChart = useMemo( () => ( { + const duration = trace.x[0] ? trace.x[0].toFixed(2) : '0.00'; // Format duration to 2 decimal places + + return { + ...trace, + text: `${duration} ms`, + textposition: 'outside', + hoverinfo: 'none', + }; + })} layout={layout} onClickHandler={onClick} onHoverHandler={onHover} onUnhoverHandler={onUnhover} + onRelayout={(event) => { + // Handle x-axis range update + if (event && event['xaxis.range[0]'] && event['xaxis.range[1]']) { + const newRange = [event['xaxis.range[0]'], event['xaxis.range[1]']]; + setSelectedRange(newRange); + } else { + setSelectedRange(fullRange); + } + }} /> ), - [data.gantt, layout, onClick, onHover, onUnhover] + [data.gantt, layout, onClick, onHover, onUnhover, setSelectedRange] ); return ( <> - - - - + - setToggleIdSelected(id)} - /> + + + + + + + {toggleIdSelected === 'timeline' && ( + + setSelectedRange(fullRange)} + isDisabled={ + selectedRange[0] === fullRange[0] && selectedRange[1] === fullRange[1] + } + > + Reset zoom + + + )} + + setToggleIdSelected(id)} + /> + + + + + + + {spanFilters.length > 0 && ( + + + + {renderFilters} + + + )} + + + + {toggleIdSelected === 'timeline' && {miniMap}} + + + {toggleIdSelected === 'timeline' + ? ganttChart + : toggleIdSelected === 'span_list' + ? spanDetailTable + : spanDetailTableHierarchy} - {spanFilters.length > 0 && ( - <> - - - {renderFilters} - - - )} - -
- {toggleIdSelected === 'timeline' ? ganttChart : spanDetailTable} -
{!!currentSpan && ( ; } +const getColumns = (mode: TraceAnalyticsMode): EuiDataGridColumn[] => [ + { + id: mode === 'jaeger' ? 'spanID' : 'spanId', + display: 'Span ID', + }, + { + id: mode === 'jaeger' ? 'references' : 'parentSpanId', + display: 'Parent span ID', + }, + { + id: mode === 'jaeger' ? 'traceID' : 'traceId', + display: 'Trace ID', + }, + ...(mode !== 'jaeger' + ? [ + { + id: 'traceGroup', + display: 'Trace group', + }, + ] + : []), + { + id: mode === 'jaeger' ? 'process' : 'serviceName', + display: 'Service', + }, + { + id: mode === 'jaeger' ? 'operationName' : 'name', + display: 'Operation', + }, + { + id: mode === 'jaeger' ? 'duration' : 'durationInNanos', + display: 'Duration', + initialWidth: 100, + }, + { + id: mode === 'jaeger' ? 'tag' : 'status.code', + display: 'Errors', + initialWidth: 100, + }, + { + id: 'startTime', + display: 'Start time', + }, + { + id: mode === 'jaeger' ? 'jaegerEndTime' : 'endTime', + display: 'End time', + }, +]; + +const renderCommonCellValue = ({ + rowIndex, + columnId, + items, + tableParams, + expandedRows, + toggleRowExpansion, + props, + flattenedItems, + indentationFactor = 0, + fullScreenMode = false, +}: { + rowIndex: number; + columnId: string; + items: any; + tableParams: any; + expandedRows?: Set; + toggleRowExpansion?: (spanId: string) => void; + props: SpanDetailTableProps; + flattenedItems?: any[]; + indentationFactor?: number; + fullScreenMode?: boolean; +}) => { + const adjustedRowIndex = flattenedItems + ? rowIndex + : rowIndex - tableParams.page * tableParams.size; + const item = flattenedItems ? flattenedItems[rowIndex] : items[adjustedRowIndex]; + + if (!item) return '-'; + + const value = item[columnId]; + const indentation = `${(item.level || 0) * indentationFactor}px`; + const isRowExpanded = expandedRows?.has(item.spanId); + + if ((value == null || value === '') && columnId !== 'jaegerEndTime') return '-'; + + switch (columnId) { + case 'tag': + return value?.error === true ? ( + + Yes + + ) : ( + 'No' + ); + case 'references': + return value.length > 0 ? value[0].spanID : ''; + case 'process': + return value?.serviceName; + case 'spanId': + case 'spanID': + return ( +
+ {toggleRowExpansion && item.children?.length > 0 ? ( + toggleRowExpansion(item.spanId)} + style={{ cursor: 'pointer', marginRight: 5 }} + data-test-subj="treeViewExpandArrow" + /> + ) : ( + + )} + {!fullScreenMode ? ( + props.openFlyout(value)}> + {value} + + ) : ( + {value} + )} +
+ ); + case 'durationInNanos': + return `${round(nanoToMilliSec(Math.max(0, value)), 2)} ms`; + case 'duration': + return `${round(microToMilliSec(Math.max(0, value)), 2)} ms`; + case 'startTime': + return props.mode === 'jaeger' + ? moment(round(microToMilliSec(Math.max(0, value)), 2)).format(TRACE_ANALYTICS_DATE_FORMAT) + : moment(value).format(TRACE_ANALYTICS_DATE_FORMAT); + case 'jaegerEndTime': + return moment(round(microToMilliSec(Math.max(0, item.startTime + item.duration)), 2)).format( + TRACE_ANALYTICS_DATE_FORMAT + ); + case 'endTime': + return moment(value).format(TRACE_ANALYTICS_DATE_FORMAT); + case 'status.code': + return value === 2 ? ( + + Yes + + ) : ( + 'No' + ); + + default: + return value || '-'; + } +}; + export function SpanDetailTable(props: SpanDetailTableProps) { const [tableParams, setTableParams] = useState({ size: 10, @@ -41,15 +202,17 @@ export function SpanDetailTable(props: SpanDetailTableProps) { direction: 'asc' | 'desc'; }>, }); - const { mode } = props; const [items, setItems] = useState([]); const [total, setTotal] = useState(0); + const { mode } = props; useEffect(() => { const spanSearchParams: SpanSearchParams = { from: tableParams.page * tableParams.size, size: tableParams.size, - sortingColumns: tableParams.sortingColumns.map(({ id, direction }) => ({ [id]: direction })), + sortingColumns: tableParams.sortingColumns.map(({ id, direction }) => ({ + [id]: direction, + })), }; handleSpansRequest( props.http, @@ -66,124 +229,7 @@ export function SpanDetailTable(props: SpanDetailTableProps) { if (props.setTotal) props.setTotal(total); }, [total]); - const columns: EuiDataGridColumn[] = [ - ...(mode === 'jaeger' - ? [ - { - id: 'spanID', - display: 'Span ID', - }, - ] - : [ - { - id: 'spanId', - display: 'Span ID', - }, - ]), - ...(mode === 'jaeger' - ? [ - { - id: 'references', - display: 'Parent span ID', - }, - ] - : [ - { - id: 'parentSpanId', - display: 'Parent span ID', - }, - ]), - ...(mode === 'jaeger' - ? [ - { - id: 'traceID', - display: 'Trace ID', - }, - ] - : [ - { - id: 'traceId', - display: 'Trace ID', - }, - ]), - ...(mode === 'jaeger' - ? [] - : [ - { - id: 'traceGroup', - display: 'Trace group', - }, - ]), - ...(mode === 'jaeger' - ? [ - { - id: 'process', - display: 'Service', - }, - ] - : [ - { - id: 'serviceName', - display: 'Service', - }, - ]), - ...(mode === 'jaeger' - ? [ - { - id: 'operationName', - display: 'Operation', - }, - ] - : [ - { - id: 'name', - display: 'Operation', - }, - ]), - ...(mode === 'jaeger' - ? [ - { - id: 'duration', - display: 'Duration', - }, - ] - : [ - { - id: 'durationInNanos', - display: 'Duration', - }, - ]), - { - id: 'startTime', - display: 'Start time', - }, - ...(mode === 'jaeger' - ? [ - { - id: 'jaegerEndTime', - display: 'End time', - }, - ] - : [ - { - id: 'endTime', - display: 'End time', - }, - ]), - ...(mode === 'jaeger' - ? [ - { - id: 'tag', - display: 'Errors', - }, - ] - : [ - { - id: 'status.code', - display: 'Errors', - }, - ]), - ]; + const columns = useMemo(() => getColumns(mode), [mode]); const [visibleColumns, setVisibleColumns] = useState(() => columns @@ -191,68 +237,14 @@ export function SpanDetailTable(props: SpanDetailTableProps) { .map(({ id }) => id) ); - const renderCellValue = useMemo(() => { - return ({ rowIndex, columnId }: { rowIndex: number; columnId: string }) => { - const adjustedRowIndex = rowIndex - tableParams.page * tableParams.size; - if (!items.hasOwnProperty(adjustedRowIndex)) return '-'; - const value = items[adjustedRowIndex][columnId]; - if ((value == null || value === '') && columnId !== 'jaegerEndTime') return '-'; - switch (columnId) { - case 'tag': - return value.error === true ? ( - - Yes - - ) : ( - 'No' - ); - case 'references': - return value.length > 0 ? value[0].spanID : ''; - case 'process': - return value.serviceName; - case 'spanId': - return ( - props.openFlyout(value)}> - {value} - - ); - case 'spanID': - return props.openFlyout(value)}>{value}; - case 'durationInNanos': - return `${_.round(nanoToMilliSec(Math.max(0, value)), 2)} ms`; - case 'duration': - return `${_.round(microToMilliSec(Math.max(0, value)), 2)} ms`; - case 'startTime': - return mode === 'jaeger' - ? moment(_.round(microToMilliSec(Math.max(0, value)), 2)).format( - TRACE_ANALYTICS_DATE_FORMAT - ) - : moment(value).format(TRACE_ANALYTICS_DATE_FORMAT); - case 'jaegerEndTime': - return moment( - _.round( - microToMilliSec( - Math.max(0, items[adjustedRowIndex].startTime + items[adjustedRowIndex].duration) - ), - 2 - ) - ).format(TRACE_ANALYTICS_DATE_FORMAT); - case 'endTime': - return moment(value).format(TRACE_ANALYTICS_DATE_FORMAT); - case 'status.code': - return value === 2 ? ( - - Yes - - ) : ( - 'No' - ); + const [fullScreenMode, setFullScreenMode] = useState(false); + const openFullScreenModal = () => setFullScreenMode(true); + const closeFullScreenModal = () => setFullScreenMode(false); - default: - return value; - } - }; - }, [items, tableParams.page, tableParams.size]); + const renderCellValue = useCallback( + (params) => renderCommonCellValue({ ...params, items, tableParams, props, fullScreenMode }), + [items, tableParams, props, fullScreenMode] + ); const onSort = useCallback( (sortingColumns) => { @@ -261,37 +253,248 @@ export function SpanDetailTable(props: SpanDetailTableProps) { sortingColumns, }); }, - [setTableParams] + [tableParams] ); const onChangeItemsPerPage = useCallback((size) => setTableParams({ ...tableParams, size }), [ tableParams, - setTableParams, ]); const onChangePage = useCallback((page) => setTableParams({ ...tableParams, page }), [ tableParams, - setTableParams, ]); + const toolbarButtons = [ + + {fullScreenMode ? 'Exit full screen' : 'Full screen'} + , + ]; + return ( <> - + + + {total === 0 && } ); } + +export function SpanDetailTableHierarchy(props: SpanDetailTableProps) { + const { mode } = props; + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [expandedRows, setExpandedRows] = useState(new Set()); + const [visibleColumns, setVisibleColumns] = useState([]); + + useEffect(() => { + const spanSearchParams = { + from: 0, + size: 10000, + sortingColumns: [], + }; + handleSpansRequest( + props.http, + (data) => { + const hierarchy = buildHierarchy(data); + setItems(hierarchy); + }, + setTotal, + spanSearchParams, + props.DSL, + mode, + props.dataSourceMDSId + ); + }, [props.DSL]); + + interface Span { + spanId: string; + parentSpanId?: string; + children: Span[]; + [key: string]: any; + } + + type SpanMap = Record; + + const buildHierarchy = (spans: Span[]): Span[] => { + const spanMap: SpanMap = {}; + + spans.forEach((span) => { + spanMap[span.spanId] = { ...span, children: [] }; + }); + + const rootSpans: Span[] = []; + + spans.forEach((span) => { + if (span.parentSpanId && spanMap[span.parentSpanId]) { + // If the parent span exists, add this span to its children array + spanMap[span.parentSpanId].children.push(spanMap[span.spanId]); + } else { + rootSpans.push(spanMap[span.spanId]); + } + }); + + return rootSpans; + }; + + const flattenedItems = useMemo(() => { + const flattenHierarchy = (spans: Span[], level = 0, isParentExpanded = true): Span[] => { + return spans.flatMap((span) => { + const isExpanded = expandedRows.has(span.spanId); + const shouldShow = level === 0 || isParentExpanded; + const row = shouldShow ? [{ ...span, level }] : []; + const children = flattenHierarchy(span.children || [], level + 1, isExpanded && shouldShow); + return [...row, ...children]; + }); + }; + + return flattenHierarchy(items); + }, [items, expandedRows]); + + const columns = useMemo(() => getColumns(mode), [mode]); + + useEffect(() => { + setVisibleColumns( + columns + .filter(({ id }) => props.hiddenColumns.findIndex((column) => column === id) === -1) + .map(({ id }) => id) + ); + }, [columns, props.hiddenColumns]); + + const [fullScreenMode, setFullScreenMode] = useState(false); + const openFullScreenModal = () => setFullScreenMode(true); + const closeFullScreenModal = () => setFullScreenMode(false); + + const renderCellValue = useCallback( + (params) => + renderCommonCellValue({ + ...params, + items, + props, + expandedRows, + toggleRowExpansion: (id) => { + setExpandedRows((prev) => { + const newSet = new Set(prev); + if (newSet.has(id)) { + newSet.delete(id); + } else { + newSet.add(id); + } + return newSet; + }); + }, + flattenedItems, + indentationFactor: 20, + fullScreenMode, + }), + [items, expandedRows, props, flattenedItems, fullScreenMode] + ); + + const gatherAllSpanIds = (spans: Span[]): Set => { + const allSpanIds = new Set(); + + spans.forEach((span) => { + allSpanIds.add(span.spanId); + + if (span.children && span.children.length > 0) { + const childSpanIds = gatherAllSpanIds(span.children); + childSpanIds.forEach((id) => allSpanIds.add(id)); + } + }); + + return allSpanIds; + }; + + const expandAllRows = () => { + const allExpandedIds = gatherAllSpanIds(items); + setExpandedRows(allExpandedIds); + }; + + const collapseAllRows = () => { + setExpandedRows(new Set()); + }; + + const toolbarButtons = [ + + {fullScreenMode ? 'Exit full screen' : 'Full screen'} + , + + Expand all + , + + Collapse all + , + ]; + + return ( + <> + + + + {!fullScreenMode && total === 0 && } + + ); +} diff --git a/public/components/trace_analytics/components/traces/trace_view.tsx b/public/components/trace_analytics/components/traces/trace_view.tsx index f04e97c36c..1ff7cbb1c3 100644 --- a/public/components/trace_analytics/components/traces/trace_view.tsx +++ b/public/components/trace_analytics/components/traces/trace_view.tsx @@ -260,18 +260,16 @@ export function TraceView(props: TraceViewProps) { <> - - {renderTitle(props.traceId)} - - - {renderOverview(fields)} - - - + {renderTitle(props.traceId)} + + {renderOverview(fields)} - + + + + { diff --git a/public/components/trace_analytics/index.scss b/public/components/trace_analytics/index.scss index 779a472596..5cd2ec9cdf 100644 --- a/public/components/trace_analytics/index.scss +++ b/public/components/trace_analytics/index.scss @@ -122,3 +122,27 @@ th[data-test-subj^='tableHeaderCell_dashboard_latency_variance'] { .popOverSelectableItem { white-space: initial !important; } + +.full-screen-wrapper { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: #fff; + z-index: 9999; + display: flex; + flex-direction: column; +} + +.full-screen-close-icon { + position: absolute; + top: 4px; + right: 4px; + z-index: 10000; +} + +.full-screen-content { + flex: 1 1 auto; + overflow: auto; +} \ No newline at end of file diff --git a/public/components/visualizations/plotly/plot.tsx b/public/components/visualizations/plotly/plot.tsx index e1cb387459..ad23332184 100644 --- a/public/components/visualizations/plotly/plot.tsx +++ b/public/components/visualizations/plotly/plot.tsx @@ -15,6 +15,8 @@ interface PltProps { onHoverHandler?: (event: Readonly) => void; onUnhoverHandler?: (event: Readonly) => void; onClickHandler?: (event: Readonly) => void; + onSelectedHandler?: (event: Readonly) => void; + onRelayout?: (event: Readonly) => void; height?: string; dispatch?: (props: any) => void; } @@ -71,6 +73,8 @@ export function Plt(props: PltProps) { onHover={props.onHoverHandler} onUnhover={props.onUnhoverHandler} onClick={props.onClickHandler} + onRelayout={props.onRelayout} + onSelected={props.onSelectedHandler} useResizeHandler config={finalConfig} layout={finalLayout}