mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-09 10:30:27 +01:00
Compare commits
3 Commits
main
...
feat/trace
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9622c867a2 | ||
|
|
a2e75cf5ba | ||
|
|
b2cba2aa2c |
@@ -192,7 +192,7 @@ function FieldsSelector({
|
||||
() =>
|
||||
fields.map((f) => ({
|
||||
...f,
|
||||
key: f.key ?? buildCompositeKey(f.name, f.fieldContext),
|
||||
key: f.key ?? buildCompositeKey(f.name, f.fieldContext, f.fieldDataType),
|
||||
})),
|
||||
[fields],
|
||||
);
|
||||
|
||||
@@ -52,14 +52,20 @@ function OtherFields({
|
||||
const normalizedSuggestions: TelemetryFieldKey[] = suggestions.map(
|
||||
(attr) => ({
|
||||
...attr,
|
||||
key: buildCompositeKey(attr.name, attr.fieldContext as string),
|
||||
key: buildCompositeKey(
|
||||
attr.name,
|
||||
attr.fieldContext as string,
|
||||
attr.fieldDataType as string | undefined,
|
||||
),
|
||||
signal: attr.signal as SignalType,
|
||||
fieldContext: attr.fieldContext as FieldContext,
|
||||
fieldDataType: attr.fieldDataType as FieldDataType,
|
||||
}),
|
||||
);
|
||||
const addedIds = new Set(
|
||||
addedFields.map((f) => f.key ?? buildCompositeKey(f.name, f.fieldContext)),
|
||||
addedFields.map(
|
||||
(f) => f.key ?? buildCompositeKey(f.name, f.fieldContext, f.fieldDataType),
|
||||
),
|
||||
);
|
||||
return normalizedSuggestions.filter(
|
||||
(attr) => !addedIds.has(attr.key as string),
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useMemo, type ReactElement } from 'react';
|
||||
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import type { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
type UseTracesTableColumnsProps<TRow> = {
|
||||
/** Pinned / always-on columns owned by the consumer (e.g. timestamp for List view, the 5 static columns for Traces grouped view). */
|
||||
baseColumns: TableColumnDef<TRow>[];
|
||||
/** Dynamic columns sourced from `selectColumns` (List view). Omit or pass [] for views without a picker (Traces grouped). */
|
||||
fields?: TelemetryFieldKey[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared column builder for the trace list view and the trace (group-by-trace) view.
|
||||
*
|
||||
* Composition: `[...baseColumns, ...fields.map(makeUserFieldCol)]`. Each view owns its
|
||||
* `baseColumns` inline so view-specific changes (timestamp formatting on list, static-column
|
||||
* cell renderers on grouped) stay localized. The shared piece is `makeUserFieldCol` — the
|
||||
* dynamic-field factory that consumes `selectColumns` for the list view.
|
||||
*/
|
||||
export function useTracesTableColumns<TRow>({
|
||||
baseColumns,
|
||||
fields = [],
|
||||
}: UseTracesTableColumnsProps<TRow>): TableColumnDef<TRow>[] {
|
||||
return useMemo<TableColumnDef<TRow>[]>(
|
||||
() => [...baseColumns, ...fields.map((f) => makeUserFieldCol<TRow>(f))],
|
||||
[baseColumns, fields],
|
||||
);
|
||||
}
|
||||
|
||||
function makeUserFieldCol<TRow>(f: TelemetryFieldKey): TableColumnDef<TRow> {
|
||||
const col: TableColumnDef<Record<string, unknown>> = {
|
||||
id: buildCompositeKey(f.name, f.fieldContext, f.fieldDataType),
|
||||
header: f.name,
|
||||
accessorFn: (row): unknown => row[f.name],
|
||||
enableRemove: true,
|
||||
width: { min: 192 },
|
||||
cell: ({ value }): ReactElement => (
|
||||
<TanStackTable.Text>{stringifyCellValue(value)}</TanStackTable.Text>
|
||||
),
|
||||
};
|
||||
return col as TableColumnDef<TRow>;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -56,7 +56,7 @@ export function dedupeColumnsByCompositeKey(
|
||||
const seen = new Set<string>();
|
||||
let hasDuplicate = false;
|
||||
const deduped = columns.filter((c) => {
|
||||
const key = buildCompositeKey(c.name, c.fieldContext);
|
||||
const key = buildCompositeKey(c.name, c.fieldContext, c.fieldDataType);
|
||||
if (seen.has(key)) {
|
||||
hasDuplicate = true;
|
||||
return false;
|
||||
|
||||
@@ -281,7 +281,8 @@ const useOptionsMenu = ({
|
||||
const handleRemoveSelectedColumn = useCallback(
|
||||
(columnKey: string) => {
|
||||
const newSelectedColumns = preferences?.columns?.filter(
|
||||
(f) => buildCompositeKey(f.name, f.fieldContext) !== columnKey,
|
||||
(f) =>
|
||||
buildCompositeKey(f.name, f.fieldContext, f.fieldDataType) !== columnKey,
|
||||
);
|
||||
|
||||
if (!newSelectedColumns?.length && dataSource !== DataSource.LOGS) {
|
||||
@@ -364,7 +365,10 @@ const useOptionsMenu = ({
|
||||
(orderedIds: string[]): void => {
|
||||
const current = preferences?.columns ?? [];
|
||||
const byCompositeKey = new Map(
|
||||
current.map((f) => [buildCompositeKey(f.name, f.fieldContext), f]),
|
||||
current.map((f) => [
|
||||
buildCompositeKey(f.name, f.fieldContext, f.fieldDataType),
|
||||
f,
|
||||
]),
|
||||
);
|
||||
const reordered = orderedIds
|
||||
.map((id) => byCompositeKey.get(id))
|
||||
|
||||
@@ -15,8 +15,15 @@ export const getOptionsFromKeys = (
|
||||
);
|
||||
};
|
||||
|
||||
// Composite identity for a column. Disambiguates same-name fields across
|
||||
// different fieldContexts (e.g. resource.service.name vs attribute.service.name).
|
||||
// Falls back to bare name when context is missing.
|
||||
export const buildCompositeKey = (name: string, context?: string): string =>
|
||||
context ? `${context}.${name}` : name;
|
||||
// Composite column id. Disambiguates same-name fields by `context` and `dataType`
|
||||
// (e.g. attribute.http.status_code ships as both number and string). Each arg
|
||||
// is appended only when truthy. `dataType` is optional — logs callers stay on
|
||||
// the 2-arg form until parity lands.
|
||||
export const buildCompositeKey = (
|
||||
name: string,
|
||||
context?: string,
|
||||
dataType?: string,
|
||||
): string => {
|
||||
const withContext = context ? `${context}.${name}` : name;
|
||||
return dataType ? `${withContext}.${dataType}` : withContext;
|
||||
};
|
||||
|
||||
@@ -2,62 +2,37 @@ import { memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Settings } from '@signozhq/icons';
|
||||
import FieldsSelector from 'components/FieldsSelector';
|
||||
import Controls, { ControlsProps } from 'container/Controls';
|
||||
import { OptionsMenuConfig } from 'container/OptionsMenu/types';
|
||||
import useQueryPagination from 'hooks/queryPagination/useQueryPagination';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import styles from './Controls.module.scss';
|
||||
|
||||
function TraceExplorerControls({
|
||||
isLoading,
|
||||
totalCount,
|
||||
perPageOptions,
|
||||
config,
|
||||
showSizeChanger = true,
|
||||
}: TraceExplorerControlsProps): JSX.Element | null {
|
||||
const { t } = useTranslation(['trace']);
|
||||
const [isFieldsSelectorOpen, setIsFieldsSelectorOpen] = useState(false);
|
||||
|
||||
const {
|
||||
pagination,
|
||||
handleCountItemsPerPageChange,
|
||||
handleNavigateNext,
|
||||
handleNavigatePrevious,
|
||||
} = useQueryPagination(totalCount, perPageOptions);
|
||||
if (!config?.fieldsSelector) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{config?.fieldsSelector && (
|
||||
<>
|
||||
<div
|
||||
className={styles.optionsTrigger}
|
||||
onClick={(): void => setIsFieldsSelectorOpen(true)}
|
||||
>
|
||||
{t('options_menu.options')}
|
||||
<Settings size="md" />
|
||||
</div>
|
||||
<FieldsSelector
|
||||
isOpen={isFieldsSelectorOpen}
|
||||
title="Edit columns"
|
||||
fields={config.fieldsSelector.value}
|
||||
onFieldsChange={config.fieldsSelector.onFieldsChange}
|
||||
onClose={(): void => setIsFieldsSelectorOpen(false)}
|
||||
signal={DataSource.TRACES}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Controls
|
||||
isLoading={isLoading}
|
||||
totalCount={totalCount}
|
||||
offset={pagination.offset}
|
||||
countPerPage={pagination.limit}
|
||||
perPageOptions={perPageOptions}
|
||||
handleCountItemsPerPageChange={handleCountItemsPerPageChange}
|
||||
handleNavigateNext={handleNavigateNext}
|
||||
handleNavigatePrevious={handleNavigatePrevious}
|
||||
showSizeChanger={showSizeChanger}
|
||||
<div
|
||||
className={styles.optionsTrigger}
|
||||
onClick={(): void => setIsFieldsSelectorOpen(true)}
|
||||
>
|
||||
{t('options_menu.options')}
|
||||
<Settings size="md" />
|
||||
</div>
|
||||
<FieldsSelector
|
||||
isOpen={isFieldsSelectorOpen}
|
||||
title="Edit columns"
|
||||
fields={config.fieldsSelector.value}
|
||||
onFieldsChange={config.fieldsSelector.onFieldsChange}
|
||||
onClose={(): void => setIsFieldsSelectorOpen(false)}
|
||||
signal={DataSource.TRACES}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -67,16 +42,8 @@ TraceExplorerControls.defaultProps = {
|
||||
config: null,
|
||||
};
|
||||
|
||||
type TraceExplorerControlsProps = Pick<
|
||||
ControlsProps,
|
||||
'isLoading' | 'totalCount' | 'perPageOptions'
|
||||
> & {
|
||||
type TraceExplorerControlsProps = {
|
||||
config?: OptionsMenuConfig | null;
|
||||
showSizeChanger?: boolean;
|
||||
};
|
||||
|
||||
TraceExplorerControls.defaultProps = {
|
||||
showSizeChanger: true,
|
||||
};
|
||||
|
||||
export default memo(TraceExplorerControls);
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
|
||||
export const defaultSelectedColumns: string[] = [
|
||||
'service.name',
|
||||
'name',
|
||||
'duration_nano',
|
||||
'http_method',
|
||||
'response_status_code',
|
||||
'timestamp',
|
||||
];
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
|
||||
@@ -54,18 +54,17 @@ const renderListView = (
|
||||
);
|
||||
};
|
||||
|
||||
// Helper to verify all controls are visible
|
||||
// Helper to verify all controls are visible.
|
||||
// Pagination controls were removed in the TanStack-table migration (infinite
|
||||
// scroll replaces page-by-page navigation), so only the order-by combobox +
|
||||
// options trigger remain in the top toolbar.
|
||||
const verifyControlsVisibility = (): void => {
|
||||
// Order by controls
|
||||
expect(screen.getByText(/Order by/i)).toBeInTheDocument();
|
||||
|
||||
// Pagination controls
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument();
|
||||
|
||||
// Items per page selector (there are multiple comboboxes, so we check for at least 2)
|
||||
// At least one combobox (order-by); page-size selector is gone post-migration.
|
||||
const comboboxes = screen.getAllByRole('combobox');
|
||||
expect(comboboxes.length).toBeGreaterThanOrEqual(2);
|
||||
expect(comboboxes.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Options menu (settings button) - check for translation key or actual text
|
||||
expect(screen.getByText(/options_menu.options|options/i)).toBeInTheDocument();
|
||||
@@ -152,15 +151,10 @@ describe('Traces ListView - Error and Empty States', () => {
|
||||
expect(screen.getByText(/Order by/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Order by controls should be interactive
|
||||
// Order-by combobox should be interactive (pagination buttons removed
|
||||
// after the TanStack migration switched List view to infinite scroll).
|
||||
const comboboxes = screen.getAllByRole('combobox');
|
||||
expect(comboboxes.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Pagination controls should be present
|
||||
const previousButton = screen.getByRole('button', { name: /previous/i });
|
||||
const nextButton = screen.getByRole('button', { name: /next/i });
|
||||
expect(previousButton).toBeInTheDocument();
|
||||
expect(nextButton).toBeInTheDocument();
|
||||
expect(comboboxes.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Options menu should be clickable
|
||||
const optionsButton = screen.getByText(/options_menu.options|options/i);
|
||||
@@ -175,9 +169,9 @@ describe('Traces ListView - Error and Empty States', () => {
|
||||
expect(screen.getByText(/No traces yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// All controls should be interactive
|
||||
// At least the order-by combobox should be interactive.
|
||||
const comboboxes = screen.getAllByRole('combobox');
|
||||
expect(comboboxes.length).toBeGreaterThanOrEqual(2);
|
||||
expect(comboboxes.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Options menu should be clickable
|
||||
const optionsButton = screen.getByText(/options_menu.options|options/i);
|
||||
|
||||
@@ -10,14 +10,16 @@ import {
|
||||
} from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { ArrowUp10, Minus } from '@signozhq/icons';
|
||||
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 TanStackTable from 'components/TanStackTableView';
|
||||
import { useTracesTableColumns } from 'components/Traces/TableView/useTracesTableColumns';
|
||||
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';
|
||||
@@ -28,24 +30,28 @@ import TraceExplorerControls from 'container/TracesExplorer/Controls';
|
||||
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
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 { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
import { TracesLoading } from '../TraceLoading/TraceLoading';
|
||||
import { defaultSelectedColumns, PER_PAGE_OPTIONS } from './configs';
|
||||
import { Container, tableStyles } from './styles';
|
||||
import { getListColumns, transformDataWithDate } from './utils';
|
||||
import { Container } from './styles';
|
||||
import {
|
||||
getTraceLink,
|
||||
makeListFieldCol,
|
||||
makeTimestampCol,
|
||||
SpanRow,
|
||||
transformSpanRows,
|
||||
} from './utils';
|
||||
|
||||
import './ListView.styles.scss';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
interface ListViewProps {
|
||||
isFilterApplied: boolean;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
@@ -59,6 +65,7 @@ function ListView({
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
}: ListViewProps): JSX.Element {
|
||||
const history = useHistory();
|
||||
const { stagedQuery, panelType: panelTypeFromQueryBuilder } =
|
||||
useQueryBuilder();
|
||||
|
||||
@@ -77,25 +84,22 @@ function ListView({
|
||||
storageKey: LOCALSTORAGE.TRACES_LIST_OPTIONS,
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'count',
|
||||
initialOptions: {
|
||||
selectColumns: defaultSelectedColumns,
|
||||
},
|
||||
});
|
||||
|
||||
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
|
||||
QueryParams.pagination,
|
||||
);
|
||||
const paginationConfig =
|
||||
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
|
||||
// Infinite-scroll state — owned by this view.
|
||||
const [pagination, setPagination] = useState<{
|
||||
offset: number;
|
||||
limit: number;
|
||||
}>({
|
||||
offset: 0,
|
||||
limit: PAGE_SIZE,
|
||||
});
|
||||
const [accumulatedRows, setAccumulatedRows] = useState<SpanRow[]>([]);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
const requestQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
|
||||
[stagedQuery, orderBy],
|
||||
);
|
||||
|
||||
// TEMP — remove after traces moves to TanStack table.
|
||||
// Stable sorted-name signature for the queryKey + reset trigger.
|
||||
// - Drag updates selectColumns; raw queryKey would churn on reorder.
|
||||
// - Trace API fetches only listed columns → add/remove must refetch.
|
||||
// - Trace API fetches only listed columns → add/remove must refetch from scratch.
|
||||
// - Sorted-name signature: stable on reorder, changes on add/remove.
|
||||
const selectColumnsSignature = useMemo(
|
||||
() =>
|
||||
@@ -106,6 +110,25 @@ function ListView({
|
||||
[options?.selectColumns],
|
||||
);
|
||||
|
||||
// Reset accumulator + offset whenever the underlying query identity changes.
|
||||
useEffect(() => {
|
||||
setPagination({ offset: 0, limit: PAGE_SIZE });
|
||||
setAccumulatedRows([]);
|
||||
setHasMore(true);
|
||||
}, [
|
||||
stagedQuery?.id,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
orderBy,
|
||||
selectColumnsSignature,
|
||||
]);
|
||||
|
||||
const requestQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
|
||||
[stagedQuery, orderBy],
|
||||
);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
@@ -114,18 +137,18 @@ function ListView({
|
||||
minTime,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationConfig,
|
||||
pagination,
|
||||
selectColumnsSignature,
|
||||
orderBy,
|
||||
],
|
||||
[
|
||||
stagedQuery,
|
||||
panelType,
|
||||
globalSelectedTime,
|
||||
paginationConfig,
|
||||
selectColumnsSignature,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
pagination,
|
||||
selectColumnsSignature,
|
||||
orderBy,
|
||||
],
|
||||
);
|
||||
@@ -144,16 +167,14 @@ function ListView({
|
||||
dataSource: 'traces',
|
||||
},
|
||||
tableParams: {
|
||||
pagination: paginationConfig,
|
||||
pagination,
|
||||
selectColumns: options?.selectColumns,
|
||||
},
|
||||
},
|
||||
// ENTITY_VERSION_V4,
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey,
|
||||
enabled:
|
||||
// don't make api call while the time range state in redux is loading
|
||||
!timeRangeUpdateLoading &&
|
||||
!!stagedQuery &&
|
||||
panelType === PANEL_TYPES.LIST &&
|
||||
@@ -168,6 +189,19 @@ function ListView({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
// Append fetched page to accumulator (replace when offset === 0).
|
||||
const responseResult = data?.payload?.data?.newResult?.data?.result;
|
||||
useEffect(() => {
|
||||
if (!responseResult) {
|
||||
return;
|
||||
}
|
||||
const newRows = transformSpanRows(responseResult);
|
||||
setAccumulatedRows((prev) =>
|
||||
pagination.offset === 0 ? newRows : [...prev, ...newRows],
|
||||
);
|
||||
setHasMore(newRows.length >= pagination.limit);
|
||||
}, [responseResult, pagination.offset, pagination.limit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isFetching) {
|
||||
setIsLoadingQueries(true);
|
||||
@@ -176,68 +210,50 @@ function ListView({
|
||||
}
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
const dataLength =
|
||||
data?.payload?.data?.newResult?.data?.result[0]?.list?.length;
|
||||
const totalCount = useMemo(() => dataLength || 0, [dataLength]);
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isFetching && !isError && accumulatedRows.length !== 0) {
|
||||
void logEvent('Traces Explorer: Data present', { panelType });
|
||||
}
|
||||
}, [isLoading, isFetching, isError, accumulatedRows.length, panelType]);
|
||||
|
||||
const queryTableDataResult = data?.payload?.data?.newResult?.data?.result;
|
||||
const queryTableData = useMemo(
|
||||
() => queryTableDataResult || [],
|
||||
[queryTableDataResult],
|
||||
);
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
getListColumns(
|
||||
options?.selectColumns || [],
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
),
|
||||
[options?.selectColumns, formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
const transformedQueryTableData = useMemo(
|
||||
() => transformDataWithDate(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);
|
||||
},
|
||||
[columns, config],
|
||||
);
|
||||
const handleEndReached = useCallback(() => {
|
||||
if (!hasMore) {
|
||||
return;
|
||||
}
|
||||
setPagination((p) => ({ ...p, offset: p.offset + p.limit }));
|
||||
}, [hasMore]);
|
||||
|
||||
const handleOrderChange = useCallback((value: string) => {
|
||||
setOrderBy(value);
|
||||
}, []);
|
||||
|
||||
const isDataAbsent =
|
||||
!isLoading &&
|
||||
!isFetching &&
|
||||
!isError &&
|
||||
transformedQueryTableData.length === 0;
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const baseColumns = useMemo(
|
||||
() => [
|
||||
makeTimestampCol(formatTimezoneAdjustedTimestamp),
|
||||
...(options?.selectColumns ?? []).map(makeListFieldCol),
|
||||
],
|
||||
[formatTimezoneAdjustedTimestamp, options?.selectColumns],
|
||||
);
|
||||
|
||||
const tableColumns = useTracesTableColumns<SpanRow>({ baseColumns });
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: SpanRow): void => {
|
||||
history.push(getTraceLink(row));
|
||||
},
|
||||
[history],
|
||||
);
|
||||
|
||||
const handleRowClickNewTab = useCallback((row: SpanRow): void => {
|
||||
window.open(
|
||||
getAbsoluteUrl(getTraceLink(row)),
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isLoading &&
|
||||
!isFetching &&
|
||||
!isError &&
|
||||
transformedQueryTableData.length !== 0
|
||||
) {
|
||||
logEvent('Traces Explorer: Data present', {
|
||||
panelType,
|
||||
});
|
||||
}
|
||||
}, [isLoading, isFetching, isError, transformedQueryTableData, panelType]);
|
||||
return (
|
||||
<Container>
|
||||
<div className="trace-explorer-controls">
|
||||
@@ -258,39 +274,54 @@ function ListView({
|
||||
selectedColumns={options?.selectColumns}
|
||||
/>
|
||||
|
||||
<TraceExplorerControls
|
||||
isLoading={isFetching}
|
||||
totalCount={totalCount}
|
||||
config={config}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
/>
|
||||
<TraceExplorerControls config={config} />
|
||||
</div>
|
||||
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
|
||||
{(isLoading || (isFetching && transformedQueryTableData.length === 0)) && (
|
||||
{(isLoading || isFetching) && accumulatedRows.length === 0 && (
|
||||
<TracesLoading />
|
||||
)}
|
||||
|
||||
{isDataAbsent && !isFilterApplied && (
|
||||
<NoLogs dataSource={DataSource.TRACES} />
|
||||
)}
|
||||
{!isLoading &&
|
||||
!isFetching &&
|
||||
!isError &&
|
||||
!isFilterApplied &&
|
||||
accumulatedRows.length === 0 && <NoLogs dataSource={DataSource.TRACES} />}
|
||||
|
||||
{isDataAbsent && isFilterApplied && (
|
||||
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType="LIST" />
|
||||
)}
|
||||
{!isLoading &&
|
||||
!isFetching &&
|
||||
accumulatedRows.length === 0 &&
|
||||
!isError &&
|
||||
isFilterApplied && (
|
||||
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType="LIST" />
|
||||
)}
|
||||
|
||||
{!isError && transformedQueryTableData.length !== 0 && (
|
||||
<ResizeTable
|
||||
tableLayout="fixed"
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
loading={isFetching}
|
||||
style={tableStyles}
|
||||
dataSource={transformedQueryTableData}
|
||||
columns={columns}
|
||||
onDragColumn={handleDragColumn}
|
||||
/>
|
||||
{accumulatedRows.length !== 0 && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<TanStackTable<SpanRow>
|
||||
data={accumulatedRows}
|
||||
columns={tableColumns}
|
||||
columnStorageKey={LOCALSTORAGE.TRACES_LIST_COLUMNS}
|
||||
respectColumnOrder={false}
|
||||
cellTypographySize="medium"
|
||||
isLoading={isLoading || isFetching}
|
||||
onEndReached={handleEndReached}
|
||||
onColumnOrderChange={(cols): void =>
|
||||
config?.addColumn?.onReorder(cols.map((c) => c.id))
|
||||
}
|
||||
onColumnRemove={config?.addColumn?.onRemove}
|
||||
onRowClick={handleRowClick}
|
||||
onRowClickNewTab={handleRowClickNewTab}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import styled from 'styled-components';
|
||||
|
||||
// Kept for legacy antd consumers (TracesTableComponent, LogsPanelComponent).
|
||||
// The TanStack ListView doesn't use it.
|
||||
export const tableStyles: CSSProperties = {
|
||||
cursor: 'unset',
|
||||
};
|
||||
@@ -9,13 +10,30 @@ export const tableStyles: CSSProperties = {
|
||||
export const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
// Fallback: the page-level CSS chain (.trace-explorer-page → .trace-explorer →
|
||||
// .traces-explorer-views) isn't a flex column today, so flex:1 alone has nothing
|
||||
// to flex against. Anchor a height via the viewport so react-virtuoso (inside
|
||||
// TanStackTable) has a sized parent to render into.
|
||||
height: calc(100vh - 240px);
|
||||
min-height: 400px;
|
||||
|
||||
// Match logs explorer table typography (mirrors LogsExplorerList.style.scss).
|
||||
font-family: 'Space Mono', monospace;
|
||||
|
||||
// Row hover affordance — TanStack's row hover reads var(--row-hover-bg) with no
|
||||
// fallback, so without setting it hover is invisible.
|
||||
--row-hover-bg: var(--l1-border);
|
||||
|
||||
// Small leading gap before the pinned timestamp column. No drag handle here
|
||||
// (pinned columns aren't movable), so we don't need the full 12px we use in
|
||||
// the grouped Traces view — 5px just keeps the text off the table edge.
|
||||
--tanstack-cell-padding-left-first-column: 5px;
|
||||
|
||||
// Allow dynamic-field cells to clamp to 3 lines (matches old LineClampedText
|
||||
// behavior). Header + intrinsic columns stay 1-line by their own settings.
|
||||
--tanstack-plain-body-line-clamp: 3;
|
||||
|
||||
--typography-color: var(--l1-foreground);
|
||||
`;
|
||||
|
||||
export const ErrorText = styled(Typography)`
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
export const DateText = styled(Typography)`
|
||||
min-width: 145px;
|
||||
`;
|
||||
|
||||
@@ -3,6 +3,8 @@ 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 TanStackTable from 'components/TanStackTableView';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
@@ -14,6 +16,14 @@ import LineClampedText from 'periscope/components/LineClampedText/LineClampedTex
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
|
||||
// `BlockLink`, `getListColumns`, `transformDataWithDate` are kept for legacy
|
||||
// antd consumers. `getTraceLink` is shared with the TanStack ListView, which
|
||||
// otherwise uses `make*Col` / `SpanRow` / `transformSpanRows`.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legacy antd consumers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function BlockLink({
|
||||
children,
|
||||
to,
|
||||
@@ -41,12 +51,43 @@ 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,
|
||||
/**
|
||||
* Reads camelCase OR snake_case at runtime — both legacy `RowData` and the new
|
||||
* `SpanRow` (each used by different ListView/utils consumers) satisfy
|
||||
* `Record<string, unknown>` because their named props are subtypes of `unknown`.
|
||||
*/
|
||||
export const getTraceLink = (record: Record<string, unknown>): string => {
|
||||
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,
|
||||
})}`;
|
||||
};
|
||||
|
||||
function readId(value: unknown): string {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return String(value);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export const getListColumns = (
|
||||
selectedColumns: TelemetryFieldKey[],
|
||||
@@ -136,3 +177,107 @@ export const getListColumns = (
|
||||
|
||||
return [...initialColumns, ...columns];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TanStack ListView (current)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Span row shape for the trace list view. Known intrinsic fields explicit; the
|
||||
// rest of the row comes from user-selected dynamic columns (selectColumns), hence
|
||||
// the Record intersection. `timestamp` is added by transformSpanRows from the
|
||||
// API's wrapping ListItem.timestamp (data itself omits it).
|
||||
export type SpanRow = {
|
||||
trace_id: string;
|
||||
span_id: string;
|
||||
timestamp: string;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
export const transformSpanRows = (data: QueryDataV3[]): SpanRow[] => {
|
||||
const list = data[0]?.list;
|
||||
if (!list) {
|
||||
return [];
|
||||
}
|
||||
return list.map((item) => ({
|
||||
...(item.data as Record<string, unknown>),
|
||||
timestamp: item.timestamp,
|
||||
})) as unknown as SpanRow[];
|
||||
};
|
||||
|
||||
// Field-name allowlists that drive signal-specific cell rendering (kept from the
|
||||
// pre-TanStack getListColumns). Both legacy camelCase + snake_case variants are
|
||||
// listed because the API has shipped both over time.
|
||||
const STATUS_FIELD_NAMES = new Set([
|
||||
'httpMethod',
|
||||
'http_method',
|
||||
'responseStatusCode',
|
||||
'response_status_code',
|
||||
]);
|
||||
const DURATION_FIELD_NAMES = new Set(['durationNano', 'duration_nano']);
|
||||
|
||||
type TimestampFormatter = (
|
||||
input: TimestampInput,
|
||||
format?: string,
|
||||
) => string | number;
|
||||
|
||||
export function makeTimestampCol(
|
||||
formatTimezoneAdjustedTimestamp: TimestampFormatter,
|
||||
): TableColumnDef<SpanRow> {
|
||||
return {
|
||||
id: buildCompositeKey('timestamp', 'span'),
|
||||
header: 'Timestamp',
|
||||
accessorFn: (row): unknown => row.timestamp,
|
||||
// Pinned left as a visual anchor during horizontal scroll. Trade-off: the
|
||||
// sticky-positioning + cell `overflow: hidden` in TanStackTable.module.scss
|
||||
// makes the right-edge resize handle effectively unhittable for pinned
|
||||
// columns — accepted.
|
||||
pin: 'left',
|
||||
canBeHidden: false,
|
||||
enableRemove: false,
|
||||
width: { default: 170, min: 170 },
|
||||
cell: ({ value }): JSX.Element => {
|
||||
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,
|
||||
);
|
||||
return <TanStackTable.Text>{String(formatted)}</TanStackTable.Text>;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function makeListFieldCol(
|
||||
f: TelemetryFieldKey,
|
||||
): TableColumnDef<SpanRow> {
|
||||
return {
|
||||
id: buildCompositeKey(f.name, f.fieldContext, f.fieldDataType),
|
||||
header: f.name,
|
||||
accessorFn: (row): unknown => row[f.name],
|
||||
enableRemove: true,
|
||||
width: { min: 192 },
|
||||
cell: ({ value }): JSX.Element => {
|
||||
if (value === '' || value == null) {
|
||||
return <TanStackTable.Text data-testid={f.name}>N/A</TanStackTable.Text>;
|
||||
}
|
||||
const text = stringifyCellValue(value);
|
||||
if (STATUS_FIELD_NAMES.has(f.name)) {
|
||||
return (
|
||||
<Badge data-testid={f.name} color="sakura" variant="outline">
|
||||
{text}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (DURATION_FIELD_NAMES.has(f.name)) {
|
||||
return (
|
||||
<TanStackTable.Text data-testid={f.name}>
|
||||
{getMs(text)}
|
||||
ms
|
||||
</TanStackTable.Text>
|
||||
);
|
||||
}
|
||||
return <TanStackTable.Text data-testid={f.name}>{text}</TanStackTable.Text>;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,50 +1,69 @@
|
||||
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 TanStackTable from 'components/TanStackTableView';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
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<ListItem['data']> = [
|
||||
// Trace-grouped (group-by-trace) row shape. Distinct from logs' `ListItem.data`
|
||||
// (which is `Omit<ILog, 'timestamp' | 'span_id'>` — the legacy logs shape).
|
||||
// Trace rows ship trace-summary fields; runtime keys often contain dots (e.g.
|
||||
// `service.name`), so the row indexes via string keys, not nested-property access.
|
||||
export type TraceRow = {
|
||||
'service.name': string;
|
||||
name: string;
|
||||
duration_nano: number | string;
|
||||
span_count: number | string;
|
||||
trace_id: string;
|
||||
};
|
||||
|
||||
export const columns: TableColumnDef<TraceRow>[] = [
|
||||
{
|
||||
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 => (
|
||||
<Typography>{getMs(String(duration))}ms</Typography>
|
||||
id: buildCompositeKey('service.name', 'resource'),
|
||||
header: 'Root Service Name',
|
||||
accessorFn: (row): unknown => row['service.name'],
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>
|
||||
),
|
||||
width: { min: 192 },
|
||||
},
|
||||
{
|
||||
title: 'No of Spans',
|
||||
dataIndex: 'span_count',
|
||||
key: 'span_count',
|
||||
},
|
||||
{
|
||||
title: 'TraceID',
|
||||
dataIndex: 'trace_id',
|
||||
key: 'traceID',
|
||||
render: (traceID: string): JSX.Element => (
|
||||
<Link
|
||||
to={generatePath(ROUTES.TRACE_DETAIL, {
|
||||
id: traceID,
|
||||
})}
|
||||
data-testid="trace-id"
|
||||
>
|
||||
{traceID}
|
||||
</Link>
|
||||
id: 'name',
|
||||
header: 'Root Operation Name',
|
||||
accessorFn: (row): unknown => row.name,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text data-testid="trace-id">
|
||||
{String(value ?? '')}
|
||||
</TanStackTable.Text>
|
||||
),
|
||||
width: { min: 200 },
|
||||
},
|
||||
{
|
||||
id: 'duration_nano',
|
||||
header: 'Root Duration (in ms)',
|
||||
accessorFn: (row): unknown => row.duration_nano,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text>{getMs(String(value))}ms</TanStackTable.Text>
|
||||
),
|
||||
width: { min: 130 },
|
||||
},
|
||||
{
|
||||
id: 'span_count',
|
||||
header: 'No of Spans',
|
||||
accessorFn: (row): unknown => row.span_count,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>
|
||||
),
|
||||
width: { min: 100 },
|
||||
},
|
||||
{
|
||||
id: 'trace_id',
|
||||
header: 'TraceID',
|
||||
accessorFn: (row): unknown => row.trace_id,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>
|
||||
),
|
||||
width: { min: 250 },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -4,38 +4,44 @@ import {
|
||||
memo,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { generatePath, useHistory } from 'react-router-dom';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { useTracesTableColumns } from 'components/Traces/TableView/useTracesTableColumns';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ROUTES from 'constants/routes';
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import NoLogs from 'container/NoLogs/NoLogs';
|
||||
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
|
||||
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 { getAbsoluteUrl } from 'utils/basePath';
|
||||
import DOCLINKS from 'utils/docLinks';
|
||||
|
||||
import TraceExplorerControls from '../Controls';
|
||||
import { TracesLoading } from '../TraceLoading/TraceLoading';
|
||||
import { columns, PER_PAGE_OPTIONS } from './configs';
|
||||
import { columns as baseColumns, TraceRow } from './configs';
|
||||
import { ActionsContainer, Container } from './styles';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
interface TracesViewProps {
|
||||
isFilterApplied: boolean;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
@@ -49,6 +55,7 @@ function TracesView({
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
}: TracesViewProps): JSX.Element {
|
||||
const history = useHistory();
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const {
|
||||
@@ -57,9 +64,20 @@ function TracesView({
|
||||
minTime,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
|
||||
QueryParams.pagination,
|
||||
);
|
||||
// Infinite-scroll state — owned by this view.
|
||||
const [pagination, setPagination] = useState<Pagination>({
|
||||
offset: 0,
|
||||
limit: PAGE_SIZE,
|
||||
});
|
||||
const [accumulatedRows, setAccumulatedRows] = useState<TraceRow[]>([]);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
// Reset accumulator + offset whenever the underlying query identity changes.
|
||||
useEffect(() => {
|
||||
setPagination({ offset: 0, limit: PAGE_SIZE });
|
||||
setAccumulatedRows([]);
|
||||
setHasMore(true);
|
||||
}, [stagedQuery?.id, globalSelectedTime, maxTime, minTime]);
|
||||
|
||||
const transformedQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces),
|
||||
@@ -74,16 +92,9 @@ function TracesView({
|
||||
minTime,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationQueryData,
|
||||
],
|
||||
[
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationQueryData,
|
||||
pagination,
|
||||
],
|
||||
[globalSelectedTime, maxTime, minTime, stagedQuery, panelType, pagination],
|
||||
);
|
||||
|
||||
if (queryKeyRef) {
|
||||
@@ -100,7 +111,7 @@ function TracesView({
|
||||
dataSource: 'traces',
|
||||
},
|
||||
tableParams: {
|
||||
pagination: paginationQueryData,
|
||||
pagination,
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
@@ -117,11 +128,20 @@ function TracesView({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
const responseData = data?.payload?.data?.newResult?.data?.result[0]?.list;
|
||||
const tableData = useMemo(
|
||||
() => responseData?.map((listItem) => listItem.data),
|
||||
[responseData],
|
||||
);
|
||||
// Append fetched page to accumulator (replace when offset === 0).
|
||||
const responseList = data?.payload?.data?.newResult?.data?.result?.[0]?.list;
|
||||
useEffect(() => {
|
||||
if (!responseList) {
|
||||
return;
|
||||
}
|
||||
// API returns trace-summary rows; the `ListItem.data` static type is the
|
||||
// legacy logs shape, so route through `unknown` to land on `TraceRow`.
|
||||
const newRows = responseList.map((li) => li.data) as unknown as TraceRow[];
|
||||
setAccumulatedRows((prev) =>
|
||||
pagination.offset === 0 ? newRows : [...prev, ...newRows],
|
||||
);
|
||||
setHasMore(newRows.length >= pagination.limit);
|
||||
}, [responseList, pagination.offset, pagination.limit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isFetching) {
|
||||
@@ -132,16 +152,48 @@ function TracesView({
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isFetching && !isError && (tableData || []).length !== 0) {
|
||||
logEvent('Traces Explorer: Data present', {
|
||||
if (!isLoading && !isFetching && !isError && accumulatedRows.length !== 0) {
|
||||
void logEvent('Traces Explorer: Data present', {
|
||||
panelType: 'TRACE',
|
||||
});
|
||||
}
|
||||
}, [isLoading, isFetching, isError, panelType, tableData]);
|
||||
}, [isLoading, isFetching, isError, accumulatedRows.length]);
|
||||
|
||||
const handleEndReached = useCallback(() => {
|
||||
if (!hasMore) {
|
||||
return;
|
||||
}
|
||||
setPagination((p) => ({ ...p, offset: p.offset + p.limit }));
|
||||
}, [hasMore]);
|
||||
|
||||
const tableColumns = useTracesTableColumns<TraceRow>({ baseColumns });
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: TraceRow): void => {
|
||||
const traceId = String(row.trace_id);
|
||||
history.push(generatePath(ROUTES.TRACE_DETAIL, { id: traceId }));
|
||||
},
|
||||
[history],
|
||||
);
|
||||
|
||||
const handleRowClickNewTab = useCallback((row: TraceRow): void => {
|
||||
const traceId = String(row.trace_id);
|
||||
const path = generatePath(ROUTES.TRACE_DETAIL, { id: traceId });
|
||||
window.open(getAbsoluteUrl(path), '_blank', 'noopener,noreferrer');
|
||||
}, []);
|
||||
|
||||
//oxlint-disable-next-line no-console
|
||||
console.log('TracesView rendered with rows:', {
|
||||
accumulatedRows,
|
||||
tableColumns,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
error,
|
||||
});
|
||||
return (
|
||||
<Container>
|
||||
{(tableData || []).length !== 0 && (
|
||||
{accumulatedRows.length !== 0 && (
|
||||
<ActionsContainer>
|
||||
<Typography>
|
||||
This tab only shows Root Spans. More details
|
||||
@@ -150,20 +202,12 @@ function TracesView({
|
||||
here
|
||||
</Typography.Link>
|
||||
</Typography>
|
||||
|
||||
<div className="trace-explorer-controls">
|
||||
<TraceExplorerControls
|
||||
isLoading={isLoading}
|
||||
totalCount={responseData?.length || 0}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</ActionsContainer>
|
||||
)}
|
||||
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
|
||||
{(isLoading || (isFetching && (tableData || []).length === 0)) && (
|
||||
{(isLoading || isFetching) && accumulatedRows.length === 0 && (
|
||||
<TracesLoading />
|
||||
)}
|
||||
|
||||
@@ -171,25 +215,36 @@ function TracesView({
|
||||
!isFetching &&
|
||||
!isError &&
|
||||
!isFilterApplied &&
|
||||
(tableData || []).length === 0 && <NoLogs dataSource={DataSource.TRACES} />}
|
||||
accumulatedRows.length === 0 && <NoLogs dataSource={DataSource.TRACES} />}
|
||||
|
||||
{!isLoading &&
|
||||
!isFetching &&
|
||||
(tableData || []).length === 0 &&
|
||||
accumulatedRows.length === 0 &&
|
||||
!isError &&
|
||||
isFilterApplied && (
|
||||
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType="TRACE" />
|
||||
)}
|
||||
|
||||
{(tableData || []).length !== 0 && (
|
||||
<ResizeTable
|
||||
loading={isLoading}
|
||||
columns={columns}
|
||||
tableLayout="fixed"
|
||||
dataSource={tableData}
|
||||
scroll={{ x: true }}
|
||||
pagination={false}
|
||||
/>
|
||||
{accumulatedRows.length !== 0 && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<TanStackTable<TraceRow>
|
||||
data={accumulatedRows}
|
||||
columns={tableColumns}
|
||||
columnStorageKey={LOCALSTORAGE.TRACES_VIEW_COLUMNS}
|
||||
cellTypographySize="medium"
|
||||
isLoading={isLoading || isFetching}
|
||||
onEndReached={handleEndReached}
|
||||
onRowClick={handleRowClick}
|
||||
onRowClickNewTab={handleRowClickNewTab}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,16 @@ import styled from 'styled-components';
|
||||
export const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: calc(100vh - 240px);
|
||||
min-height: 400px;
|
||||
|
||||
// Row hover affordance
|
||||
--row-hover-bg: var(--l1-border);
|
||||
|
||||
// Breathing room before the first column so cell content doesn't hug the corner.
|
||||
--tanstack-cell-padding-left-first-column: 12px;
|
||||
`;
|
||||
|
||||
export const ActionsContainer = styled.div`
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
getQueryByPanelType,
|
||||
} from 'container/TracesExplorer/explorerUtils';
|
||||
import ListView from 'container/TracesExplorer/ListView';
|
||||
import { defaultSelectedColumns } from 'container/TracesExplorer/ListView/configs';
|
||||
import QuerySection from 'container/TracesExplorer/QuerySection';
|
||||
import TableView from 'container/TracesExplorer/TableView';
|
||||
import TracesView from 'container/TracesExplorer/TracesView';
|
||||
@@ -80,9 +79,6 @@ function TracesExplorer(): JSX.Element {
|
||||
storageKey: LOCALSTORAGE.TRACES_LIST_OPTIONS,
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'noop',
|
||||
initialOptions: {
|
||||
selectColumns: defaultSelectedColumns,
|
||||
},
|
||||
});
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
@@ -3,9 +3,9 @@ import { useEffect, useState } from 'react';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
defaultTraceSelectedColumns,
|
||||
ensureLogsRequiredColumns,
|
||||
} from 'container/OptionsMenu/constants';
|
||||
import { defaultSelectedColumns as defaultTracesSelectedColumns } from 'container/TracesExplorer/ListView/configs';
|
||||
import { useGetAllViews } from 'hooks/saveViews/useGetAllViews';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
@@ -69,7 +69,7 @@ export function usePreferenceSync({
|
||||
};
|
||||
}
|
||||
if (dataSource === DataSource.TRACES) {
|
||||
columns = parsedExtraData?.selectColumns || defaultTracesSelectedColumns;
|
||||
columns = parsedExtraData?.selectColumns || defaultTraceSelectedColumns;
|
||||
}
|
||||
setSavedViewPreferences({ columns, formatting });
|
||||
}, [viewsData, dataSource, savedViewId, mode]);
|
||||
|
||||
Reference in New Issue
Block a user