{
+ 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}