Compare commits
21 Commits
main
...
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/resizable": "0.0.0",
|
||||
"@signozhq/sonner": "0.1.0",
|
||||
"@signozhq/table": "0.3.7",
|
||||
"@signozhq/table": "0.4.0",
|
||||
"@signozhq/tooltip": "0.0.2",
|
||||
"@tanstack/react-table": "8.20.6",
|
||||
"@tanstack/react-virtual": "3.11.2",
|
||||
|
||||
@@ -264,6 +264,7 @@ function convertRawData(
|
||||
date: row.timestamp,
|
||||
} as any,
|
||||
})),
|
||||
nextCursor: rawData.nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
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 { memo, useMemo } from 'react';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { defaultSelectStyle } from './config';
|
||||
import { Container } from './styles';
|
||||
import { Container, StyledButton } from './styles';
|
||||
|
||||
function Controls({
|
||||
offset = 0,
|
||||
@@ -18,6 +18,7 @@ function Controls({
|
||||
handleCountItemsPerPageChange,
|
||||
isLogPanel = false,
|
||||
showSizeChanger = true,
|
||||
nextCursor,
|
||||
}: ControlsProps): JSX.Element | null {
|
||||
const isNextAndPreviousDisabled = useMemo(
|
||||
() => isLoading || countPerPage < 0 || totalCount === 0,
|
||||
@@ -27,15 +28,28 @@ function Controls({
|
||||
() => (isLogPanel ? false : offset <= 0 || isNextAndPreviousDisabled),
|
||||
[isLogPanel, isNextAndPreviousDisabled, offset],
|
||||
);
|
||||
const isNextDisabled = useMemo(
|
||||
() =>
|
||||
isLogPanel ? false : totalCount < countPerPage || isNextAndPreviousDisabled,
|
||||
[countPerPage, isLogPanel, isNextAndPreviousDisabled, totalCount],
|
||||
);
|
||||
const isNextDisabled = useMemo(() => {
|
||||
if (isLogPanel) return false;
|
||||
if (isNextAndPreviousDisabled) return true;
|
||||
|
||||
// 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 (
|
||||
<Container>
|
||||
<Button
|
||||
<StyledButton
|
||||
loading={isLoading}
|
||||
size="small"
|
||||
type="link"
|
||||
@@ -43,8 +57,8 @@ function Controls({
|
||||
onClick={handleNavigatePrevious}
|
||||
>
|
||||
<LeftOutlined /> Previous
|
||||
</Button>
|
||||
<Button
|
||||
</StyledButton>
|
||||
<StyledButton
|
||||
loading={isLoading}
|
||||
size="small"
|
||||
type="link"
|
||||
@@ -52,7 +66,7 @@ function Controls({
|
||||
onClick={handleNavigateNext}
|
||||
>
|
||||
Next <RightOutlined />
|
||||
</Button>
|
||||
</StyledButton>
|
||||
|
||||
{showSizeChanger && (
|
||||
<Select<Pagination['limit']>
|
||||
@@ -79,6 +93,7 @@ Controls.defaultProps = {
|
||||
perPageOptions: DEFAULT_PER_PAGE_OPTIONS,
|
||||
isLogPanel: false,
|
||||
showSizeChanger: true,
|
||||
nextCursor: undefined,
|
||||
};
|
||||
|
||||
export interface ControlsProps {
|
||||
@@ -92,6 +107,7 @@ export interface ControlsProps {
|
||||
handleCountItemsPerPageChange: (value: Pagination['limit']) => void;
|
||||
isLogPanel?: boolean;
|
||||
showSizeChanger?: boolean;
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
export default memo(Controls);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Button } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Container = styled.div`
|
||||
@@ -5,3 +6,8 @@ export const Container = styled.div`
|
||||
align-items: center;
|
||||
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,
|
||||
],
|
||||
);
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const viewParam = urlQuery.get('view');
|
||||
|
||||
useEffect(() => {
|
||||
if (viewParam === 'span-list') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (interestedSpanId.spanId !== '' && virtualizerRef.current) {
|
||||
const idx = spans.findIndex(
|
||||
(span) => span.spanId === interestedSpanId.spanId,
|
||||
@@ -526,7 +533,7 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}, [interestedSpanId, setSelectedSpan, spans]);
|
||||
}, [interestedSpanId, setSelectedSpan, spans, viewParam]);
|
||||
|
||||
return (
|
||||
<div className="success-content">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import './TraceDetailV2.styles.scss';
|
||||
|
||||
import { UnorderedListOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
@@ -10,14 +11,16 @@ import FlamegraphImg from 'assets/TraceDetail/Flamegraph';
|
||||
import cx from 'classnames';
|
||||
import TraceFlamegraph from 'container/PaginatedTraceFlamegraph/PaginatedTraceFlamegraph';
|
||||
import SpanDetailsDrawer from 'container/SpanDetailsDrawer/SpanDetailsDrawer';
|
||||
import SpanList from 'container/SpanList/SpanList';
|
||||
import TraceMetadata from 'container/TraceMetadata/TraceMetadata';
|
||||
import TraceWaterfall, {
|
||||
IInterestedSpan,
|
||||
} from 'container/TraceWaterfall/TraceWaterfall';
|
||||
import useGetTraceV2 from 'hooks/trace/useGetTraceV2';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
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 { Span, TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
|
||||
|
||||
@@ -26,6 +29,11 @@ import NoData from './NoData/NoData';
|
||||
function TraceDetailsV2(): JSX.Element {
|
||||
const { id: traceId } = useParams<TraceDetailV2URLProps>();
|
||||
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>(
|
||||
() => ({
|
||||
spanId: urlQuery.get('spanId') || '',
|
||||
@@ -67,6 +75,8 @@ function TraceDetailsV2(): JSX.Element {
|
||||
useEffect(() => {
|
||||
if (selectedSpan) {
|
||||
setIsSpanDetailsDocked(false);
|
||||
} else {
|
||||
setIsSpanDetailsDocked(true);
|
||||
}
|
||||
}, [selectedSpan]);
|
||||
|
||||
@@ -88,6 +98,35 @@ function TraceDetailsV2(): JSX.Element {
|
||||
}
|
||||
}, [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 = [
|
||||
{
|
||||
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 (
|
||||
@@ -147,7 +205,13 @@ function TraceDetailsV2(): JSX.Element {
|
||||
notFound={noData}
|
||||
/>
|
||||
{!noData ? (
|
||||
<Tabs items={items} animated className="trace-visualisation-tabs" />
|
||||
<Tabs
|
||||
items={items}
|
||||
animated
|
||||
className="trace-visualisation-tabs"
|
||||
activeKey={activeTab}
|
||||
onChange={handleTabChange}
|
||||
/>
|
||||
) : (
|
||||
<NoData />
|
||||
)}
|
||||
|
||||
@@ -81,6 +81,7 @@ export interface QueryDataV3 {
|
||||
}[];
|
||||
columns: Column[];
|
||||
};
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
|
||||
@@ -4352,14 +4352,13 @@
|
||||
tailwind-merge "^2.5.2"
|
||||
tailwindcss-animate "^1.0.7"
|
||||
|
||||
"@signozhq/table@0.3.7":
|
||||
version "0.3.7"
|
||||
resolved "https://registry.yarnpkg.com/@signozhq/table/-/table-0.3.7.tgz#895b710c02af124dfb5117e02bbc6d80ce062063"
|
||||
integrity sha512-XDwRHBTf2q48MOuxkzzr0htWd0/mmvgHoZLl0WAMLk2gddbbNHg9hkCPfVARYOke6mX8Z/4T3e7dzgkRUhDGDg==
|
||||
"@signozhq/table@0.4.0":
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@signozhq/table/-/table-0.4.0.tgz#35e40c8a40f362ce65e68258cd7b87c8474918ae"
|
||||
integrity sha512-9Ktem3T9MHITh3+k1xTjhUpVpDJ5xNHijrUVuN6P9a4yP2/kqwXtHC4WGgOxJreUhp+bfvzQr956hR+ZAHnL3g==
|
||||
dependencies:
|
||||
"@radix-ui/react-icons" "^1.3.0"
|
||||
"@radix-ui/react-slot" "^1.1.0"
|
||||
"@signozhq/tooltip" "0.0.2"
|
||||
"@tanstack/react-table" "^8.21.3"
|
||||
"@tanstack/react-virtual" "^3.13.9"
|
||||
"@types/lodash-es" "^4.17.12"
|
||||
|
||||
Reference in New Issue
Block a user