Compare commits
21 Commits
playwright
...
feat/span-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7dc701c988 | ||
|
|
b80db44c67 | ||
|
|
0440f8be7c | ||
|
|
9533fe1c76 | ||
|
|
c8f3ab667a | ||
|
|
47e2687afe | ||
|
|
277d6c5dbd | ||
|
|
ae18c138c8 | ||
|
|
5a1f20f626 | ||
|
|
ae5895ca49 | ||
|
|
d1b4c8cae8 | ||
|
|
474fd623fc | ||
|
|
777af5dc4f | ||
|
|
d432ee463d | ||
|
|
5e1ba0ed30 | ||
|
|
6776ecf601 | ||
|
|
101976914c | ||
|
|
2b3c309c85 | ||
|
|
0976a572e3 | ||
|
|
3d4c6eda71 | ||
|
|
da841650f5 |
@@ -52,7 +52,7 @@
|
|||||||
"@signozhq/popover": "0.0.0",
|
"@signozhq/popover": "0.0.0",
|
||||||
"@signozhq/resizable": "0.0.0",
|
"@signozhq/resizable": "0.0.0",
|
||||||
"@signozhq/sonner": "0.1.0",
|
"@signozhq/sonner": "0.1.0",
|
||||||
"@signozhq/table": "0.3.7",
|
"@signozhq/table": "0.4.0",
|
||||||
"@signozhq/tooltip": "0.0.2",
|
"@signozhq/tooltip": "0.0.2",
|
||||||
"@tanstack/react-table": "8.20.6",
|
"@tanstack/react-table": "8.20.6",
|
||||||
"@tanstack/react-virtual": "3.11.2",
|
"@tanstack/react-virtual": "3.11.2",
|
||||||
|
|||||||
@@ -264,6 +264,7 @@ function convertRawData(
|
|||||||
date: row.timestamp,
|
date: row.timestamp,
|
||||||
} as any,
|
} as any,
|
||||||
})),
|
})),
|
||||||
|
nextCursor: rawData.nextCursor,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
|
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
|
||||||
import { Button, Select } from 'antd';
|
import { Select } from 'antd';
|
||||||
import { DEFAULT_PER_PAGE_OPTIONS, Pagination } from 'hooks/queryPagination';
|
import { DEFAULT_PER_PAGE_OPTIONS, Pagination } from 'hooks/queryPagination';
|
||||||
import { memo, useMemo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import { popupContainer } from 'utils/selectPopupContainer';
|
import { popupContainer } from 'utils/selectPopupContainer';
|
||||||
|
|
||||||
import { defaultSelectStyle } from './config';
|
import { defaultSelectStyle } from './config';
|
||||||
import { Container } from './styles';
|
import { Container, StyledButton } from './styles';
|
||||||
|
|
||||||
function Controls({
|
function Controls({
|
||||||
offset = 0,
|
offset = 0,
|
||||||
@@ -18,6 +18,7 @@ function Controls({
|
|||||||
handleCountItemsPerPageChange,
|
handleCountItemsPerPageChange,
|
||||||
isLogPanel = false,
|
isLogPanel = false,
|
||||||
showSizeChanger = true,
|
showSizeChanger = true,
|
||||||
|
nextCursor,
|
||||||
}: ControlsProps): JSX.Element | null {
|
}: ControlsProps): JSX.Element | null {
|
||||||
const isNextAndPreviousDisabled = useMemo(
|
const isNextAndPreviousDisabled = useMemo(
|
||||||
() => isLoading || countPerPage < 0 || totalCount === 0,
|
() => isLoading || countPerPage < 0 || totalCount === 0,
|
||||||
@@ -27,15 +28,28 @@ function Controls({
|
|||||||
() => (isLogPanel ? false : offset <= 0 || isNextAndPreviousDisabled),
|
() => (isLogPanel ? false : offset <= 0 || isNextAndPreviousDisabled),
|
||||||
[isLogPanel, isNextAndPreviousDisabled, offset],
|
[isLogPanel, isNextAndPreviousDisabled, offset],
|
||||||
);
|
);
|
||||||
const isNextDisabled = useMemo(
|
const isNextDisabled = useMemo(() => {
|
||||||
() =>
|
if (isLogPanel) return false;
|
||||||
isLogPanel ? false : totalCount < countPerPage || isNextAndPreviousDisabled,
|
if (isNextAndPreviousDisabled) return true;
|
||||||
[countPerPage, isLogPanel, isNextAndPreviousDisabled, totalCount],
|
|
||||||
);
|
// If nextCursor is provided, use it to determine if there are more pages
|
||||||
|
if (nextCursor !== undefined) {
|
||||||
|
return nextCursor === '' || nextCursor === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to original logic for components that don't provide nextCursor
|
||||||
|
return totalCount < countPerPage;
|
||||||
|
}, [
|
||||||
|
countPerPage,
|
||||||
|
isLogPanel,
|
||||||
|
isNextAndPreviousDisabled,
|
||||||
|
totalCount,
|
||||||
|
nextCursor,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<Button
|
<StyledButton
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
size="small"
|
size="small"
|
||||||
type="link"
|
type="link"
|
||||||
@@ -43,8 +57,8 @@ function Controls({
|
|||||||
onClick={handleNavigatePrevious}
|
onClick={handleNavigatePrevious}
|
||||||
>
|
>
|
||||||
<LeftOutlined /> Previous
|
<LeftOutlined /> Previous
|
||||||
</Button>
|
</StyledButton>
|
||||||
<Button
|
<StyledButton
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
size="small"
|
size="small"
|
||||||
type="link"
|
type="link"
|
||||||
@@ -52,7 +66,7 @@ function Controls({
|
|||||||
onClick={handleNavigateNext}
|
onClick={handleNavigateNext}
|
||||||
>
|
>
|
||||||
Next <RightOutlined />
|
Next <RightOutlined />
|
||||||
</Button>
|
</StyledButton>
|
||||||
|
|
||||||
{showSizeChanger && (
|
{showSizeChanger && (
|
||||||
<Select<Pagination['limit']>
|
<Select<Pagination['limit']>
|
||||||
@@ -79,6 +93,7 @@ Controls.defaultProps = {
|
|||||||
perPageOptions: DEFAULT_PER_PAGE_OPTIONS,
|
perPageOptions: DEFAULT_PER_PAGE_OPTIONS,
|
||||||
isLogPanel: false,
|
isLogPanel: false,
|
||||||
showSizeChanger: true,
|
showSizeChanger: true,
|
||||||
|
nextCursor: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ControlsProps {
|
export interface ControlsProps {
|
||||||
@@ -92,6 +107,7 @@ export interface ControlsProps {
|
|||||||
handleCountItemsPerPageChange: (value: Pagination['limit']) => void;
|
handleCountItemsPerPageChange: (value: Pagination['limit']) => void;
|
||||||
isLogPanel?: boolean;
|
isLogPanel?: boolean;
|
||||||
showSizeChanger?: boolean;
|
showSizeChanger?: boolean;
|
||||||
|
nextCursor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(Controls);
|
export default memo(Controls);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Button } from 'antd';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
export const Container = styled.div`
|
export const Container = styled.div`
|
||||||
@@ -5,3 +6,8 @@ export const Container = styled.div`
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const StyledButton = styled(Button)`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
`;
|
||||||
|
|||||||
19
frontend/src/container/SpanList/SearchFilters.tsx
Normal file
19
frontend/src/container/SpanList/SearchFilters.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { SearchOutlined } from '@ant-design/icons';
|
||||||
|
import { Input } from 'antd';
|
||||||
|
|
||||||
|
function SearchFilters(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="search-filters">
|
||||||
|
<div className="search-filters__input">
|
||||||
|
<Input
|
||||||
|
placeholder="Search Spans..."
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
size="middle"
|
||||||
|
className="search-filters__search-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SearchFilters;
|
||||||
146
frontend/src/container/SpanList/SpanList.styles.scss
Normal file
146
frontend/src/container/SpanList/SpanList.styles.scss
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
.span-list {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid var(--bg-slate-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-filters {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__search-input {
|
||||||
|
.ant-input-prefix {
|
||||||
|
color: var(--text-slate-400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.span-table {
|
||||||
|
height: 100%;
|
||||||
|
margin-top: 16px;
|
||||||
|
|
||||||
|
.data-table-container {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.sticky-header-table-container {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot='table-cell'] {
|
||||||
|
font-size: 13px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-cell-with-expand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.expand-button {
|
||||||
|
min-width: auto;
|
||||||
|
padding: 0;
|
||||||
|
height: auto;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-slate-100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-cell-child {
|
||||||
|
padding-left: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.span-table-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-span-pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-left: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot='table-header'] {
|
||||||
|
&,
|
||||||
|
[data-slot='table-row']:hover {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
thead > [data-slot='table-row'] {
|
||||||
|
border-bottom: none !important;
|
||||||
|
[data-slot='table-head'] {
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px; /* 163.636% */
|
||||||
|
letter-spacing: 0.44px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[data-slot='table-row'] {
|
||||||
|
border: none !important;
|
||||||
|
&:nth-of-type(odd) {
|
||||||
|
background: rgba(171, 189, 255, 0.01);
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-slate-500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[data-slot='table-head'] {
|
||||||
|
background: var(--bg-ink-500);
|
||||||
|
}
|
||||||
|
[data-slot='table-head']:first-of-type {
|
||||||
|
background: #13141a;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot='table-cell']:first-of-type {
|
||||||
|
background: rgba(171, 189, 255, 0.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightMode {
|
||||||
|
.span-table {
|
||||||
|
[data-slot='table-row'] {
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-vanilla-400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[data-slot='table-head'] {
|
||||||
|
background: var(--bg-vanilla-100);
|
||||||
|
}
|
||||||
|
[data-slot='table-head']:first-of-type {
|
||||||
|
background: var(--bg-vanilla-200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
198
frontend/src/container/SpanList/SpanList.tsx
Normal file
198
frontend/src/container/SpanList/SpanList.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import './SpanList.styles.scss';
|
||||||
|
|
||||||
|
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||||
|
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
|
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||||
|
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||||
|
import useUrlQuery from 'hooks/useUrlQuery';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||||
|
import { GetTraceV2SuccessResponse, Span } from 'types/api/trace/getTraceV2';
|
||||||
|
import { DataSource } from 'types/common/queryBuilder';
|
||||||
|
|
||||||
|
import SpanTable from './SpanTable';
|
||||||
|
import { SpanDataRow } from './types';
|
||||||
|
import { transformEntrySpansToHierarchy } from './utils';
|
||||||
|
|
||||||
|
interface SpanListProps {
|
||||||
|
traceId?: string;
|
||||||
|
setSelectedSpan?: (span: Span) => void;
|
||||||
|
traceData:
|
||||||
|
| SuccessResponse<GetTraceV2SuccessResponse, unknown>
|
||||||
|
| ErrorResponse
|
||||||
|
| undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpanList({
|
||||||
|
traceId,
|
||||||
|
setSelectedSpan,
|
||||||
|
traceData,
|
||||||
|
}: SpanListProps): JSX.Element {
|
||||||
|
const urlQuery = useUrlQuery();
|
||||||
|
const { safeNavigate } = useSafeNavigate();
|
||||||
|
const payload = initialQueriesMap.traces;
|
||||||
|
|
||||||
|
// Entry spans pagination
|
||||||
|
const entryPage = parseInt(urlQuery.get('entryPage') || '1', 10);
|
||||||
|
const entryPageSize = 10;
|
||||||
|
|
||||||
|
const startTimestamp = useMemo(
|
||||||
|
() =>
|
||||||
|
traceData?.payload?.startTimestampMillis != null
|
||||||
|
? traceData.payload.startTimestampMillis / 1e3 - 30 * 60 * 1000
|
||||||
|
: undefined,
|
||||||
|
[traceData?.payload?.startTimestampMillis],
|
||||||
|
);
|
||||||
|
|
||||||
|
const endTimestamp = useMemo(
|
||||||
|
() =>
|
||||||
|
traceData?.payload?.endTimestampMillis != null
|
||||||
|
? traceData.payload.endTimestampMillis / 1e3 + 90 * 60 * 1000
|
||||||
|
: undefined,
|
||||||
|
[traceData?.payload?.endTimestampMillis],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isFetching } = useGetQueryRange(
|
||||||
|
{
|
||||||
|
graphType: PANEL_TYPES.LIST,
|
||||||
|
selectedTime: 'GLOBAL_TIME',
|
||||||
|
...(startTimestamp != null ? { start: startTimestamp } : {}),
|
||||||
|
...(endTimestamp != null ? { end: endTimestamp } : {}),
|
||||||
|
query: {
|
||||||
|
...payload,
|
||||||
|
builder: {
|
||||||
|
...payload.builder,
|
||||||
|
queryData: [
|
||||||
|
{
|
||||||
|
...payload.builder.queryData[0],
|
||||||
|
...{
|
||||||
|
queryName: 'A',
|
||||||
|
dataSource: DataSource.TRACES,
|
||||||
|
stepInterval: null,
|
||||||
|
disabled: false,
|
||||||
|
filter: {
|
||||||
|
expression: `trace_id = '${traceId}' isEntryPoint = 'true'`,
|
||||||
|
},
|
||||||
|
aggregateOperator: 'count',
|
||||||
|
timeAggregation: 'rate',
|
||||||
|
spaceAggregation: 'sum',
|
||||||
|
aggregations: [
|
||||||
|
{
|
||||||
|
expression: 'count() ',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
expression: 'A',
|
||||||
|
orderBy: [
|
||||||
|
{
|
||||||
|
columnName: 'timestamp',
|
||||||
|
order: 'desc',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
having: {
|
||||||
|
expression: '',
|
||||||
|
},
|
||||||
|
|
||||||
|
reduceTo: 'avg',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tableParams: {
|
||||||
|
pagination: {
|
||||||
|
limit: entryPageSize,
|
||||||
|
offset: (entryPage - 1) * entryPageSize,
|
||||||
|
},
|
||||||
|
selectColumns: [
|
||||||
|
{
|
||||||
|
name: 'service.name',
|
||||||
|
fieldDataType: 'string',
|
||||||
|
signal: 'traces',
|
||||||
|
fieldContext: 'resource',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
fieldDataType: 'string',
|
||||||
|
signal: 'traces',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'duration_nano',
|
||||||
|
fieldDataType: '',
|
||||||
|
signal: 'traces',
|
||||||
|
fieldContext: 'span',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'http_method',
|
||||||
|
fieldDataType: '',
|
||||||
|
signal: 'traces',
|
||||||
|
fieldContext: 'span',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'response_status_code',
|
||||||
|
fieldDataType: '',
|
||||||
|
signal: 'traces',
|
||||||
|
fieldContext: 'span',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// version,
|
||||||
|
ENTITY_VERSION_V5,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEntryPageChange = useCallback(
|
||||||
|
(newPage: number) => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
params.set('entryPage', newPage.toString());
|
||||||
|
safeNavigate({ search: params.toString() });
|
||||||
|
},
|
||||||
|
[safeNavigate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hierarchicalData = useMemo(
|
||||||
|
() =>
|
||||||
|
// TODO(shaheer): properly fix the type
|
||||||
|
transformEntrySpansToHierarchy(
|
||||||
|
(data?.payload.data.newResult.data.result[0].list as unknown) as
|
||||||
|
| SpanDataRow[]
|
||||||
|
| undefined,
|
||||||
|
),
|
||||||
|
[data?.payload.data.newResult.data.result],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract nextCursor from the API response
|
||||||
|
const nextCursor = useMemo(
|
||||||
|
() => data?.payload?.data?.newResult?.data?.result?.[0]?.nextCursor || '',
|
||||||
|
[data?.payload?.data?.newResult?.data?.result],
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(data);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="span-list">
|
||||||
|
<div className="span-list__content">
|
||||||
|
<SpanTable
|
||||||
|
data={hierarchicalData}
|
||||||
|
isLoading={isLoading || isFetching}
|
||||||
|
traceId={traceId}
|
||||||
|
setSelectedSpan={setSelectedSpan}
|
||||||
|
entryPagination={{
|
||||||
|
currentPage: entryPage,
|
||||||
|
totalCount: entryPageSize,
|
||||||
|
pageSize: entryPageSize,
|
||||||
|
onPageChange: handleEntryPageChange,
|
||||||
|
isLoading: isLoading || isFetching,
|
||||||
|
nextCursor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
SpanList.defaultProps = {
|
||||||
|
traceId: undefined,
|
||||||
|
setSelectedSpan: (): void => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SpanList;
|
||||||
614
frontend/src/container/SpanList/SpanTable.tsx
Normal file
614
frontend/src/container/SpanList/SpanTable.tsx
Normal file
@@ -0,0 +1,614 @@
|
|||||||
|
import { Button } from '@signozhq/button';
|
||||||
|
import { ColumnDef, DataTable, Row } from '@signozhq/table';
|
||||||
|
import getTraceV2 from 'api/trace/getTraceV2';
|
||||||
|
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||||
|
import Controls from 'container/Controls';
|
||||||
|
import { ChevronDownIcon, ChevronRightIcon } from 'lucide-react';
|
||||||
|
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
|
||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { Span } from 'types/api/trace/getTraceV2';
|
||||||
|
|
||||||
|
import { HierarchicalSpanData, ServiceEntrySpan, SpanDataRow } from './types';
|
||||||
|
import { fetchServiceSpans } from './utils';
|
||||||
|
|
||||||
|
interface EntryPagination {
|
||||||
|
currentPage: number;
|
||||||
|
totalCount: number;
|
||||||
|
pageSize: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onPageSizeChange?: (pageSize: number) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
nextCursor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
const SPAN_TYPE_ENTRY = 'entry-span';
|
||||||
|
const SPAN_TYPE_SERVICE = 'service-span';
|
||||||
|
const SPAN_TYPE_PAGINATION = 'pagination-row';
|
||||||
|
const SERVICE_SPAN_PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
interface SpanTableProps {
|
||||||
|
data: HierarchicalSpanData;
|
||||||
|
traceId?: string;
|
||||||
|
setSelectedSpan?: (span: Span) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
entryPagination: EntryPagination;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableRowData {
|
||||||
|
id: string;
|
||||||
|
type:
|
||||||
|
| typeof SPAN_TYPE_ENTRY
|
||||||
|
| typeof SPAN_TYPE_SERVICE
|
||||||
|
| typeof SPAN_TYPE_PAGINATION;
|
||||||
|
spanName?: string;
|
||||||
|
serviceName?: string;
|
||||||
|
spanCount?: number;
|
||||||
|
duration?: string;
|
||||||
|
timestamp?: string;
|
||||||
|
statusCode?: string;
|
||||||
|
httpMethod?: string;
|
||||||
|
spanId?: string;
|
||||||
|
originalData?: ServiceEntrySpan | SpanDataRow;
|
||||||
|
isLoading?: boolean;
|
||||||
|
entrySpanId?: string; // For pagination rows
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpanTable({
|
||||||
|
data,
|
||||||
|
traceId,
|
||||||
|
setSelectedSpan,
|
||||||
|
isLoading,
|
||||||
|
entryPagination,
|
||||||
|
}: SpanTableProps): JSX.Element {
|
||||||
|
const [expandedEntrySpans, setExpandedEntrySpans] = useState<
|
||||||
|
Record<string, ServiceEntrySpan>
|
||||||
|
>({});
|
||||||
|
const [loadingSpans, setLoadingSpans] = useState<Record<string, boolean>>({});
|
||||||
|
const [serviceSpansPagination, setServiceSpansPagination] = useState<
|
||||||
|
Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
currentPage: number;
|
||||||
|
totalCount?: number;
|
||||||
|
hasMorePages?: boolean;
|
||||||
|
nextCursor?: string;
|
||||||
|
}
|
||||||
|
>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
const handleEntrySpanClick = useCallback(
|
||||||
|
async (entrySpan: ServiceEntrySpan) => {
|
||||||
|
const spanId = entrySpan.spanData.data.span_id;
|
||||||
|
|
||||||
|
if (expandedEntrySpans[spanId]) {
|
||||||
|
// Collapse - remove from expanded spans
|
||||||
|
const { [spanId]: removed, ...rest } = expandedEntrySpans;
|
||||||
|
setExpandedEntrySpans(rest);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand - fetch service spans
|
||||||
|
if (!entrySpan.serviceSpans && traceId) {
|
||||||
|
setLoadingSpans((prev) => ({ ...prev, [spanId]: true }));
|
||||||
|
|
||||||
|
// Initialize pagination state for this entry span
|
||||||
|
setServiceSpansPagination((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[spanId]: { currentPage: 1, hasMorePages: false },
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentPage = 1;
|
||||||
|
const offset = (currentPage - 1) * SERVICE_SPAN_PAGE_SIZE;
|
||||||
|
const { spans: serviceSpans, nextCursor } = await fetchServiceSpans(
|
||||||
|
traceId,
|
||||||
|
entrySpan.serviceName,
|
||||||
|
SERVICE_SPAN_PAGE_SIZE,
|
||||||
|
offset,
|
||||||
|
);
|
||||||
|
const updatedEntrySpan = {
|
||||||
|
...entrySpan,
|
||||||
|
serviceSpans,
|
||||||
|
isExpanded: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update pagination with nextCursor
|
||||||
|
setServiceSpansPagination((prevPagination) => ({
|
||||||
|
...prevPagination,
|
||||||
|
[spanId]: {
|
||||||
|
...prevPagination[spanId],
|
||||||
|
hasMorePages: serviceSpans.length === SERVICE_SPAN_PAGE_SIZE,
|
||||||
|
nextCursor,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
setExpandedEntrySpans((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[spanId]: updatedEntrySpan,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch service spans:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingSpans((prev) => ({ ...prev, [spanId]: false }));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Already have service spans, just toggle expansion
|
||||||
|
setExpandedEntrySpans((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[spanId]: { ...entrySpan, isExpanded: true },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[expandedEntrySpans, traceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleServiceSpanPageChange = useCallback(
|
||||||
|
async (entrySpanId: string, newPage: number) => {
|
||||||
|
if (!traceId) return;
|
||||||
|
|
||||||
|
const entrySpan = expandedEntrySpans[entrySpanId];
|
||||||
|
if (!entrySpan) return;
|
||||||
|
|
||||||
|
setLoadingSpans((prev) => ({ ...prev, [entrySpanId]: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const offset = (newPage - 1) * SERVICE_SPAN_PAGE_SIZE;
|
||||||
|
const { spans: serviceSpans, nextCursor } = await fetchServiceSpans(
|
||||||
|
traceId,
|
||||||
|
entrySpan.serviceName,
|
||||||
|
SERVICE_SPAN_PAGE_SIZE,
|
||||||
|
offset,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update pagination with nextCursor
|
||||||
|
setServiceSpansPagination((prevPagination) => ({
|
||||||
|
...prevPagination,
|
||||||
|
[entrySpanId]: {
|
||||||
|
...prevPagination[entrySpanId],
|
||||||
|
currentPage: newPage,
|
||||||
|
hasMorePages: serviceSpans.length === SERVICE_SPAN_PAGE_SIZE,
|
||||||
|
nextCursor,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Update the expanded entry span with new service spans
|
||||||
|
setExpandedEntrySpans((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[entrySpanId]: {
|
||||||
|
...entrySpan,
|
||||||
|
serviceSpans,
|
||||||
|
isExpanded: true,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch service spans for page:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingSpans((prev) => ({ ...prev, [entrySpanId]: false }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[expandedEntrySpans, traceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleServiceSpanNavigatePrevious = useCallback(
|
||||||
|
(entrySpanId: string) => {
|
||||||
|
const pagination = serviceSpansPagination[entrySpanId];
|
||||||
|
if (pagination && pagination.currentPage > 1) {
|
||||||
|
handleServiceSpanPageChange(entrySpanId, pagination.currentPage - 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[serviceSpansPagination, handleServiceSpanPageChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleServiceSpanNavigateNext = useCallback(
|
||||||
|
(entrySpanId: string) => {
|
||||||
|
const pagination = serviceSpansPagination[entrySpanId];
|
||||||
|
if (pagination && pagination.hasMorePages) {
|
||||||
|
handleServiceSpanPageChange(entrySpanId, pagination.currentPage + 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[serviceSpansPagination, handleServiceSpanPageChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSpanClick = useCallback(
|
||||||
|
async (span: SpanDataRow): Promise<void> => {
|
||||||
|
if (!setSelectedSpan || !traceId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Make API call to fetch full span details
|
||||||
|
const response = await getTraceV2({
|
||||||
|
traceId,
|
||||||
|
selectedSpanId: span.data.span_id,
|
||||||
|
uncollapsedSpans: [],
|
||||||
|
isSelectedSpanIDUnCollapsed: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.payload?.spans) {
|
||||||
|
const fullSpan = response.payload.spans.find(
|
||||||
|
(s: Span) => s.spanId === span.data.span_id,
|
||||||
|
);
|
||||||
|
console.log({ fullSpan });
|
||||||
|
if (fullSpan) {
|
||||||
|
setSelectedSpan(fullSpan);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch span details:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setSelectedSpan, traceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderNameCell = useCallback(
|
||||||
|
({ row }: { row: Row<TableRowData> }): JSX.Element => {
|
||||||
|
const { original } = row;
|
||||||
|
if (original.type === SPAN_TYPE_ENTRY) {
|
||||||
|
return (
|
||||||
|
<LineClampedText
|
||||||
|
text={original.spanName || ''}
|
||||||
|
lines={1}
|
||||||
|
tooltipProps={{ placement: 'topLeft' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (original.type === SPAN_TYPE_PAGINATION) return <div />;
|
||||||
|
|
||||||
|
// Service span (nested)
|
||||||
|
return (
|
||||||
|
<LineClampedText
|
||||||
|
text={original.spanName || ''}
|
||||||
|
lines={1}
|
||||||
|
tooltipProps={{ placement: 'topLeft' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderServiceCell = useCallback(
|
||||||
|
({ row }: { row: Row<TableRowData> }): JSX.Element | null => {
|
||||||
|
const { original } = row;
|
||||||
|
if (original.type === SPAN_TYPE_PAGINATION) return null;
|
||||||
|
|
||||||
|
if (original.type === SPAN_TYPE_ENTRY) {
|
||||||
|
const entrySpan = original.originalData as ServiceEntrySpan;
|
||||||
|
const spanId = entrySpan.spanData.data.span_id;
|
||||||
|
const isExpanded = !!expandedEntrySpans[spanId];
|
||||||
|
const isLoading = loadingSpans[spanId];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="service-cell-with-expand">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
loading={isLoading}
|
||||||
|
prefixIcon={
|
||||||
|
!isLoading && isExpanded ? (
|
||||||
|
<ChevronDownIcon size={16} />
|
||||||
|
) : (
|
||||||
|
<ChevronRightIcon size={16} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={(e): void => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleEntrySpanClick(entrySpan);
|
||||||
|
}}
|
||||||
|
className="expand-button"
|
||||||
|
/>
|
||||||
|
<LineClampedText
|
||||||
|
text={original.serviceName || ''}
|
||||||
|
lines={1}
|
||||||
|
tooltipProps={{ placement: 'topLeft' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="service-cell-child">
|
||||||
|
<LineClampedText
|
||||||
|
text={original.serviceName || ''}
|
||||||
|
lines={1}
|
||||||
|
tooltipProps={{ placement: 'topLeft' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[expandedEntrySpans, loadingSpans, handleEntrySpanClick],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderDurationCell = useCallback(
|
||||||
|
({ row }: { row: Row<TableRowData> }): JSX.Element | null => {
|
||||||
|
const { original } = row;
|
||||||
|
if (original.type === SPAN_TYPE_PAGINATION) return null;
|
||||||
|
return <span>{original.duration}</span>;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderTimestampCell = useCallback(
|
||||||
|
({ row }: { row: Row<TableRowData> }): JSX.Element | null => {
|
||||||
|
const { original } = row;
|
||||||
|
if (original.type === SPAN_TYPE_PAGINATION) {
|
||||||
|
const { entrySpanId } = original;
|
||||||
|
if (!entrySpanId) return <div />;
|
||||||
|
|
||||||
|
const entrySpan = expandedEntrySpans[entrySpanId];
|
||||||
|
const pagination = serviceSpansPagination[entrySpanId];
|
||||||
|
|
||||||
|
if (!entrySpan || !pagination) return <div />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="service-span-pagination">
|
||||||
|
<Controls
|
||||||
|
offset={(pagination.currentPage - 1) * SERVICE_SPAN_PAGE_SIZE}
|
||||||
|
totalCount={SERVICE_SPAN_PAGE_SIZE * 10}
|
||||||
|
countPerPage={SERVICE_SPAN_PAGE_SIZE}
|
||||||
|
isLoading={loadingSpans[entrySpanId] || false}
|
||||||
|
handleNavigatePrevious={(): void =>
|
||||||
|
handleServiceSpanNavigatePrevious(entrySpanId)
|
||||||
|
}
|
||||||
|
handleNavigateNext={(): void =>
|
||||||
|
handleServiceSpanNavigateNext(entrySpanId)
|
||||||
|
}
|
||||||
|
handleCountItemsPerPageChange={(): void => {}} // Service spans use fixed page size
|
||||||
|
showSizeChanger={false} // Disable page size changer for service spans
|
||||||
|
nextCursor={pagination.nextCursor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="timestamp">
|
||||||
|
{new Date(original.timestamp || '').toLocaleString()}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
expandedEntrySpans,
|
||||||
|
loadingSpans,
|
||||||
|
serviceSpansPagination,
|
||||||
|
handleServiceSpanNavigatePrevious,
|
||||||
|
handleServiceSpanNavigateNext,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderStatusCodeCell = useCallback(
|
||||||
|
({ row }: { row: Row<TableRowData> }): JSX.Element | null => {
|
||||||
|
const { original } = row;
|
||||||
|
if (original.type === SPAN_TYPE_PAGINATION) return null;
|
||||||
|
return <span>{original.statusCode || '-'}</span>;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderSpanIdCell = useCallback(
|
||||||
|
({ row }: { row: Row<TableRowData> }): JSX.Element | null => {
|
||||||
|
const { original } = row;
|
||||||
|
if (original.type === SPAN_TYPE_PAGINATION) return null;
|
||||||
|
return <span className="span-id">{original.spanId}</span>;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderHttpMethodCell = useCallback(
|
||||||
|
// eslint-disable-next-line react/no-unused-prop-types
|
||||||
|
({ row }: { row: Row<TableRowData> }): JSX.Element | null => {
|
||||||
|
const { original } = row;
|
||||||
|
if (original.type === SPAN_TYPE_PAGINATION) return null;
|
||||||
|
return <span>{original.httpMethod || '-'}</span>;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns: ColumnDef<TableRowData>[] = [
|
||||||
|
{
|
||||||
|
id: 'service',
|
||||||
|
header: 'Service',
|
||||||
|
accessorKey: 'serviceName',
|
||||||
|
size: 120,
|
||||||
|
cell: renderServiceCell,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'spanId',
|
||||||
|
header: 'Span ID',
|
||||||
|
accessorKey: 'spanId',
|
||||||
|
size: 150,
|
||||||
|
cell: renderSpanIdCell,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
header: 'Span Name',
|
||||||
|
accessorKey: 'spanName',
|
||||||
|
cell: renderNameCell,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'httpMethod',
|
||||||
|
header: 'Method',
|
||||||
|
accessorKey: 'httpMethod',
|
||||||
|
size: 80,
|
||||||
|
cell: renderHttpMethodCell,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'statusCode',
|
||||||
|
header: 'Status',
|
||||||
|
accessorKey: 'statusCode',
|
||||||
|
size: 80,
|
||||||
|
cell: renderStatusCodeCell,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'duration',
|
||||||
|
header: 'Duration',
|
||||||
|
accessorKey: 'duration',
|
||||||
|
size: 120,
|
||||||
|
cell: renderDurationCell,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'timestamp',
|
||||||
|
header: 'Timestamp',
|
||||||
|
accessorKey: 'timestamp',
|
||||||
|
size: 180,
|
||||||
|
cell: renderTimestampCell,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const flattenedData = useMemo(() => {
|
||||||
|
const result: TableRowData[] = [];
|
||||||
|
|
||||||
|
data.entrySpans.forEach((entrySpan) => {
|
||||||
|
const spanId = entrySpan.spanData.data.span_id;
|
||||||
|
|
||||||
|
// Calculate span count for this service
|
||||||
|
const expandedSpan = expandedEntrySpans[spanId];
|
||||||
|
const spanCount = expandedSpan?.serviceSpans?.length || 0;
|
||||||
|
|
||||||
|
// Add entry span row
|
||||||
|
result.push({
|
||||||
|
id: spanId,
|
||||||
|
type: SPAN_TYPE_ENTRY,
|
||||||
|
spanName: entrySpan.spanData.data.name,
|
||||||
|
serviceName: entrySpan.serviceName,
|
||||||
|
spanCount: spanCount > 0 ? spanCount : undefined,
|
||||||
|
duration: getYAxisFormattedValue(
|
||||||
|
entrySpan.spanData.data.duration_nano.toString(),
|
||||||
|
'ns',
|
||||||
|
),
|
||||||
|
timestamp: entrySpan.spanData.timestamp,
|
||||||
|
statusCode: entrySpan.spanData.data.response_status_code,
|
||||||
|
httpMethod: entrySpan.spanData.data.http_method,
|
||||||
|
spanId,
|
||||||
|
originalData: entrySpan,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add service spans if expanded
|
||||||
|
if (expandedSpan?.serviceSpans) {
|
||||||
|
expandedSpan.serviceSpans.forEach((serviceSpan) => {
|
||||||
|
result.push({
|
||||||
|
id: serviceSpan.data.span_id,
|
||||||
|
type: SPAN_TYPE_SERVICE,
|
||||||
|
spanName: serviceSpan.data.name,
|
||||||
|
serviceName: serviceSpan.data['service.name'],
|
||||||
|
duration: getYAxisFormattedValue(
|
||||||
|
serviceSpan.data.duration_nano.toString(),
|
||||||
|
'ns',
|
||||||
|
),
|
||||||
|
timestamp: serviceSpan.timestamp,
|
||||||
|
statusCode: serviceSpan.data.response_status_code,
|
||||||
|
httpMethod: serviceSpan.data.http_method,
|
||||||
|
spanId: serviceSpan.data.span_id,
|
||||||
|
originalData: serviceSpan,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add pagination row for service spans if we have spans and (current page > 1 or more pages available)
|
||||||
|
const pagination = serviceSpansPagination[spanId];
|
||||||
|
if (
|
||||||
|
pagination &&
|
||||||
|
expandedSpan.serviceSpans.length > 0 &&
|
||||||
|
(pagination.currentPage > 1 || pagination.hasMorePages)
|
||||||
|
) {
|
||||||
|
result.push({
|
||||||
|
id: `${spanId}-pagination`,
|
||||||
|
type: SPAN_TYPE_PAGINATION,
|
||||||
|
entrySpanId: spanId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [data.entrySpans, expandedEntrySpans, serviceSpansPagination]);
|
||||||
|
|
||||||
|
const handleRowClick = useCallback(
|
||||||
|
(row: Row<TableRowData>) => {
|
||||||
|
const { original } = row;
|
||||||
|
if (original.type === SPAN_TYPE_ENTRY) {
|
||||||
|
// For entry spans, expand/collapse
|
||||||
|
const entrySpan = original.originalData as ServiceEntrySpan;
|
||||||
|
handleEntrySpanClick(entrySpan);
|
||||||
|
} else if (original.type === SPAN_TYPE_SERVICE) {
|
||||||
|
// For service spans, trigger API call to fetch full details
|
||||||
|
handleSpanClick(original.originalData as SpanDataRow);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleEntrySpanClick, handleSpanClick],
|
||||||
|
);
|
||||||
|
|
||||||
|
const args = {
|
||||||
|
columns,
|
||||||
|
tableId: 'span-list-table',
|
||||||
|
enableSorting: false,
|
||||||
|
enableFiltering: false,
|
||||||
|
enableGlobalFilter: false,
|
||||||
|
enableColumnReordering: false,
|
||||||
|
enableColumnResizing: true,
|
||||||
|
enableColumnPinning: false,
|
||||||
|
enableRowSelection: false,
|
||||||
|
enablePagination: false,
|
||||||
|
showHeaders: true,
|
||||||
|
defaultColumnWidth: 150,
|
||||||
|
minColumnWidth: 80,
|
||||||
|
maxColumnWidth: 300,
|
||||||
|
enableVirtualization: false,
|
||||||
|
fixedHeight: 600,
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNavigatePrevious = useCallback(() => {
|
||||||
|
if (entryPagination.currentPage > 1) {
|
||||||
|
entryPagination.onPageChange(entryPagination.currentPage - 1);
|
||||||
|
}
|
||||||
|
}, [entryPagination]);
|
||||||
|
|
||||||
|
const handleNavigateNext = useCallback(() => {
|
||||||
|
entryPagination.onPageChange(entryPagination.currentPage + 1);
|
||||||
|
}, [entryPagination]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="span-table">
|
||||||
|
<div className="span-table-content">
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
tableId={args.tableId}
|
||||||
|
enableSorting={args.enableSorting}
|
||||||
|
enableFiltering={args.enableFiltering}
|
||||||
|
enableGlobalFilter={args.enableGlobalFilter}
|
||||||
|
enableColumnReordering={args.enableColumnReordering}
|
||||||
|
enableColumnResizing={args.enableColumnResizing}
|
||||||
|
enableColumnPinning={args.enableColumnPinning}
|
||||||
|
enableRowSelection={args.enableRowSelection}
|
||||||
|
enablePagination={args.enablePagination}
|
||||||
|
showHeaders={args.showHeaders}
|
||||||
|
defaultColumnWidth={args.defaultColumnWidth}
|
||||||
|
minColumnWidth={args.minColumnWidth}
|
||||||
|
maxColumnWidth={args.maxColumnWidth}
|
||||||
|
enableVirtualization={args.enableVirtualization}
|
||||||
|
fixedHeight={args.fixedHeight}
|
||||||
|
data={flattenedData}
|
||||||
|
onRowClick={handleRowClick}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
<div className="entry-pagination">
|
||||||
|
<Controls
|
||||||
|
offset={(entryPagination.currentPage - 1) * entryPagination.pageSize}
|
||||||
|
countPerPage={entryPagination.pageSize}
|
||||||
|
totalCount={flattenedData.length}
|
||||||
|
isLoading={entryPagination.isLoading || isLoading || false}
|
||||||
|
handleNavigatePrevious={handleNavigatePrevious}
|
||||||
|
handleNavigateNext={handleNavigateNext}
|
||||||
|
handleCountItemsPerPageChange={(): void => {}}
|
||||||
|
showSizeChanger={false}
|
||||||
|
nextCursor={entryPagination.nextCursor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
SpanTable.defaultProps = {
|
||||||
|
traceId: undefined,
|
||||||
|
setSelectedSpan: undefined,
|
||||||
|
isLoading: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SpanTable;
|
||||||
47
frontend/src/container/SpanList/types.ts
Normal file
47
frontend/src/container/SpanList/types.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
export interface SpanData {
|
||||||
|
duration_nano: number;
|
||||||
|
http_method: string;
|
||||||
|
name: string;
|
||||||
|
response_status_code: string;
|
||||||
|
'service.name': string;
|
||||||
|
span_id: string;
|
||||||
|
timestamp: string;
|
||||||
|
trace_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpanDataRow {
|
||||||
|
data: SpanData;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse {
|
||||||
|
status: string;
|
||||||
|
data: {
|
||||||
|
type: string;
|
||||||
|
meta: {
|
||||||
|
rowsScanned: number;
|
||||||
|
bytesScanned: number;
|
||||||
|
durationMs: number;
|
||||||
|
};
|
||||||
|
data: {
|
||||||
|
results: Array<{
|
||||||
|
queryName: string;
|
||||||
|
nextCursor: string;
|
||||||
|
rows: SpanDataRow[];
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceEntrySpan {
|
||||||
|
spanData: SpanDataRow;
|
||||||
|
serviceName: string;
|
||||||
|
isExpanded?: boolean;
|
||||||
|
serviceSpans?: SpanDataRow[]; // All spans for this service when expanded
|
||||||
|
isLoadingServiceSpans?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HierarchicalSpanData {
|
||||||
|
entrySpans: ServiceEntrySpan[];
|
||||||
|
totalTraceTime: number;
|
||||||
|
}
|
||||||
137
frontend/src/container/SpanList/utils.ts
Normal file
137
frontend/src/container/SpanList/utils.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||||
|
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
|
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||||
|
import { uniqBy } from 'lodash-es';
|
||||||
|
|
||||||
|
import { HierarchicalSpanData, ServiceEntrySpan, SpanDataRow } from './types';
|
||||||
|
|
||||||
|
export function transformEntrySpansToHierarchy(
|
||||||
|
entrySpans?: SpanDataRow[],
|
||||||
|
): HierarchicalSpanData {
|
||||||
|
let totalTraceTime = 0;
|
||||||
|
|
||||||
|
if (!entrySpans) {
|
||||||
|
return { entrySpans: [], totalTraceTime: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueEntrySpans = uniqBy(entrySpans, 'data.span_id');
|
||||||
|
|
||||||
|
// Calculate total trace time from all entry spans
|
||||||
|
uniqueEntrySpans.forEach((span) => {
|
||||||
|
totalTraceTime += span.data.duration_nano;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transform entry spans to ServiceEntrySpan structure
|
||||||
|
const entrySpansList: ServiceEntrySpan[] = uniqueEntrySpans.map((span) => ({
|
||||||
|
spanData: span,
|
||||||
|
serviceName: span.data['service.name'],
|
||||||
|
isExpanded: false,
|
||||||
|
serviceSpans: undefined,
|
||||||
|
isLoadingServiceSpans: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
entrySpans: entrySpansList,
|
||||||
|
totalTraceTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchServiceSpans(
|
||||||
|
traceId: string,
|
||||||
|
serviceName: string,
|
||||||
|
pageSize = 10,
|
||||||
|
offset = 0,
|
||||||
|
): Promise<{ spans: SpanDataRow[]; nextCursor: string }> {
|
||||||
|
// Use the same payload structure as in SpanList component but with service-specific filter
|
||||||
|
const payload = initialQueriesMap.traces;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await GetMetricQueryRange(
|
||||||
|
{
|
||||||
|
graphType: PANEL_TYPES.LIST,
|
||||||
|
selectedTime: 'GLOBAL_TIME',
|
||||||
|
query: {
|
||||||
|
...payload,
|
||||||
|
builder: {
|
||||||
|
...payload.builder,
|
||||||
|
queryData: [
|
||||||
|
{
|
||||||
|
...payload.builder.queryData[0],
|
||||||
|
...{
|
||||||
|
name: 'A',
|
||||||
|
signal: 'traces',
|
||||||
|
stepInterval: null,
|
||||||
|
disabled: false,
|
||||||
|
filter: {
|
||||||
|
expression: `trace_id = '${traceId}' and service.name = '${serviceName}'`,
|
||||||
|
},
|
||||||
|
limit: pageSize,
|
||||||
|
offset,
|
||||||
|
order: [
|
||||||
|
{
|
||||||
|
key: {
|
||||||
|
name: 'timestamp',
|
||||||
|
},
|
||||||
|
direction: 'desc',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
having: {
|
||||||
|
expression: '',
|
||||||
|
},
|
||||||
|
selectFields: [
|
||||||
|
{
|
||||||
|
name: 'service.name',
|
||||||
|
fieldDataType: 'string',
|
||||||
|
signal: 'traces',
|
||||||
|
fieldContext: 'resource',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
fieldDataType: 'string',
|
||||||
|
signal: 'traces',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'duration_nano',
|
||||||
|
fieldDataType: '',
|
||||||
|
signal: 'traces',
|
||||||
|
fieldContext: 'span',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'http_method',
|
||||||
|
fieldDataType: '',
|
||||||
|
signal: 'traces',
|
||||||
|
fieldContext: 'span',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'response_status_code',
|
||||||
|
fieldDataType: '',
|
||||||
|
signal: 'traces',
|
||||||
|
fieldContext: 'span',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ENTITY_VERSION_V5,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract spans from the API response using the same path as SpanList component
|
||||||
|
const spans =
|
||||||
|
response?.payload?.data?.newResult?.data?.result?.[0]?.list || [];
|
||||||
|
const nextCursor =
|
||||||
|
response?.payload?.data?.newResult?.data?.result?.[0]?.nextCursor || '';
|
||||||
|
|
||||||
|
// Transform the API response to SpanDataRow format if needed
|
||||||
|
// The API should return the correct format for traces, but we'll handle any potential transformation
|
||||||
|
return {
|
||||||
|
spans: (spans as unknown) as SpanDataRow[],
|
||||||
|
nextCursor,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch service spans:', error);
|
||||||
|
return { spans: [], nextCursor: '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -502,8 +502,15 @@ function Success(props: ISuccessProps): JSX.Element {
|
|||||||
isFilterActive,
|
isFilterActive,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
const urlQuery = useUrlQuery();
|
||||||
|
|
||||||
|
const viewParam = urlQuery.get('view');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (viewParam === 'span-list') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (interestedSpanId.spanId !== '' && virtualizerRef.current) {
|
if (interestedSpanId.spanId !== '' && virtualizerRef.current) {
|
||||||
const idx = spans.findIndex(
|
const idx = spans.findIndex(
|
||||||
(span) => span.spanId === interestedSpanId.spanId,
|
(span) => span.spanId === interestedSpanId.spanId,
|
||||||
@@ -526,7 +533,7 @@ function Success(props: ISuccessProps): JSX.Element {
|
|||||||
return prev;
|
return prev;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [interestedSpanId, setSelectedSpan, spans]);
|
}, [interestedSpanId, setSelectedSpan, spans, viewParam]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="success-content">
|
<div className="success-content">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import './TraceDetailV2.styles.scss';
|
import './TraceDetailV2.styles.scss';
|
||||||
|
|
||||||
|
import { UnorderedListOutlined } from '@ant-design/icons';
|
||||||
import {
|
import {
|
||||||
ResizableHandle,
|
ResizableHandle,
|
||||||
ResizablePanel,
|
ResizablePanel,
|
||||||
@@ -10,14 +11,16 @@ import FlamegraphImg from 'assets/TraceDetail/Flamegraph';
|
|||||||
import cx from 'classnames';
|
import cx from 'classnames';
|
||||||
import TraceFlamegraph from 'container/PaginatedTraceFlamegraph/PaginatedTraceFlamegraph';
|
import TraceFlamegraph from 'container/PaginatedTraceFlamegraph/PaginatedTraceFlamegraph';
|
||||||
import SpanDetailsDrawer from 'container/SpanDetailsDrawer/SpanDetailsDrawer';
|
import SpanDetailsDrawer from 'container/SpanDetailsDrawer/SpanDetailsDrawer';
|
||||||
|
import SpanList from 'container/SpanList/SpanList';
|
||||||
import TraceMetadata from 'container/TraceMetadata/TraceMetadata';
|
import TraceMetadata from 'container/TraceMetadata/TraceMetadata';
|
||||||
import TraceWaterfall, {
|
import TraceWaterfall, {
|
||||||
IInterestedSpan,
|
IInterestedSpan,
|
||||||
} from 'container/TraceWaterfall/TraceWaterfall';
|
} from 'container/TraceWaterfall/TraceWaterfall';
|
||||||
import useGetTraceV2 from 'hooks/trace/useGetTraceV2';
|
import useGetTraceV2 from 'hooks/trace/useGetTraceV2';
|
||||||
|
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||||
import useUrlQuery from 'hooks/useUrlQuery';
|
import useUrlQuery from 'hooks/useUrlQuery';
|
||||||
import { defaultTo } from 'lodash-es';
|
import { defaultTo } from 'lodash-es';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { Span, TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
|
import { Span, TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
|
||||||
|
|
||||||
@@ -26,6 +29,11 @@ import NoData from './NoData/NoData';
|
|||||||
function TraceDetailsV2(): JSX.Element {
|
function TraceDetailsV2(): JSX.Element {
|
||||||
const { id: traceId } = useParams<TraceDetailV2URLProps>();
|
const { id: traceId } = useParams<TraceDetailV2URLProps>();
|
||||||
const urlQuery = useUrlQuery();
|
const urlQuery = useUrlQuery();
|
||||||
|
const { safeNavigate } = useSafeNavigate();
|
||||||
|
const [activeTab, setActiveTab] = useState<string>(() => {
|
||||||
|
const viewParam = urlQuery.get('view');
|
||||||
|
return viewParam === 'span-list' ? 'span-list' : 'flamegraph';
|
||||||
|
});
|
||||||
const [interestedSpanId, setInterestedSpanId] = useState<IInterestedSpan>(
|
const [interestedSpanId, setInterestedSpanId] = useState<IInterestedSpan>(
|
||||||
() => ({
|
() => ({
|
||||||
spanId: urlQuery.get('spanId') || '',
|
spanId: urlQuery.get('spanId') || '',
|
||||||
@@ -67,6 +75,8 @@ function TraceDetailsV2(): JSX.Element {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedSpan) {
|
if (selectedSpan) {
|
||||||
setIsSpanDetailsDocked(false);
|
setIsSpanDetailsDocked(false);
|
||||||
|
} else {
|
||||||
|
setIsSpanDetailsDocked(true);
|
||||||
}
|
}
|
||||||
}, [selectedSpan]);
|
}, [selectedSpan]);
|
||||||
|
|
||||||
@@ -88,6 +98,35 @@ function TraceDetailsV2(): JSX.Element {
|
|||||||
}
|
}
|
||||||
}, [noData]);
|
}, [noData]);
|
||||||
|
|
||||||
|
const handleTabChange = useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
const urlQueryParams = new URLSearchParams();
|
||||||
|
|
||||||
|
if (key === 'span-list') {
|
||||||
|
urlQueryParams.set('view', 'span-list');
|
||||||
|
|
||||||
|
setIsSpanDetailsDocked(true);
|
||||||
|
setSelectedSpan(undefined);
|
||||||
|
} else if (selectedSpan) {
|
||||||
|
setIsSpanDetailsDocked(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
safeNavigate({ search: urlQueryParams.toString() });
|
||||||
|
setActiveTab(key);
|
||||||
|
},
|
||||||
|
[safeNavigate, selectedSpan],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync tab state with URL changes
|
||||||
|
useEffect(() => {
|
||||||
|
const viewParam = urlQuery.get('view');
|
||||||
|
const newActiveTab = viewParam === 'span-list' ? 'span-list' : 'flamegraph';
|
||||||
|
|
||||||
|
if (newActiveTab !== activeTab) {
|
||||||
|
setActiveTab(newActiveTab);
|
||||||
|
}
|
||||||
|
}, [urlQuery, activeTab, selectedSpan]);
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
label: (
|
label: (
|
||||||
@@ -124,6 +163,25 @@ function TraceDetailsV2(): JSX.Element {
|
|||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<UnorderedListOutlined />}
|
||||||
|
className="flamegraph-waterfall-toggle"
|
||||||
|
>
|
||||||
|
Span list
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
key: 'span-list',
|
||||||
|
children: (
|
||||||
|
<SpanList
|
||||||
|
traceId={traceId}
|
||||||
|
setSelectedSpan={setSelectedSpan}
|
||||||
|
traceData={traceData}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -147,7 +205,13 @@ function TraceDetailsV2(): JSX.Element {
|
|||||||
notFound={noData}
|
notFound={noData}
|
||||||
/>
|
/>
|
||||||
{!noData ? (
|
{!noData ? (
|
||||||
<Tabs items={items} animated className="trace-visualisation-tabs" />
|
<Tabs
|
||||||
|
items={items}
|
||||||
|
animated
|
||||||
|
className="trace-visualisation-tabs"
|
||||||
|
activeKey={activeTab}
|
||||||
|
onChange={handleTabChange}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<NoData />
|
<NoData />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export interface QueryDataV3 {
|
|||||||
}[];
|
}[];
|
||||||
columns: Column[];
|
columns: Column[];
|
||||||
};
|
};
|
||||||
|
nextCursor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
|
|||||||
@@ -4352,14 +4352,13 @@
|
|||||||
tailwind-merge "^2.5.2"
|
tailwind-merge "^2.5.2"
|
||||||
tailwindcss-animate "^1.0.7"
|
tailwindcss-animate "^1.0.7"
|
||||||
|
|
||||||
"@signozhq/table@0.3.7":
|
"@signozhq/table@0.4.0":
|
||||||
version "0.3.7"
|
version "0.4.0"
|
||||||
resolved "https://registry.yarnpkg.com/@signozhq/table/-/table-0.3.7.tgz#895b710c02af124dfb5117e02bbc6d80ce062063"
|
resolved "https://registry.yarnpkg.com/@signozhq/table/-/table-0.4.0.tgz#35e40c8a40f362ce65e68258cd7b87c8474918ae"
|
||||||
integrity sha512-XDwRHBTf2q48MOuxkzzr0htWd0/mmvgHoZLl0WAMLk2gddbbNHg9hkCPfVARYOke6mX8Z/4T3e7dzgkRUhDGDg==
|
integrity sha512-9Ktem3T9MHITh3+k1xTjhUpVpDJ5xNHijrUVuN6P9a4yP2/kqwXtHC4WGgOxJreUhp+bfvzQr956hR+ZAHnL3g==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@radix-ui/react-icons" "^1.3.0"
|
"@radix-ui/react-icons" "^1.3.0"
|
||||||
"@radix-ui/react-slot" "^1.1.0"
|
"@radix-ui/react-slot" "^1.1.0"
|
||||||
"@signozhq/tooltip" "0.0.2"
|
|
||||||
"@tanstack/react-table" "^8.21.3"
|
"@tanstack/react-table" "^8.21.3"
|
||||||
"@tanstack/react-virtual" "^3.13.9"
|
"@tanstack/react-virtual" "^3.13.9"
|
||||||
"@types/lodash-es" "^4.17.12"
|
"@types/lodash-es" "^4.17.12"
|
||||||
|
|||||||
Reference in New Issue
Block a user