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); +}