From 724f7ce78bca32b9c22c1eafae53b603f3399625 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Tue, 25 Aug 2026 12:39:19 +0000 Subject: [PATCH] feat(traces): table migration to tanstack for traces view in traces explorer (#12672) #### Description - migrates the traces view from the antd `ResizeTable` to the shared TanStack table, the same one list view uses now, so both views share the renderer. - updated `FieldCell` to handle for `trace_id` columns as well. - columns are resizable and reorderable now in trace view as well. which was not possible earlier - toolbar always renders now (root spans note + download + prev/next), so pagination doesn't disappear when data is loading - removed the styled-components file for this view, layout is a css module now - tests added for both views #### Issues closed by this PR Part of https://github.com/SigNoz/engineering-pod/issues/5052 #### Screenshots / Screen Recordings https://github.com/user-attachments/assets/e0ad657e-a74e-41fa-badb-8dea40007701 --- frontend/src/constants/localStorage.ts | 1 + .../ListView/index.data.test.tsx | 133 +++++++++++++++++ .../TracesExplorer/TracesTable/FieldCell.tsx | 15 ++ .../TracesTable/TracesTable.tsx | 8 +- .../TracesExplorer/TracesTable/constants.ts | 6 + .../TracesView/TracesView.module.scss | 15 ++ .../TracesExplorer/TracesView/configs.tsx | 67 +++------ .../TracesExplorer/TracesView/index.test.tsx | 136 ++++++++++++++++++ .../TracesExplorer/TracesView/index.tsx | 124 +++++++--------- .../TracesExplorer/TracesView/styles.ts | 12 -- 10 files changed, 387 insertions(+), 130 deletions(-) create mode 100644 frontend/src/container/TracesExplorer/ListView/index.data.test.tsx create mode 100644 frontend/src/container/TracesExplorer/TracesView/TracesView.module.scss create mode 100644 frontend/src/container/TracesExplorer/TracesView/index.test.tsx delete mode 100644 frontend/src/container/TracesExplorer/TracesView/styles.ts diff --git a/frontend/src/constants/localStorage.ts b/frontend/src/constants/localStorage.ts index 4f79192e06e..a0fc4f242be 100644 --- a/frontend/src/constants/localStorage.ts +++ b/frontend/src/constants/localStorage.ts @@ -11,6 +11,7 @@ export enum LOCALSTORAGE { TRACES_LIST_OPTIONS = 'TRACES_LIST_OPTIONS', GRAPH_VISIBILITY_STATES = 'GRAPH_VISIBILITY_STATES', TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS', + TRACES_VIEW_COLUMNS = 'TRACES_VIEW_COLUMNS', LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS', LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING', LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME', diff --git a/frontend/src/container/TracesExplorer/ListView/index.data.test.tsx b/frontend/src/container/TracesExplorer/ListView/index.data.test.tsx new file mode 100644 index 00000000000..4d6f4ef373b --- /dev/null +++ b/frontend/src/container/TracesExplorer/ListView/index.data.test.tsx @@ -0,0 +1,133 @@ +import { ENVIRONMENT } from 'constants/env'; +import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder'; +import { server } from 'mocks-server/server'; +import { rest } from 'msw'; +import { VirtuosoMockContext } from 'react-virtuoso'; +import { render, screen } from 'tests/test-utils'; + +import ListView from './index'; + +// globalTime starts with loading:true, which gates the list query. Force just that +// slice's loading to false so the query fires; every other selector is untouched. +jest.mock('react-redux', () => { + const actual = jest.requireActual('react-redux'); + return { + ...actual, + useSelector: (selector: (state: unknown) => unknown): unknown => { + const result = actual.useSelector(selector); + if (result && typeof result === 'object' && 'loading' in result) { + return { ...result, loading: false }; + } + return result; + }, + }; +}); + +// List columns come from the options menu (server-synced preferences). Pin them +// so the query fires and the expected columns render, independent of that API. +jest.mock('container/OptionsMenu/useOptionsMenu', () => ({ + __esModule: true, + default: (): unknown => ({ + options: { + selectColumns: [ + { name: 'service.name', fieldContext: 'resource' }, + { name: 'name', fieldContext: 'span' }, + { name: 'duration_nano', fieldContext: 'span' }, + { name: 'http_method', fieldContext: 'span' }, + { name: 'response_status_code', fieldContext: 'span' }, + ], + }, + config: { addColumn: { onRemove: jest.fn() } }, + }), +})); + +const BASE_URL = ENVIRONMENT.baseURL; +const QUERY_RANGE_URL = `${BASE_URL}/api/v5/query_range`; + +const listRows = [ + { + timestamp: '2024-07-19T08:39:58.735245Z', + data: { + 'service.name': 'frontend', + name: 'HTTP GET', + duration_nano: 55306000, + http_method: 'GET', + response_status_code: '200', + span_id: '772c4d29dd9076ac', + trace_id: '0000000000000000344ded1387b08a7e', + }, + }, + { + timestamp: '2024-07-19T08:39:59.949129915Z', + data: { + 'service.name': 'demo-app', + name: 'authenticate_check_db', + duration_nano: 790949390, + // empty status fields to assert the "-" cell + http_method: '', + response_status_code: '', + span_id: '5704353737b6778e', + trace_id: 'a364a8e15af3e9a8c866e0528db8b637', + }, + }, +]; + +const listResponse = (rows: unknown[]): Record => ({ + data: { type: 'raw', data: { results: [{ queryName: 'A', rows }] } }, +}); + +const mockSuccess = (rows: unknown[] = listRows): void => { + server.use( + rest.post(QUERY_RANGE_URL, (_req, res, ctx) => + res(ctx.status(200), ctx.json(listResponse(rows))), + ), + ); +}; + +const renderListView = (): ReturnType => + render( + + + , + {}, + { + initialRoute: '/traces-explorer', + queryBuilderOverrides: { + panelType: PANEL_TYPES.LIST, + stagedQuery: initialQueriesMap.traces, + currentQuery: initialQueriesMap.traces, + redirectWithQueryBuilderData: jest.fn(), + } as any, + }, + ); + +describe('Traces ListView - Data Loaded', () => { + afterEach(() => { + server.resetHandlers(); + }); + + it('renders backend rows in FieldCell format', async () => { + mockSuccess(); + renderListView(); + + // plain-text columns + await expect(screen.findByText('frontend')).resolves.toBeInTheDocument(); + expect(screen.getByText('authenticate_check_db')).toBeInTheDocument(); + + // duration_nano renders in milliseconds + expect(screen.getAllByTestId('duration_nano')[0]).toHaveTextContent(/ms$/); + + // http_method / response_status_code render as badges + expect(screen.getAllByTestId('http_method')[0]).toHaveTextContent('GET'); + expect(screen.getAllByTestId('response_status_code')[0]).toHaveTextContent( + '200', + ); + + // empty status fields render "-" + expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/frontend/src/container/TracesExplorer/TracesTable/FieldCell.tsx b/frontend/src/container/TracesExplorer/TracesTable/FieldCell.tsx index 7a90c952f9b..f7393f294ec 100644 --- a/frontend/src/container/TracesExplorer/TracesTable/FieldCell.tsx +++ b/frontend/src/container/TracesExplorer/TracesTable/FieldCell.tsx @@ -1,6 +1,8 @@ +import { generatePath, Link } from 'react-router-dom'; import { Badge } from '@signozhq/ui/badge'; import TanStackTable from 'components/TanStackTableView'; import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats'; +import ROUTES from 'constants/routes'; import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util'; import { useTimezone } from 'providers/Timezone'; @@ -8,6 +10,7 @@ import { DURATION_FIELD_NAMES, STATUS_FIELD_NAMES, TIMESTAMP_FIELD_NAMES, + TRACE_ID_FIELD_NAMES, } from './constants'; import { stringifyCellValue } from './utils'; @@ -38,6 +41,18 @@ function FieldCell({ name, value }: FieldCellProps): JSX.Element { const text = stringifyCellValue(value); + if (TRACE_ID_FIELD_NAMES.has(name)) { + return ( + e.stopPropagation()} + > + {text} + + ); + } + if (STATUS_FIELD_NAMES.has(name)) { return ( diff --git a/frontend/src/container/TracesExplorer/TracesTable/TracesTable.tsx b/frontend/src/container/TracesExplorer/TracesTable/TracesTable.tsx index a0bf0cdaad5..b449325b041 100644 --- a/frontend/src/container/TracesExplorer/TracesTable/TracesTable.tsx +++ b/frontend/src/container/TracesExplorer/TracesTable/TracesTable.tsx @@ -19,7 +19,8 @@ import styles from './TracesTable.module.scss'; export type TracesTableProps = { data: TracesTableRow[]; columns: TableColumnDef[]; - columnStorageKey: string; + columnStorageKey?: string; + respectColumnOrder?: boolean; panelType: PanelTypeKeys; /** Builds the trace-detail href for a row; drives row click + cmd/ctrl-click. */ getRowHref: (row: TracesTableRow) => string; @@ -37,6 +38,7 @@ function TracesTable({ data, columns, columnStorageKey, + respectColumnOrder = false, panelType, getRowHref, isLoading, @@ -88,7 +90,7 @@ function TracesTable({ columns={columns} className={styles.tracesTable} columnStorageKey={columnStorageKey} - respectColumnOrder={false} + respectColumnOrder={respectColumnOrder} isLoading={isFetching} cellTypographySize={cellTypographySize} onColumnOrderChange={onColumnOrderChange} @@ -104,6 +106,8 @@ function TracesTable({ } TracesTable.defaultProps = { + columnStorageKey: undefined, + respectColumnOrder: false, onColumnOrderChange: undefined, onColumnRemove: undefined, cellTypographySize: 'medium', diff --git a/frontend/src/container/TracesExplorer/TracesTable/constants.ts b/frontend/src/container/TracesExplorer/TracesTable/constants.ts index 60867477730..6a911eaa235 100644 --- a/frontend/src/container/TracesExplorer/TracesTable/constants.ts +++ b/frontend/src/container/TracesExplorer/TracesTable/constants.ts @@ -5,8 +5,14 @@ export const TIMESTAMP_FIELD_NAMES = new Set(['timestamp']); export const STATUS_FIELD_NAMES = new Set([ 'httpMethod', 'http_method', + 'http.method', + 'http.request.method', 'responseStatusCode', 'response_status_code', + 'http.status_code', + 'http.response.status_code', ]); export const DURATION_FIELD_NAMES = new Set(['durationNano', 'duration_nano']); + +export const TRACE_ID_FIELD_NAMES = new Set(['traceID', 'trace_id']); diff --git a/frontend/src/container/TracesExplorer/TracesView/TracesView.module.scss b/frontend/src/container/TracesExplorer/TracesView/TracesView.module.scss new file mode 100644 index 00000000000..7e358c29829 --- /dev/null +++ b/frontend/src/container/TracesExplorer/TracesView/TracesView.module.scss @@ -0,0 +1,15 @@ +.container { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + // Page chain isn't a flex column, so anchor the virtualized table against the viewport. + height: calc(100vh - 240px); + min-height: 400px; +} + +.actionsContainer { + display: flex; + justify-content: space-between; + align-items: center; +} diff --git a/frontend/src/container/TracesExplorer/TracesView/configs.tsx b/frontend/src/container/TracesExplorer/TracesView/configs.tsx index be1388453ad..a9f1ce30326 100644 --- a/frontend/src/container/TracesExplorer/TracesView/configs.tsx +++ b/frontend/src/container/TracesExplorer/TracesView/configs.tsx @@ -1,50 +1,25 @@ -import { generatePath, Link } from 'react-router-dom'; -import type { TableColumnsType as ColumnsType } from 'antd'; -import { Typography } from '@signozhq/ui/typography'; -import ROUTES from 'constants/routes'; -import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util'; +import { TelemetryFieldKey } from 'api/v5/v5'; +import type { TableColumnDef } from 'components/TanStackTableView/types'; +import { + getFieldColumn, + TracesTableRow, +} from 'container/TracesExplorer/TracesTable/getFieldColumn'; import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination'; -import { ListItem } from 'types/api/widgets/getQuery'; export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS]; -export const columns: ColumnsType = [ - { - title: 'Root Service Name', - dataIndex: 'service.name', - key: 'serviceName', - }, - { - title: 'Root Operation Name', - dataIndex: 'name', - key: 'name', - }, - { - title: 'Root Duration (in ms)', - dataIndex: 'duration_nano', - key: 'durationNano', - render: (duration: number): JSX.Element => ( - {getMs(String(duration))}ms - ), - }, - { - title: 'No of Spans', - dataIndex: 'span_count', - key: 'span_count', - }, - { - title: 'TraceID', - dataIndex: 'trace_id', - key: 'traceID', - render: (traceID: string): JSX.Element => ( - - {traceID} - - ), - }, -]; +const TRACE_FIELDS = [ + { name: 'service.name', fieldContext: 'resource' }, + { name: 'name' }, + { name: 'duration_nano' }, + { name: 'span_count' }, + { name: 'trace_id' }, +] as TelemetryFieldKey[]; + +export const columns: TableColumnDef[] = TRACE_FIELDS.map( + (field) => ({ + ...getFieldColumn(field), + enableRemove: false, + canBeHidden: false, + }), +); diff --git a/frontend/src/container/TracesExplorer/TracesView/index.test.tsx b/frontend/src/container/TracesExplorer/TracesView/index.test.tsx new file mode 100644 index 00000000000..32337cb8ca9 --- /dev/null +++ b/frontend/src/container/TracesExplorer/TracesView/index.test.tsx @@ -0,0 +1,136 @@ +import { ENVIRONMENT } from 'constants/env'; +import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder'; +import { server } from 'mocks-server/server'; +import { rest } from 'msw'; +import { VirtuosoMockContext } from 'react-virtuoso'; +import { render, screen, waitFor } from 'tests/test-utils'; + +import TracesView from './index'; + +const BASE_URL = ENVIRONMENT.baseURL; +const QUERY_RANGE_URL = `${BASE_URL}/api/v5/query_range`; + +const groupedRows = [ + { + timestamp: '2024-07-19T08:39:58.735245Z', + data: { + 'service.name': 'frontend', + name: 'HTTP GET', + duration_nano: 55306000, + span_count: 8, + trace_id: '0000000000000000344ded1387b08a7e', + }, + }, + { + timestamp: '2024-07-19T08:39:59.949129915Z', + data: { + 'service.name': 'demo-app', + // intentionally empty to assert the "-" cell + name: '', + duration_nano: 790949390, + span_count: 3, + trace_id: 'a364a8e15af3e9a8c866e0528db8b637', + }, + }, +]; + +const groupedResponse = (rows: unknown[]): Record => ({ + data: { type: 'trace', data: { results: [{ queryName: 'A', rows }] } }, +}); + +const mockSuccess = (rows: unknown[] = groupedRows): void => { + server.use( + rest.post(QUERY_RANGE_URL, (_req, res, ctx) => + res(ctx.status(200), ctx.json(groupedResponse(rows))), + ), + ); +}; + +const mockError = (): void => { + server.use( + rest.post(QUERY_RANGE_URL, (_req, res, ctx) => + res(ctx.status(500), ctx.json({ status: 'error', error: 'boom' })), + ), + ); +}; + +const renderTracesView = ( + props: Record = {}, +): ReturnType => + render( + + + , + {}, + { + initialRoute: '/traces-explorer', + queryBuilderOverrides: { + panelType: PANEL_TYPES.TRACE, + stagedQuery: initialQueriesMap.traces, + currentQuery: initialQueriesMap.traces, + } as any, + }, + ); + +describe('TracesView (grouped root-span table)', () => { + afterEach(() => { + server.resetHandlers(); + }); + + it('renders backend rows in FieldCell format', async () => { + mockSuccess(); + renderTracesView(); + + // service.name + name render as plain text + await expect(screen.findByText('frontend')).resolves.toBeInTheDocument(); + expect(screen.getByText('HTTP GET')).toBeInTheDocument(); + + // duration_nano renders in milliseconds + expect(screen.getAllByTestId('duration_nano')[0]).toHaveTextContent(/ms$/); + + // span_count renders as text + expect(screen.getByText('8')).toBeInTheDocument(); + + // empty field renders "-" + expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(1); + + // trace_id renders as a link to the trace detail + const traceLinks = screen.getAllByTestId('trace-id'); + expect(traceLinks[0]).toHaveAttribute( + 'href', + expect.stringContaining('/trace/0000000000000000344ded1387b08a7e'), + ); + }); + + it('shows the empty state and keeps the toolbar when there are no rows', async () => { + mockSuccess([]); + renderTracesView(); + + // toolbar (un-gated) stays visible regardless of data + expect( + screen.getByText(/This tab only shows Root Spans/i), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /previous/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByText(/No traces yet/i)).toBeInTheDocument(); + }); + }); + + it('keeps the toolbar visible on API error', async () => { + mockError(); + renderTracesView(); + + expect( + screen.getByText(/This tab only shows Root Spans/i), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /previous/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/container/TracesExplorer/TracesView/index.tsx b/frontend/src/container/TracesExplorer/TracesView/index.tsx index bfab81521e2..8c595b6c182 100644 --- a/frontend/src/container/TracesExplorer/TracesView/index.tsx +++ b/frontend/src/container/TracesExplorer/TracesView/index.tsx @@ -1,4 +1,3 @@ -/* eslint-disable sonarjs/cognitive-complexity */ import { Dispatch, memo, @@ -12,30 +11,29 @@ import { useSelector } from 'react-redux'; import { Typography } from '@signozhq/ui/typography'; import logEvent from 'api/common/logEvent'; import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu'; -import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace'; -import { ResizeTable } from 'components/ResizeTable'; import { ENTITY_VERSION_V5 } from 'constants/app'; +import { LOCALSTORAGE } from 'constants/localStorage'; import { QueryParams } from 'constants/query'; import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder'; import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; -import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch'; -import NoLogs from 'container/NoLogs/NoLogs'; import { getListViewQuery } from 'container/TracesExplorer/explorerUtils'; +import { getTraceLink } from 'container/TracesExplorer/ListView/utils'; +import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable'; +import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn'; import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { Pagination } from 'hooks/queryPagination'; import useUrlQueryData from 'hooks/useUrlQueryData'; import { AppState } from 'store/reducers'; import { Warning } from 'types/api'; -import APIError from 'types/api/error'; import { DataSource } from 'types/common/queryBuilder'; import { GlobalReducer } from 'types/reducer/globalTime'; import DOCLINKS from 'utils/docLinks'; import TraceExplorerControls from '../Controls'; -import { TracesLoading } from '../TraceLoading/TraceLoading'; import { columns, PER_PAGE_OPTIONS } from './configs'; -import { ActionsContainer, Container } from './styles'; + +import styles from './TracesView.module.scss'; interface TracesViewProps { isFilterApplied: boolean; @@ -119,8 +117,13 @@ function TracesView({ }, [data?.payload, data?.warning]); const responseData = data?.payload?.data?.newResult?.data?.result[0]?.list; - const tableData = useMemo( - () => responseData?.map((listItem) => listItem.data), + + const rows = useMemo( + () => + (responseData ?? []).map((item) => { + const row = item.data; + return { ...row, id: row.trace_id }; + }) as TracesTableRow[], [responseData], ); @@ -133,71 +136,52 @@ function TracesView({ }, [isLoading, isFetching, setIsLoadingQueries]); useEffect(() => { - if (!isLoading && !isFetching && !isError && (tableData || []).length !== 0) { - logEvent('Traces Explorer: Data present', { + if (!isLoading && !isFetching && !isError && rows.length !== 0) { + void logEvent('Traces Explorer: Data present', { panelType: 'TRACE', }); } - }, [isLoading, isFetching, isError, panelType, tableData]); + }, [isLoading, isFetching, isError, rows.length]); return ( - - {(tableData || []).length !== 0 && ( - - - This tab only shows Root Spans. More details - - {' '} - here - - - -
- - - -
-
- )} - - {isError && error && } - - {(isLoading || (isFetching && (tableData || []).length === 0)) && ( - - )} - - {!isLoading && - !isFetching && - !isError && - !isFilterApplied && - (tableData || []).length === 0 && } - - {!isLoading && - !isFetching && - (tableData || []).length === 0 && - !isError && - isFilterApplied && ( - - )} - - {(tableData || []).length !== 0 && ( - - )} -
+
+
+ + This tab only shows Root Spans. More details + + {' '} + here + + + +
+ + + +
+
+ + +
); } diff --git a/frontend/src/container/TracesExplorer/TracesView/styles.ts b/frontend/src/container/TracesExplorer/TracesView/styles.ts deleted file mode 100644 index 5acf6309a66..00000000000 --- a/frontend/src/container/TracesExplorer/TracesView/styles.ts +++ /dev/null @@ -1,12 +0,0 @@ -import styled from 'styled-components'; - -export const Container = styled.div` - display: flex; - flex-direction: column; -`; - -export const ActionsContainer = styled.div` - display: flex; - justify-content: space-between; - align-items: center; -`;