From fe68b8e8b736f3c50e2015f1baffc219c954a121 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Mon, 24 Aug 2026 16:32:44 +0000 Subject: [PATCH] feat(traces): table migration to tanstack for list view in traces explorer (#12667) #### Description - moved list view from antd `ResizeTable` to Tanstack table. functionalities kept same. - pulled out a reusable trace table. new shared table + per field column builder. This is added to keep the table renderer common for both ListView and Trace View because they do not need to be different. Trace view will integrate this component in following stacked PR. - two new override vars on `TanStackTableView` (header height, first column header padding) #### Issues closed by this PR Part of https://github.com/SigNoz/engineering-pod/issues/5052 #### Screenshots / Screen Recordings https://github.com/user-attachments/assets/d3a75b38-7cf5-4ab0-a7b4-fce404a03e63 #### Additional Information - Touches the shared `TanStackTableView` component.. two new override vars, defaults unchanged for other tables. cc. @H4ad --- .../TanStackHeaderRow.module.scss | 7 + .../TanStackTable.module.scss | 2 +- .../ListView/ListView.module.scss | 8 ++ .../TracesExplorer/ListView/configs.tsx | 7 + .../TracesExplorer/ListView/index.tsx | 124 +++++++----------- .../TracesExplorer/ListView/utils.tsx | 36 ++++- .../TracesExplorer/TracesTable/FieldCell.tsx | 62 +++++++++ .../TracesTable/TracesTable.module.scss | 26 ++++ .../TracesTable/TracesTable.tsx | 112 ++++++++++++++++ .../TracesExplorer/TracesTable/constants.ts | 12 ++ .../TracesTable/getFieldColumn.tsx | 26 ++++ .../TracesExplorer/TracesTable/utils.ts | 12 ++ 12 files changed, 355 insertions(+), 79 deletions(-) create mode 100644 frontend/src/container/TracesExplorer/ListView/ListView.module.scss create mode 100644 frontend/src/container/TracesExplorer/TracesTable/FieldCell.tsx create mode 100644 frontend/src/container/TracesExplorer/TracesTable/TracesTable.module.scss create mode 100644 frontend/src/container/TracesExplorer/TracesTable/TracesTable.tsx create mode 100644 frontend/src/container/TracesExplorer/TracesTable/constants.ts create mode 100644 frontend/src/container/TracesExplorer/TracesTable/getFieldColumn.tsx create mode 100644 frontend/src/container/TracesExplorer/TracesTable/utils.ts diff --git a/frontend/src/components/TanStackTableView/TanStackHeaderRow.module.scss b/frontend/src/components/TanStackTableView/TanStackHeaderRow.module.scss index 98ee1f6aa1f..b0203d59596 100644 --- a/frontend/src/components/TanStackTableView/TanStackHeaderRow.module.scss +++ b/frontend/src/components/TanStackTableView/TanStackHeaderRow.module.scss @@ -44,6 +44,13 @@ --tanstack-first-column-header-bg, var(--tanstack-table-header-cell-bg, var(--l2-background)) ) !important; + padding-left: var( + --tanstack-cell-header-padding-left-first-column, + var( + --tanstack-cell-header-padding-left-override, + var(--tanstack-cell-padding-left, 0.3rem) + ) + ); } } diff --git a/frontend/src/components/TanStackTableView/TanStackTable.module.scss b/frontend/src/components/TanStackTableView/TanStackTable.module.scss index 441178e0b9a..42b398ed468 100644 --- a/frontend/src/components/TanStackTableView/TanStackTable.module.scss +++ b/frontend/src/components/TanStackTableView/TanStackTable.module.scss @@ -161,7 +161,7 @@ .tableHeaderCell { padding: var(--tanstack-cell-padding-top) var(--tanstack-cell-padding-right) var(--tanstack-cell-padding-bottom) var(--tanstack-cell-padding-left); - height: 36px; + height: var(--tanstack-table-header-height, 36px); text-align: left; font-size: 14px; font-style: normal; diff --git a/frontend/src/container/TracesExplorer/ListView/ListView.module.scss b/frontend/src/container/TracesExplorer/ListView/ListView.module.scss new file mode 100644 index 00000000000..d98732d1ff4 --- /dev/null +++ b/frontend/src/container/TracesExplorer/ListView/ListView.module.scss @@ -0,0 +1,8 @@ +.container { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + height: calc(100vh - 240px); + min-height: 400px; +} diff --git a/frontend/src/container/TracesExplorer/ListView/configs.tsx b/frontend/src/container/TracesExplorer/ListView/configs.tsx index 298a938057d..11d6a5a9b8d 100644 --- a/frontend/src/container/TracesExplorer/ListView/configs.tsx +++ b/frontend/src/container/TracesExplorer/ListView/configs.tsx @@ -1,3 +1,4 @@ +import type { TelemetryFieldKey } from 'api/v5/v5'; import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination'; export const defaultSelectedColumns: string[] = [ @@ -10,3 +11,9 @@ export const defaultSelectedColumns: string[] = [ ]; export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS]; + +// Pinned timestamp column +export const TIMESTAMP_FIELD = { + name: 'timestamp', + fieldContext: 'span', +} as TelemetryFieldKey; diff --git a/frontend/src/container/TracesExplorer/ListView/index.tsx b/frontend/src/container/TracesExplorer/ListView/index.tsx index c28b5b23388..ada647cf203 100644 --- a/frontend/src/container/TracesExplorer/ListView/index.tsx +++ b/frontend/src/container/TracesExplorer/ListView/index.tsx @@ -12,16 +12,18 @@ import { import { useSelector } from 'react-redux'; import logEvent from 'api/common/logEvent'; import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu'; -import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace'; import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy'; -import { ResizeTable } from 'components/ResizeTable'; +import type { TableColumnDef } from 'components/TanStackTableView/types'; +import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable'; +import { + getFieldColumn, + TracesTableRow, +} from 'container/TracesExplorer/TracesTable/getFieldColumn'; 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 { useOptionsMenu } from 'container/OptionsMenu'; import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types'; import TraceExplorerControls from 'container/TracesExplorer/Controls'; @@ -32,20 +34,22 @@ import { Pagination } from 'hooks/queryPagination'; import { getDefaultPaginationConfig } from 'hooks/queryPagination/utils'; import useUrlQueryData from 'hooks/useUrlQueryData'; import { ArrowUp10, Minus } from '@signozhq/icons'; -import { useTimezone } from 'providers/Timezone'; 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 { TracesLoading } from '../TraceLoading/TraceLoading'; -import { defaultSelectedColumns, PER_PAGE_OPTIONS } from './configs'; -import { Container, tableStyles } from './styles'; -import { getListColumns, transformDataWithDate } from './utils'; +import { + defaultSelectedColumns, + PER_PAGE_OPTIONS, + TIMESTAMP_FIELD, +} from './configs'; +import { getTraceLink, transformSpanRows } from './utils'; import './ListView.styles.scss'; +import styles from './ListView.module.scss'; + interface ListViewProps { isFilterApplied: boolean; setWarning: Dispatch>; @@ -93,7 +97,7 @@ function ListView({ [stagedQuery, orderBy], ); - // TEMP — remove after traces moves to TanStack table. + // Stable sorted-name signature for the queryKey. // - Drag updates selectColumns; raw queryKey would churn on reorder. // - Trace API fetches only listed columns → add/remove must refetch. // - Sorted-name signature: stable on reorder, changes on add/remove. @@ -186,60 +190,42 @@ function ListView({ [queryTableDataResult], ); - const { formatTimezoneAdjustedTimestamp } = useTimezone(); - - const columns = useMemo( - () => - getListColumns( - options?.selectColumns || [], - formatTimezoneAdjustedTimestamp, + const columns = useMemo[]>(() => { + const fields = [ + TIMESTAMP_FIELD, + ...(options?.selectColumns ?? []).filter( + (field) => field.name !== TIMESTAMP_FIELD.name, ), - [options?.selectColumns, formatTimezoneAdjustedTimestamp], - ); + ]; + return fields.map((field) => getFieldColumn(field)); + }, [options?.selectColumns]); - const transformedQueryTableData = useMemo( - () => transformDataWithDate(queryTableData) || [], + const rows = useMemo( + () => transformSpanRows(queryTableData), [queryTableData], ); - const handleDragColumn = useCallback( - (fromIndex: number, toIndex: number): void => { - const reordered = [...columns]; - const [moved] = reordered.splice(fromIndex, 1); - reordered.splice(toIndex, 0, moved); - // `key` is the composite (fieldContext.name) — disambiguates same-name fields. - const orderedIds = reordered - .map((c) => String(c.key || ('dataIndex' in c && c.dataIndex) || '')) - .filter(Boolean); - config?.addColumn?.onReorder(orderedIds); + const handleColumnOrderChange = useCallback( + (cols: TableColumnDef[]): void => { + config?.addColumn?.onReorder(cols.map((c) => c.id)); }, - [columns, config], + [config], ); const handleOrderChange = useCallback((value: string) => { setOrderBy(value); }, []); - const isDataAbsent = - !isLoading && - !isFetching && - !isError && - transformedQueryTableData.length === 0; - useEffect(() => { - if ( - !isLoading && - !isFetching && - !isError && - transformedQueryTableData.length !== 0 - ) { - logEvent('Traces Explorer: Data present', { + if (!isLoading && !isFetching && !isError && rows.length !== 0) { + void logEvent('Traces Explorer: Data present', { panelType, }); } - }, [isLoading, isFetching, isError, transformedQueryTableData, panelType]); + }, [isLoading, isFetching, isError, rows, panelType]); + return ( - +
@@ -266,33 +252,21 @@ function ListView({ />
- {isError && error && } - - {(isLoading || (isFetching && transformedQueryTableData.length === 0)) && ( - - )} - - {isDataAbsent && !isFilterApplied && ( - - )} - - {isDataAbsent && isFilterApplied && ( - - )} - - {!isError && transformedQueryTableData.length !== 0 && ( - - )} - + +
); } diff --git a/frontend/src/container/TracesExplorer/ListView/utils.tsx b/frontend/src/container/TracesExplorer/ListView/utils.tsx index d7b1b9d3d9c..9a20f4ef128 100644 --- a/frontend/src/container/TracesExplorer/ListView/utils.tsx +++ b/frontend/src/container/TracesExplorer/ListView/utils.tsx @@ -3,6 +3,7 @@ import type { TableColumnsType as ColumnsType } from 'antd'; import { Badge } from '@signozhq/ui/badge'; import { Typography } from '@signozhq/ui/typography'; import { TelemetryFieldKey } from 'api/v5/v5'; +import type { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn'; import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats'; import ROUTES from 'constants/routes'; import { buildCompositeKey } from 'container/OptionsMenu/utils'; @@ -41,12 +42,23 @@ export const transformDataWithDate = ( data[0]?.list?.map(({ data, timestamp }) => ({ ...data, date: timestamp })) || []; -export const getTraceLink = (record: RowData): string => - `${ROUTES.TRACE}/${record.traceID || record.trace_id}${formUrlParams({ - spanId: record.spanID || record.span_id, +export const getTraceLink = (record: Record): string => { + function readId(value: unknown): string { + if (typeof value === 'string' || typeof value === 'number') { + return String(value); + } + return ''; + } + + const traceId = readId(record.traceID) || readId(record.trace_id); + const spanId = readId(record.spanID) || readId(record.span_id); + + return `${ROUTES.TRACE}/${traceId}${formUrlParams({ + spanId, levelUp: 0, levelDown: 0, })}`; +}; export const getListColumns = ( selectedColumns: TelemetryFieldKey[], @@ -136,3 +148,21 @@ export const getListColumns = ( return [...initialColumns, ...columns]; }; + +// Reshapes the query-range list payload into table rows. `id` mirrors span_id so +// TanStack sees genuine row changes on orderBy toggles instead of falling back to +// positional ids; `timestamp` is lifted from the wrapping ListItem. +export const transformSpanRows = (data: QueryDataV3[]): TracesTableRow[] => { + const list = data[0]?.list; + if (!list) { + return []; + } + return list.map((item) => { + const row = item.data as Record; + return { + ...row, + timestamp: item.timestamp, + id: row.span_id, + }; + }) as TracesTableRow[]; +}; diff --git a/frontend/src/container/TracesExplorer/TracesTable/FieldCell.tsx b/frontend/src/container/TracesExplorer/TracesTable/FieldCell.tsx new file mode 100644 index 00000000000..7a90c952f9b --- /dev/null +++ b/frontend/src/container/TracesExplorer/TracesTable/FieldCell.tsx @@ -0,0 +1,62 @@ +import { Badge } from '@signozhq/ui/badge'; +import TanStackTable from 'components/TanStackTableView'; +import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats'; +import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util'; +import { useTimezone } from 'providers/Timezone'; + +import { + DURATION_FIELD_NAMES, + STATUS_FIELD_NAMES, + TIMESTAMP_FIELD_NAMES, +} from './constants'; +import { stringifyCellValue } from './utils'; + +type FieldCellProps = { + name: string; + value: unknown; +}; + +function FieldCell({ name, value }: FieldCellProps): JSX.Element { + const { formatTimezoneAdjustedTimestamp } = useTimezone(); + + if (TIMESTAMP_FIELD_NAMES.has(name)) { + const ts = value as string | number; + const formatted = + typeof ts === 'string' + ? formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.ISO_DATETIME_MS) + : formatTimezoneAdjustedTimestamp( + ts / 1e6, + DATE_TIME_FORMATS.ISO_DATETIME_MS, + ); + const text = String(formatted); + return {text}; + } + + if (value === '' || value == null) { + return -; + } + + const text = stringifyCellValue(value); + + if (STATUS_FIELD_NAMES.has(name)) { + return ( + + {text} + + ); + } + + if (DURATION_FIELD_NAMES.has(name)) { + return ( + {getMs(text)}ms + ); + } + + return ( + + {text} + + ); +} + +export default FieldCell; diff --git a/frontend/src/container/TracesExplorer/TracesTable/TracesTable.module.scss b/frontend/src/container/TracesExplorer/TracesTable/TracesTable.module.scss new file mode 100644 index 00000000000..e96d6cd3913 --- /dev/null +++ b/frontend/src/container/TracesExplorer/TracesTable/TracesTable.module.scss @@ -0,0 +1,26 @@ +.tableWrapper { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.tracesTable { + --tanstack-table-row-height: 54px; + --tanstack-table-header-height: 54px; + + --tanstack-cell-padding-top-override: 5px; + --tanstack-cell-padding-bottom-override: 5px; + --tanstack-cell-padding-right-override: 15px; + + --tanstack-cell-padding-left-override: 15px; + --tanstack-cell-header-padding-left-override: 5px; + + --tanstack-cell-header-padding-left-first-column: 15px; + + --tanstack-plain-body-line-clamp: 1; + + --tanstack-table-cell-bg: var(--l2-background); + --tanstack-table-header-cell-bg: var(--l1-background-hover); + --tanstack-table-row-hover-bg: var(--l1-background-hover); +} diff --git a/frontend/src/container/TracesExplorer/TracesTable/TracesTable.tsx b/frontend/src/container/TracesExplorer/TracesTable/TracesTable.tsx new file mode 100644 index 00000000000..a0bf0cdaad5 --- /dev/null +++ b/frontend/src/container/TracesExplorer/TracesTable/TracesTable.tsx @@ -0,0 +1,112 @@ +import { useCallback } from 'react'; +import { useHistory } from 'react-router-dom'; +import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace'; +import TanStackTable from 'components/TanStackTableView'; +import type { + CellTypographySize, + TableColumnDef, +} from 'components/TanStackTableView/types'; +import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch'; +import NoLogs from 'container/NoLogs/NoLogs'; +import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading'; +import APIError from 'types/api/error'; +import { DataSource, PanelTypeKeys } from 'types/common/queryBuilder'; +import { getAbsoluteUrl } from 'utils/basePath'; + +import type { TracesTableRow } from './getFieldColumn'; +import styles from './TracesTable.module.scss'; + +export type TracesTableProps = { + data: TracesTableRow[]; + columns: TableColumnDef[]; + columnStorageKey: string; + panelType: PanelTypeKeys; + /** Builds the trace-detail href for a row; drives row click + cmd/ctrl-click. */ + getRowHref: (row: TracesTableRow) => string; + isLoading: boolean; + isFetching: boolean; + isError: boolean; + error: APIError | Error | null; + isFilterApplied: boolean; + onColumnOrderChange?: (cols: TableColumnDef[]) => void; + onColumnRemove?: (columnId: string) => void; + cellTypographySize?: CellTypographySize; +}; + +function TracesTable({ + data, + columns, + columnStorageKey, + panelType, + getRowHref, + isLoading, + isFetching, + isError, + error, + isFilterApplied, + onColumnOrderChange, + onColumnRemove, + cellTypographySize = 'medium', +}: TracesTableProps): JSX.Element { + const history = useHistory(); + + const isDataAbsent = + !isLoading && !isFetching && !isError && data.length === 0; + + const handleRowClick = useCallback( + (row: TracesTableRow): void => { + history.push(getRowHref(row)); + }, + [history, getRowHref], + ); + + const handleRowClickNewTab = useCallback( + (row: TracesTableRow): void => { + window.open(getAbsoluteUrl(getRowHref(row)), '_blank', 'noopener'); + }, + [getRowHref], + ); + + return ( + <> + {isError && error && } + + {(isLoading || (isFetching && data.length === 0)) && } + + {isDataAbsent && !isFilterApplied && ( + + )} + + {isDataAbsent && isFilterApplied && ( + + )} + + {!isError && data.length !== 0 && ( +
+ + data={data} + columns={columns} + className={styles.tracesTable} + columnStorageKey={columnStorageKey} + respectColumnOrder={false} + isLoading={isFetching} + cellTypographySize={cellTypographySize} + onColumnOrderChange={onColumnOrderChange} + onColumnRemove={onColumnRemove} + onRowClick={handleRowClick} + onRowClickNewTab={handleRowClickNewTab} + getRowTestId={(row): string => `traces-table-row-${row.id}`} + /> +
+ )} + + ); +} + +TracesTable.defaultProps = { + onColumnOrderChange: undefined, + onColumnRemove: undefined, + cellTypographySize: 'medium', +}; + +export default TracesTable; diff --git a/frontend/src/container/TracesExplorer/TracesTable/constants.ts b/frontend/src/container/TracesExplorer/TracesTable/constants.ts new file mode 100644 index 00000000000..60867477730 --- /dev/null +++ b/frontend/src/container/TracesExplorer/TracesTable/constants.ts @@ -0,0 +1,12 @@ +// Field-name allowlists that drive signal-specific cell rendering. Both legacy +// camelCase and snake_case variants are listed because the API has shipped both. +export const TIMESTAMP_FIELD_NAMES = new Set(['timestamp']); + +export const STATUS_FIELD_NAMES = new Set([ + 'httpMethod', + 'http_method', + 'responseStatusCode', + 'response_status_code', +]); + +export const DURATION_FIELD_NAMES = new Set(['durationNano', 'duration_nano']); diff --git a/frontend/src/container/TracesExplorer/TracesTable/getFieldColumn.tsx b/frontend/src/container/TracesExplorer/TracesTable/getFieldColumn.tsx new file mode 100644 index 00000000000..1dfdebe7015 --- /dev/null +++ b/frontend/src/container/TracesExplorer/TracesTable/getFieldColumn.tsx @@ -0,0 +1,26 @@ +import { TelemetryFieldKey } from 'api/v5/v5'; +import type { TableColumnDef } from 'components/TanStackTableView/types'; +import { buildCompositeKey } from 'container/OptionsMenu/utils'; + +import { TIMESTAMP_FIELD_NAMES } from './constants'; +import FieldCell from './FieldCell'; + +export type TracesTableRow = { id: string } & Record; + +export function getFieldColumn( + field: TelemetryFieldKey, +): TableColumnDef { + const { name, fieldContext } = field; + const isTimestamp = TIMESTAMP_FIELD_NAMES.has(name); + + return { + id: buildCompositeKey(name, fieldContext), + header: name, + accessorFn: (row): unknown => row[name], + enableMove: !isTimestamp, + enableRemove: !isTimestamp, + canBeHidden: !isTimestamp, + width: { min: 192 }, + cell: ({ value }): JSX.Element => , + }; +} diff --git a/frontend/src/container/TracesExplorer/TracesTable/utils.ts b/frontend/src/container/TracesExplorer/TracesTable/utils.ts new file mode 100644 index 00000000000..bd7299c3a42 --- /dev/null +++ b/frontend/src/container/TracesExplorer/TracesTable/utils.ts @@ -0,0 +1,12 @@ +export function stringifyCellValue(value: unknown): string { + if (value == null) { + return ''; + } + if (typeof value === 'string') { + return value; + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + return JSON.stringify(value); +}