feat: span list basic UI using static data

This commit is contained in:
ahmadshaheer 2025-09-14 17:41:20 +04:30
parent 144e866afc
commit da841650f5
10 changed files with 759 additions and 1 deletions

View File

@ -44,6 +44,7 @@
"@sentry/react": "8.41.0",
"@sentry/webpack-plugin": "2.22.6",
"@signozhq/badge": "0.0.2",
"@signozhq/button": "0.0.2",
"@signozhq/calendar": "0.0.0",
"@signozhq/callout": "0.0.2",
"@signozhq/design-tokens": "1.1.4",

View 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;

View File

@ -0,0 +1,81 @@
.span-list {
height: 100%;
display: flex;
flex-direction: column;
&__header {
padding: 16px;
border-bottom: 1px solid var(--bg-slate-400);
// background: var(--bg-vanilla-100);
}
&__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%;
.span-name-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);
}
}
}
.span-name {
padding-left: 24px;
}
[data-slot='table-cell'] {
font-size: 13px;
}
thead > [data-slot='table-row'] {
background: var(--bg-slate-400);
[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'] {
&:hover {
background: var(--bg-slate-500);
}
}
}

View File

@ -0,0 +1,43 @@
import './SpanList.styles.scss';
import { useMemo } from 'react';
import { Span } from 'types/api/trace/getTraceV2';
import { mockEntrySpanData } from './mockData';
import SearchFilters from './SearchFilters';
import SpanTable from './SpanTable';
import { transformEntrySpansToHierarchy } from './utils';
interface SpanListProps {
traceId?: string;
setSelectedSpan?: (span: Span) => void;
}
function SpanList({ traceId, setSelectedSpan }: SpanListProps): JSX.Element {
const hierarchicalData = useMemo(
() => transformEntrySpansToHierarchy(mockEntrySpanData),
[],
);
return (
<div className="span-list">
<div className="span-list__header">
<SearchFilters />
</div>
<div className="span-list__content">
<SpanTable
data={hierarchicalData}
traceId={traceId}
setSelectedSpan={setSelectedSpan}
/>
</div>
</div>
);
}
SpanList.defaultProps = {
traceId: undefined,
setSelectedSpan: (): void => {},
};
export default SpanList;

View File

@ -0,0 +1,367 @@
import { Button } from '@signozhq/button';
import { ColumnDef, DataTable, Row } from '@signozhq/table';
import { ChevronDownIcon, ChevronRightIcon } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { Span } from 'types/api/trace/getTraceV2';
import { HierarchicalSpanData, ServiceEntrySpan, SpanDataRow } from './types';
import { fetchServiceSpans, formatDuration } from './utils';
// Constants
const SPAN_TYPE_ENTRY = 'entry-span';
const SPAN_TYPE_SERVICE = 'service-span';
interface SpanTableProps {
data: HierarchicalSpanData;
traceId?: string;
setSelectedSpan?: (span: Span) => void;
}
interface TableRowData {
id: string;
type: typeof SPAN_TYPE_ENTRY | typeof SPAN_TYPE_SERVICE;
spanName?: string;
serviceName?: string;
spanCount?: number;
duration?: string;
timestamp?: string;
statusCode?: string;
httpMethod?: string;
spanId?: string;
originalData?: ServiceEntrySpan | SpanDataRow;
isLoading?: boolean;
}
function SpanTable({
data,
traceId,
setSelectedSpan,
}: SpanTableProps): JSX.Element {
const [expandedEntrySpans, setExpandedEntrySpans] = useState<
Record<string, ServiceEntrySpan>
>({});
const [loadingSpans, setLoadingSpans] = useState<Record<string, boolean>>({});
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 }));
try {
const serviceSpans = await fetchServiceSpans(
traceId,
entrySpan.serviceName,
);
const updatedEntrySpan = {
...entrySpan,
serviceSpans,
isExpanded: true,
};
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 handleSpanClick = useCallback(
(span: SpanDataRow) => {
if (setSelectedSpan) {
// Convert span data to the format expected by SpanDetailsDrawer
const convertedSpan = ({
id: span.data.span_id,
traceID: span.data.trace_id,
spanID: span.data.span_id,
parentSpanID: '',
operationName: span.data.name,
startTime: new Date(span.timestamp).getTime() * 1000000, // Convert to nanoseconds
duration: span.data.duration_nano,
tags: [],
logs: [],
process: {
serviceName: span.data['service.name'],
tags: [],
},
} as unknown) as Span;
setSelectedSpan(convertedSpan);
}
},
[setSelectedSpan],
);
const renderNameCell = useCallback(
({ row }: { row: Row<TableRowData> }): JSX.Element => {
const { original } = row;
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="span-name-with-expand">
<Button
variant="ghost"
size="sm"
loading={isLoading}
prefixIcon={
!isLoading && isExpanded ? (
<ChevronDownIcon size={16} />
) : (
<ChevronRightIcon size={16} />
)
}
onClick={(): Promise<void> => handleEntrySpanClick(entrySpan)}
className="expand-button"
/>
<span className="span-name-text">{original.spanName}</span>
</div>
);
}
// Service span (nested)
return <div className="span-name">{original.spanName}</div>;
},
[expandedEntrySpans, loadingSpans, handleEntrySpanClick],
);
const renderServiceCell = useCallback(
({ row }: { row: Row<TableRowData> }): JSX.Element => {
const { original } = row;
return <span>{original.serviceName}</span>;
},
[],
);
const renderSpanCountCell = useCallback(
({ row }: { row: Row<TableRowData> }): JSX.Element => {
const { original } = row;
if (original.type === SPAN_TYPE_ENTRY && original.spanCount !== undefined) {
return <span>{original.spanCount}</span>;
}
return <span>-</span>;
},
[],
);
const renderDurationCell = useCallback(
({ row }: { row: Row<TableRowData> }): JSX.Element => {
const { original } = row;
return <span>{original.duration}</span>;
},
[],
);
const renderTimestampCell = useCallback(
({ row }: { row: Row<TableRowData> }): JSX.Element => {
const { original } = row;
return (
<span className="timestamp">
{new Date(original.timestamp || '').toLocaleString()}
</span>
);
},
[],
);
const renderStatusCodeCell = useCallback(
({ row }: { row: Row<TableRowData> }): JSX.Element => {
const { original } = row;
return <span>{original.statusCode}</span>;
},
[],
);
const renderHttpMethodCell = useCallback(
// eslint-disable-next-line react/no-unused-prop-types
({ row }: { row: Row<TableRowData> }): JSX.Element => {
const { original } = row;
return <span>{original.httpMethod || '-'}</span>;
},
[],
);
const columns: ColumnDef<TableRowData>[] = [
{
id: 'name',
header: 'Span Name',
accessorKey: 'spanName',
cell: renderNameCell,
},
{
id: 'timestamp',
header: 'Timestamp',
accessorKey: 'timestamp',
size: 180,
cell: renderTimestampCell,
},
{
id: 'httpMethod',
header: 'Method',
accessorKey: 'httpMethod',
size: 80,
cell: renderHttpMethodCell,
},
{
id: 'statusCode',
header: 'Status',
accessorKey: 'statusCode',
size: 80,
cell: renderStatusCodeCell,
},
{
id: 'service',
header: 'Service',
accessorKey: 'serviceName',
size: 120,
cell: renderServiceCell,
},
{
id: 'spans',
header: 'Spans',
accessorKey: 'spanCount',
size: 80,
cell: renderSpanCountCell,
},
{
id: 'duration',
header: 'Duration',
accessorKey: 'duration',
size: 120,
cell: renderDurationCell,
},
];
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: formatDuration(entrySpan.spanData.data.duration_nano),
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: formatDuration(serviceSpan.data.duration_nano),
timestamp: serviceSpan.timestamp,
statusCode: serviceSpan.data.response_status_code,
httpMethod: serviceSpan.data.http_method,
spanId: serviceSpan.data.span_id,
originalData: serviceSpan,
});
});
}
});
return result;
}, [data.entrySpans, expandedEntrySpans]);
const handleRowClick = useCallback(
(row: Row<TableRowData>) => {
const { original } = row;
if (original.type === SPAN_TYPE_ENTRY) {
handleEntrySpanClick(original.originalData as ServiceEntrySpan);
} else if (original.type === SPAN_TYPE_SERVICE) {
handleSpanClick(original.originalData as SpanDataRow);
}
},
[handleEntrySpanClick, handleSpanClick],
);
const args = {
columns,
tableId: 'span-list-table',
enableSorting: false,
enableFiltering: false,
enableGlobalFilter: false,
enableColumnReordering: false,
enableColumnResizing: false,
enableColumnPinning: false,
enableRowSelection: false,
enablePagination: false,
showHeaders: true,
defaultColumnWidth: 150,
minColumnWidth: 80,
maxColumnWidth: 300,
enableVirtualization: false,
fixedHeight: 600,
};
return (
<div className="span-table">
<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}
/>
</div>
);
}
SpanTable.defaultProps = {
traceId: undefined,
setSelectedSpan: undefined,
};
export default SpanTable;

View File

@ -0,0 +1,96 @@
import { SpanDataRow } from './types';
export const mockEntrySpanData: SpanDataRow[] = [
{
data: {
duration_nano: 3878813,
http_method: '',
// eslint-disable-next-line sonarjs/no-duplicate-string
name: 'CurrencyService/Convert',
response_status_code: '0',
'service.name': 'currencyservice',
span_id: '8439d7461ab457a2',
timestamp: '2025-09-03T11:33:46.060209146Z',
trace_id: '5ad9c01671f0e38582efe03bbf81360a',
},
timestamp: '2025-09-03T11:33:46.060209146Z',
},
{
data: {
duration_nano: 12485759,
http_method: '',
name: 'CurrencyService/Convert',
response_status_code: '0',
'service.name': 'currencyservice',
span_id: '7dbf7811106b1049',
timestamp: '2025-09-03T11:33:46.058715482Z',
trace_id: '5ad9c01671f0e38582efe03bbf81360a',
},
timestamp: '2025-09-03T11:33:46.058715482Z',
},
{
data: {
duration_nano: 6950595,
http_method: '',
name: 'CurrencyService/Convert',
response_status_code: '0',
'service.name': 'currencyservice',
span_id: '9adcdf1baa56d23a',
timestamp: '2025-09-03T11:33:46.058319328Z',
trace_id: '5ad9c01671f0e38582efe03bbf81360a',
},
timestamp: '2025-09-03T11:33:46.058319328Z',
},
{
data: {
duration_nano: 5323696,
http_method: '',
name: 'CurrencyService/Convert',
response_status_code: '0',
'service.name': 'currencyservice',
span_id: '1a1b01a750dfe4d6',
timestamp: '2025-09-03T11:33:46.057323233Z',
trace_id: '5ad9c01671f0e38582efe03bbf81360a',
},
timestamp: '2025-09-03T11:33:46.057323233Z',
},
{
data: {
duration_nano: 133659,
http_method: '',
name: 'oteldemo.ProductCatalogService/GetProduct',
response_status_code: '0',
'service.name': 'productcatalogservice',
span_id: '485a987e9dbf8ddd',
timestamp: '2025-09-03T11:33:45.963838319Z',
trace_id: '5ad9c01671f0e38582efe03bbf81360a',
},
timestamp: '2025-09-03T11:33:45.963838319Z',
},
{
data: {
duration_nano: 245000000,
http_method: 'GET',
name: '/api/checkout',
response_status_code: '200',
'service.name': 'checkoutservice',
span_id: 'abc123def456',
timestamp: '2025-09-03T11:33:45.800000000Z',
trace_id: '5ad9c01671f0e38582efe03bbf81360a',
},
timestamp: '2025-09-03T11:33:45.800000000Z',
},
{
data: {
duration_nano: 150000000,
http_method: 'POST',
name: '/api/payment',
response_status_code: '200',
'service.name': 'paymentservice',
span_id: 'def456ghi789',
timestamp: '2025-09-03T11:33:45.750000000Z',
trace_id: '5ad9c01671f0e38582efe03bbf81360a',
},
timestamp: '2025-09-03T11:33:45.750000000Z',
},
];

View 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;
}

View File

@ -0,0 +1,89 @@
import { HierarchicalSpanData, ServiceEntrySpan, SpanDataRow } from './types';
export function formatDuration(durationNano: number): string {
if (durationNano < 1000) {
return `${durationNano}ns`;
}
if (durationNano < 1000000) {
return `${(durationNano / 1000).toFixed(2)}μs`;
}
if (durationNano < 1000000000) {
return `${(durationNano / 1000000).toFixed(2)}ms`;
}
return `${(durationNano / 1000000000).toFixed(2)}s`;
}
export function transformEntrySpansToHierarchy(
entrySpans: SpanDataRow[],
): HierarchicalSpanData {
let totalTraceTime = 0;
// Calculate total trace time from all entry spans
entrySpans.forEach((span) => {
totalTraceTime += span.data.duration_nano;
});
// Transform entry spans to ServiceEntrySpan structure
const entrySpansList: ServiceEntrySpan[] = entrySpans.map((span) => ({
spanData: span,
serviceName: span.data['service.name'],
isExpanded: false,
serviceSpans: undefined,
isLoadingServiceSpans: false,
}));
// Sort by timestamp (most recent first)
entrySpansList.sort(
(a, b) =>
new Date(b.spanData.timestamp).getTime() -
new Date(a.spanData.timestamp).getTime(),
);
return {
entrySpans: entrySpansList,
totalTraceTime,
};
}
// Mock function to simulate fetching service spans
export function fetchServiceSpans(
traceId: string,
serviceName: string,
): Promise<SpanDataRow[]> {
// This would normally make an API call to get spans for the specific service
// For now, return mock data filtered by service name
return new Promise((resolve) => {
setTimeout(() => {
// Mock response - in real implementation, this would call the API
const mockServiceSpans: SpanDataRow[] = [
{
data: {
duration_nano: 1500000,
http_method: 'GET',
name: `${serviceName}/internal-call-1`,
response_status_code: '200',
'service.name': serviceName,
span_id: `${serviceName}-span-1`,
timestamp: '2025-09-03T11:33:46.100000000Z',
trace_id: traceId,
},
timestamp: '2025-09-03T11:33:46.100000000Z',
},
{
data: {
duration_nano: 2500000,
http_method: 'POST',
name: `${serviceName}/internal-call-2`,
response_status_code: '200',
'service.name': serviceName,
span_id: `${serviceName}-span-2`,
timestamp: '2025-09-03T11:33:46.200000000Z',
trace_id: traceId,
},
timestamp: '2025-09-03T11:33:46.200000000Z',
},
];
resolve(mockServiceSpans);
}, 500); // Simulate network delay
});
}

View File

@ -1,9 +1,11 @@
import './TraceDetailV2.styles.scss';
import { UnorderedListOutlined } from '@ant-design/icons';
import { Button, Tabs } from 'antd';
import FlamegraphImg from 'assets/TraceDetail/Flamegraph';
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,
@ -118,6 +120,19 @@ 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} />,
},
];
return (

View File

@ -4247,7 +4247,7 @@
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
"@signozhq/button@^0.0.2":
"@signozhq/button@0.0.2", "@signozhq/button@^0.0.2":
version "0.0.2"
resolved "https://registry.yarnpkg.com/@signozhq/button/-/button-0.0.2.tgz#c13edef1e735134b784a41f874b60a14bc16993f"
integrity sha512-434/gbTykC00LrnzFPp7c33QPWZkf9n+8+SToLZFTB0rzcaS/xoB4b7QKhvk+8xLCj4zpw6BxfeRAL+gSoOUJw==