Compare commits

..

1 Commits

Author SHA1 Message Date
aks07
420d405e8a feat: add V3 trace API layer and types
- V3 waterfall API (POST /api/v3/traces/:traceId/waterfall)
- V3 types with attributes/resources fields on Span
- useGetTraceV3 hook with react-query
- Flamegraph limit parameter support
- useCopySpanLink switched to @signozhq/sonner toast
- localStorage key for span details panel position
2026-04-10 09:41:57 +05:30
120 changed files with 1830 additions and 16222 deletions

View File

@@ -2787,147 +2787,6 @@ components:
type: string
type: array
type: object
TracedetailtypesEvent:
properties:
attributeMap:
additionalProperties: {}
type: object
isError:
type: boolean
name:
type: string
timeUnixNano:
minimum: 0
type: integer
type: object
TracedetailtypesWaterfallRequest:
properties:
limit:
minimum: 0
type: integer
selectedSpanId:
type: string
uncollapsedSpans:
items:
type: string
nullable: true
type: array
type: object
TracedetailtypesWaterfallResponse:
properties:
endTimestampMillis:
minimum: 0
type: integer
hasMissingSpans:
type: boolean
hasMore:
type: boolean
rootServiceEntryPoint:
type: string
rootServiceName:
type: string
serviceNameToTotalDurationMap:
additionalProperties:
minimum: 0
type: integer
nullable: true
type: object
spans:
items:
$ref: '#/components/schemas/TracedetailtypesWaterfallSpan'
nullable: true
type: array
startTimestampMillis:
minimum: 0
type: integer
totalErrorSpansCount:
minimum: 0
type: integer
totalSpansCount:
minimum: 0
type: integer
uncollapsedSpans:
items:
type: string
nullable: true
type: array
type: object
TracedetailtypesWaterfallSpan:
properties:
attributes:
additionalProperties: {}
nullable: true
type: object
db_name:
type: string
db_operation:
type: string
duration_nano:
minimum: 0
type: integer
events:
items:
$ref: '#/components/schemas/TracedetailtypesEvent'
nullable: true
type: array
external_http_method:
type: string
external_http_url:
type: string
flags:
minimum: 0
type: integer
has_children:
type: boolean
has_error:
type: boolean
http_host:
type: string
http_method:
type: string
http_url:
type: string
is_remote:
type: string
kind:
format: int32
type: integer
kind_string:
type: string
level:
minimum: 0
type: integer
name:
type: string
parent_span_id:
type: string
resource:
additionalProperties:
type: string
nullable: true
type: object
response_status_code:
type: string
span_id:
type: string
status_code:
format: int32
type: integer
status_code_string:
type: string
status_message:
type: string
sub_tree_node_count:
minimum: 0
type: integer
timestamp:
minimum: 0
type: integer
trace_id:
type: string
trace_state:
type: string
type: object
TypesChangePasswordRequest:
properties:
newPassword:
@@ -9645,76 +9504,6 @@ paths:
summary: Put profile in Zeus for a deployment.
tags:
- zeus
/api/v3/traces/{traceID}/waterfall:
post:
deprecated: false
description: Returns the waterfall view of spans for a given trace ID with tree
structure, metadata, and windowed pagination
operationId: GetWaterfall
parameters:
- in: path
name: traceID
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/TracedetailtypesWaterfallRequest'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TracedetailtypesWaterfallResponse'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get waterfall view for a trace
tags:
- tracedetail
/api/v5/query_range:
post:
deprecated: false

View File

@@ -67,7 +67,7 @@
"@signozhq/table": "0.3.7",
"@signozhq/toggle-group": "0.0.1",
"@signozhq/tooltip": "0.0.2",
"@signozhq/ui": "0.0.6",
"@signozhq/ui": "0.0.5",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
"@uiw/codemirror-theme-copilot": "4.23.11",
@@ -139,12 +139,10 @@
"react-helmet-async": "1.3.0",
"react-hook-form": "7.71.2",
"react-i18next": "^11.16.1",
"react-json-tree": "^0.20.0",
"react-lottie": "1.2.10",
"react-markdown": "8.0.7",
"react-query": "3.39.3",
"react-redux": "^7.2.2",
"react-rnd": "^10.5.3",
"react-router-dom": "^5.2.0",
"react-router-dom-v5-compat": "6.27.0",
"react-syntax-highlighter": "15.5.0",

View File

@@ -3196,249 +3196,6 @@ export interface TelemetrytypesTelemetryFieldValuesDTO {
stringValues?: string[];
}
export type TracedetailtypesEventDTOAttributeMap = { [key: string]: unknown };
export interface TracedetailtypesEventDTO {
/**
* @type object
*/
attributeMap?: TracedetailtypesEventDTOAttributeMap;
/**
* @type boolean
*/
isError?: boolean;
/**
* @type string
*/
name?: string;
/**
* @type integer
* @minimum 0
*/
timeUnixNano?: number;
}
export interface TracedetailtypesWaterfallRequestDTO {
/**
* @type integer
* @minimum 0
*/
limit?: number;
/**
* @type string
*/
selectedSpanId?: string;
/**
* @type array
* @nullable true
*/
uncollapsedSpans?: string[] | null;
}
/**
* @nullable
*/
export type TracedetailtypesWaterfallResponseDTOServiceNameToTotalDurationMap = {
[key: string]: number;
} | null;
export interface TracedetailtypesWaterfallResponseDTO {
/**
* @type integer
* @minimum 0
*/
endTimestampMillis?: number;
/**
* @type boolean
*/
hasMissingSpans?: boolean;
/**
* @type boolean
*/
hasMore?: boolean;
/**
* @type string
*/
rootServiceEntryPoint?: string;
/**
* @type string
*/
rootServiceName?: string;
/**
* @type object
* @nullable true
*/
serviceNameToTotalDurationMap?: TracedetailtypesWaterfallResponseDTOServiceNameToTotalDurationMap;
/**
* @type array
* @nullable true
*/
spans?: TracedetailtypesWaterfallSpanDTO[] | null;
/**
* @type integer
* @minimum 0
*/
startTimestampMillis?: number;
/**
* @type integer
* @minimum 0
*/
totalErrorSpansCount?: number;
/**
* @type integer
* @minimum 0
*/
totalSpansCount?: number;
/**
* @type array
* @nullable true
*/
uncollapsedSpans?: string[] | null;
}
/**
* @nullable
*/
export type TracedetailtypesWaterfallSpanDTOAttributes = {
[key: string]: unknown;
} | null;
/**
* @nullable
*/
export type TracedetailtypesWaterfallSpanDTOResource = {
[key: string]: string;
} | null;
export interface TracedetailtypesWaterfallSpanDTO {
/**
* @type object
* @nullable true
*/
attributes?: TracedetailtypesWaterfallSpanDTOAttributes;
/**
* @type string
*/
db_name?: string;
/**
* @type string
*/
db_operation?: string;
/**
* @type integer
* @minimum 0
*/
duration_nano?: number;
/**
* @type array
* @nullable true
*/
events?: TracedetailtypesEventDTO[] | null;
/**
* @type string
*/
external_http_method?: string;
/**
* @type string
*/
external_http_url?: string;
/**
* @type integer
* @minimum 0
*/
flags?: number;
/**
* @type boolean
*/
has_children?: boolean;
/**
* @type boolean
*/
has_error?: boolean;
/**
* @type string
*/
http_host?: string;
/**
* @type string
*/
http_method?: string;
/**
* @type string
*/
http_url?: string;
/**
* @type string
*/
is_remote?: string;
/**
* @type integer
* @format int32
*/
kind?: number;
/**
* @type string
*/
kind_string?: string;
/**
* @type integer
* @minimum 0
*/
level?: number;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
parent_span_id?: string;
/**
* @type object
* @nullable true
*/
resource?: TracedetailtypesWaterfallSpanDTOResource;
/**
* @type string
*/
response_status_code?: string;
/**
* @type string
*/
span_id?: string;
/**
* @type integer
* @format int32
*/
status_code?: number;
/**
* @type string
*/
status_code_string?: string;
/**
* @type string
*/
status_message?: string;
/**
* @type integer
* @minimum 0
*/
sub_tree_node_count?: number;
/**
* @type integer
* @minimum 0
*/
timestamp?: number;
/**
* @type string
*/
trace_id?: string;
/**
* @type string
*/
trace_state?: string;
}
export interface TypesChangePasswordRequestDTO {
/**
* @type string
@@ -5100,17 +4857,6 @@ export type GetHosts200 = {
status: string;
};
export type GetWaterfallPathParameters = {
traceID: string;
};
export type GetWaterfall200 = {
data: TracedetailtypesWaterfallResponseDTO;
/**
* @type string
*/
status: string;
};
export type QueryRangeV5200 = {
data: Querybuildertypesv5QueryRangeResponseDTO;
/**

View File

@@ -1,121 +0,0 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import type {
MutationFunction,
UseMutationOptions,
UseMutationResult,
} from 'react-query';
import { useMutation } from 'react-query';
import type { BodyType, ErrorType } from '../../../generatedAPIInstance';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type {
GetWaterfall200,
GetWaterfallPathParameters,
RenderErrorResponseDTO,
TracedetailtypesWaterfallRequestDTO,
} from '../sigNoz.schemas';
/**
* Returns the waterfall view of spans for a given trace ID with tree structure, metadata, and windowed pagination
* @summary Get waterfall view for a trace
*/
export const getWaterfall = (
{ traceID }: GetWaterfallPathParameters,
tracedetailtypesWaterfallRequestDTO: BodyType<TracedetailtypesWaterfallRequestDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetWaterfall200>({
url: `/api/v3/traces/${traceID}/waterfall`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: tracedetailtypesWaterfallRequestDTO,
signal,
});
};
export const getGetWaterfallMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof getWaterfall>>,
TError,
{
pathParams: GetWaterfallPathParameters;
data: BodyType<TracedetailtypesWaterfallRequestDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof getWaterfall>>,
TError,
{
pathParams: GetWaterfallPathParameters;
data: BodyType<TracedetailtypesWaterfallRequestDTO>;
},
TContext
> => {
const mutationKey = ['getWaterfall'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof getWaterfall>>,
{
pathParams: GetWaterfallPathParameters;
data: BodyType<TracedetailtypesWaterfallRequestDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return getWaterfall(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type GetWaterfallMutationResult = NonNullable<
Awaited<ReturnType<typeof getWaterfall>>
>;
export type GetWaterfallMutationBody = BodyType<TracedetailtypesWaterfallRequestDTO>;
export type GetWaterfallMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get waterfall view for a trace
*/
export const useGetWaterfall = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof getWaterfall>>,
TError,
{
pathParams: GetWaterfallPathParameters;
data: BodyType<TracedetailtypesWaterfallRequestDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof getWaterfall>>,
TError,
{
pathParams: GetWaterfallPathParameters;
data: BodyType<TracedetailtypesWaterfallRequestDTO>;
},
TContext
> => {
const mutationOptions = getGetWaterfallMutationOptions(options);
return useMutation(mutationOptions);
};

View File

@@ -1,40 +0,0 @@
.details-header {
// ghost + secondary missing hover bg token in @signozhq/button
--button-ghost-hover-background: var(--l3-background);
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
border-bottom: 1px solid var(--l1-border);
height: 36px;
background: var(--l2-background);
&__icon-btn {
flex-shrink: 0;
}
&__title {
font-size: 13px;
font-weight: 500;
color: var(--l1-foreground);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
&__nav {
display: flex;
align-items: center;
gap: 2px;
}
}

View File

@@ -1,59 +0,0 @@
import { ReactNode } from 'react';
import { Button } from '@signozhq/button';
import { X } from '@signozhq/icons';
import './DetailsHeader.styles.scss';
export interface HeaderAction {
key: string;
component: ReactNode; // check later if we can use direct btn itself or not.
}
export interface DetailsHeaderProps {
title: string;
onClose: () => void;
actions?: HeaderAction[];
closePosition?: 'left' | 'right';
className?: string;
}
function DetailsHeader({
title,
onClose,
actions,
closePosition = 'right',
className,
}: DetailsHeaderProps): JSX.Element {
const closeButton = (
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={onClose}
aria-label="Close"
className="details-header__icon-btn"
>
<X size={14} />
</Button>
);
return (
<div className={`details-header ${className || ''}`}>
{closePosition === 'left' && closeButton}
<span className="details-header__title">{title}</span>
{actions && (
<div className="details-header__actions">
{actions.map((action) => (
<div key={action.key}>{action.component}</div>
))}
</div>
)}
{closePosition === 'right' && closeButton}
</div>
);
}
export default DetailsHeader;

View File

@@ -1,7 +0,0 @@
.details-panel-drawer {
&__body {
display: flex;
flex-direction: column;
height: 100%;
}
}

View File

@@ -1,36 +0,0 @@
import { DrawerWrapper } from '@signozhq/drawer';
import './DetailsPanelDrawer.styles.scss';
interface DetailsPanelDrawerProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
className?: string;
}
function DetailsPanelDrawer({
isOpen,
onClose,
children,
className,
}: DetailsPanelDrawerProps): JSX.Element {
return (
<DrawerWrapper
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
direction="right"
type="panel"
showOverlay={false}
allowOutsideClick
className={`details-panel-drawer ${className || ''}`}
content={<div className="details-panel-drawer__body">{children}</div>}
/>
);
}
export default DetailsPanelDrawer;

View File

@@ -1,8 +0,0 @@
export type {
DetailsHeaderProps,
HeaderAction,
} from './DetailsHeader/DetailsHeader';
export { default as DetailsHeader } from './DetailsHeader/DetailsHeader';
export { default as DetailsPanelDrawer } from './DetailsPanelDrawer';
export type { DetailsPanelState, UseDetailsPanelOptions } from './types';
export { default as useDetailsPanel } from './useDetailsPanel';

View File

@@ -1,10 +0,0 @@
export interface DetailsPanelState {
isOpen: boolean;
open: () => void;
close: () => void;
}
export interface UseDetailsPanelOptions {
entityId: string | undefined;
onClose?: () => void;
}

View File

@@ -1,29 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { DetailsPanelState, UseDetailsPanelOptions } from './types';
function useDetailsPanel({
entityId,
onClose,
}: UseDetailsPanelOptions): DetailsPanelState {
const [isOpen, setIsOpen] = useState<boolean>(false);
const prevEntityIdRef = useRef<string>('');
useEffect(() => {
const currentId = entityId || '';
if (currentId && currentId !== prevEntityIdRef.current) {
setIsOpen(true);
}
prevEntityIdRef.current = currentId;
}, [entityId]);
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => {
setIsOpen(false);
onClose?.();
}, [onClose]);
return { isOpen, open, close };
}
export default useDetailsPanel;

View File

@@ -15,6 +15,7 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import ContextView from 'container/LogDetailedView/ContextView/ContextView';
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
import JSONView from 'container/LogDetailedView/JsonView';
import Overview from 'container/LogDetailedView/Overview';
import {
aggregateAttributesResourcesToString,
@@ -44,7 +45,6 @@ import {
TextSelect,
X,
} from 'lucide-react';
import { JsonView } from 'periscope/components/JsonView';
import { AppState } from 'store/reducers';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
@@ -527,9 +527,7 @@ function LogDetailInner({
handleChangeSelectedView={handleChangeSelectedView}
/>
)}
{selectedView === VIEW_TYPES.JSON && (
<JsonView data={LogJsonData} height="68vh" />
)}
{selectedView === VIEW_TYPES.JSON && <JSONView logData={log} />}
{selectedView === VIEW_TYPES.CONTEXT && (
<ContextView

View File

@@ -1,4 +0,0 @@
.timeline-v3-container {
// flex: 1;
overflow: visible;
}

View File

@@ -1,87 +0,0 @@
import { useEffect, useState } from 'react';
import { useMeasure } from 'react-use';
import { useIsDarkMode } from 'hooks/useDarkMode';
import {
getIntervals,
getMinimumIntervalsBasedOnWidth,
Interval,
} from './utils';
import './TimelineV3.styles.scss';
interface ITimelineV3Props {
startTimestamp: number;
endTimestamp: number;
timelineHeight: number;
offsetTimestamp: number;
}
function TimelineV3(props: ITimelineV3Props): JSX.Element {
const {
startTimestamp,
endTimestamp,
timelineHeight,
offsetTimestamp,
} = props;
const [intervals, setIntervals] = useState<Interval[]>([]);
const [ref, { width }] = useMeasure<HTMLDivElement>();
const isDarkMode = useIsDarkMode();
useEffect(() => {
const spread = endTimestamp - startTimestamp;
if (spread < 0) {
return;
}
const minIntervals = getMinimumIntervalsBasedOnWidth(width);
const intervalisedSpread = (spread / minIntervals) * 1.0;
const intervals = getIntervals(intervalisedSpread, spread, offsetTimestamp);
setIntervals(intervals);
}, [startTimestamp, endTimestamp, width, offsetTimestamp]);
if (endTimestamp < startTimestamp) {
console.error(
'endTimestamp cannot be less than startTimestamp',
startTimestamp,
endTimestamp,
);
return <div />;
}
const strokeColor = isDarkMode ? ' rgb(192,193,195,0.8)' : 'black';
return (
<div ref={ref as never} className="timeline-v3-container">
<svg
width={width}
height={timelineHeight * 2.5}
xmlns="http://www.w3.org/2000/svg"
overflow="visible"
>
{intervals &&
intervals.length > 0 &&
intervals.map((interval, index) => (
<g
transform={`translate(${(interval.percentage * width) / 100},0)`}
key={`${interval.percentage + interval.label + index}`}
textAnchor="middle"
fontSize="0.6rem"
>
<text
x={index === intervals.length - 1 ? -10 : 0}
y={timelineHeight * 2}
fill={strokeColor}
>
{interval.label}
</text>
<line y1={0} y2={timelineHeight} stroke={strokeColor} strokeWidth="1" />
</g>
))}
</svg>
</div>
);
}
export default TimelineV3;

View File

@@ -1,93 +0,0 @@
import {
IIntervalUnit,
Interval,
INTERVAL_UNITS,
resolveTimeFromInterval,
} from 'components/TimelineV2/utils';
import { toFixed } from 'utils/toFixed';
export type { Interval };
/** Fewer intervals than TimelineV2 for a cleaner flamegraph ruler. */
export function getMinimumIntervalsBasedOnWidth(width: number): number {
if (width < 640) {
return 3;
}
if (width < 768) {
return 4;
}
if (width < 1024) {
return 5;
}
return 6;
}
/**
* Computes timeline intervals with offset-aware labels.
* Labels reflect absolute time from trace start (offsetTimestamp + elapsed),
* so when zoomed into a window, the first tick shows e.g. "50ms" not "0ms".
*/
export function getIntervals(
intervalSpread: number,
baseSpread: number,
offsetTimestamp: number,
): Interval[] {
const integerPartString = intervalSpread.toString().split('.')[0];
const integerPartLength = integerPartString.length;
const intervalSpreadNormalized =
intervalSpread < 1.0
? intervalSpread
: Math.floor(Number(integerPartString) / 10 ** (integerPartLength - 1)) *
10 ** (integerPartLength - 1);
// Unit must suit both: (1) tick granularity (intervalSpread) and (2) label magnitude
// (offsetTimestamp). When zoomed deep into a trace, labels show offsetTimestamp + elapsed,
// so we must pick a unit where that value is readable (e.g. "500.00s" not "500000.00ms").
const valueForUnitSelection = Math.max(offsetTimestamp, intervalSpread);
let intervalUnit: IIntervalUnit = INTERVAL_UNITS[0];
for (let idx = INTERVAL_UNITS.length - 1; idx >= 0; idx -= 1) {
const standardInterval = INTERVAL_UNITS[idx];
if (valueForUnitSelection * standardInterval.multiplier >= 1) {
intervalUnit = INTERVAL_UNITS[idx];
break;
}
}
const intervals: Interval[] = [
{
label: `${toFixed(
resolveTimeFromInterval(offsetTimestamp, intervalUnit),
2,
)}${intervalUnit.name}`,
percentage: 0,
},
];
let tempBaseSpread = baseSpread;
let elapsedIntervals = 0;
while (tempBaseSpread && intervals.length < 20) {
let intervalTime: number;
if (tempBaseSpread <= 1.5 * intervalSpreadNormalized) {
intervalTime = elapsedIntervals + tempBaseSpread;
tempBaseSpread = 0;
} else {
intervalTime = elapsedIntervals + intervalSpreadNormalized;
tempBaseSpread -= intervalSpreadNormalized;
}
elapsedIntervals = intervalTime;
const labelTime = offsetTimestamp + intervalTime;
intervals.push({
label: `${toFixed(resolveTimeFromInterval(labelTime, intervalUnit), 2)}${
intervalUnit.name
}`,
percentage: (intervalTime / baseSpread) * 100,
});
}
return intervals;
}

View File

@@ -33,125 +33,6 @@ const themeColors = {
purple: '#800080',
cyan: '#00FFFF',
},
traceDetailColorsV3: {
// Blues
dodgerBlue: '#2F80ED',
royalBlue: '#3366E6',
steelBlue: '#4682B4',
// Teals / Cyans
turquoise: '#00CEC9',
lagoon: '#1ABC9C',
cyanBright: '#22A6F2',
// Greens
emeraldGreen: '#27AE60',
mediumSeaGreen: '#3CB371',
limeGreen: '#A3E635',
// Yellows / Golds
festivalYellow: '#F2C94C',
sunflower: '#FFD93D',
warmAmber: '#FFCA28',
// Purples / Violets
mediumPurple: '#BB6BD9',
royalPurple: '#9B51E0',
orchid: '#DA77F2',
// Accent
neonViolet: '#C77DFF',
electricPurple: '#6C5CE7',
arcticBlue: '#48DBFB',
// Blues extended
blue1: '#1F63E0',
blue2: '#3A7AED',
blue3: '#5A9DF5',
blue4: '#2874A6',
blue5: '#2E86C1',
blue6: '#3498DB',
// Cyans
cyan1: '#00B0AA',
cyan2: '#33D6C2',
cyan3: '#66E9DA',
// Greens extended
green1: '#1E8449',
green2: '#2ECC71',
green3: '#58D68D',
green4: '#229954',
green5: '#27AE60',
green6: '#52BE80',
// Forest
forest1: '#27AE60',
forest2: '#2ECC71',
forest3: '#58D68D',
// Lime
lime1: '#A3E635',
lime2: '#B9F18D',
lime3: '#D4FFB0',
// Teals
teal1: '#009688',
teal2: '#1ABC9C',
teal3: '#48C9B0',
teal4: '#1ABC9C',
teal5: '#48C9B0',
teal6: '#76D7C4',
// Yellows
yellow1: '#F1C40F',
yellow2: '#F7DC6F',
yellow3: '#F9E79F',
// Gold
gold1: '#F39C12',
gold2: '#F1C40F',
gold3: '#F7DC6F',
gold4: '#B7950B',
gold5: '#F1C40F',
gold6: '#F4D03F',
// Mustard
mustard1: '#F1C40F',
mustard2: '#F7DC6F',
mustard3: '#F9E79F',
// Aqua
aqua1: '#00BFFF',
aqua2: '#1E90FF',
aqua3: '#63B8FF',
// Purple extended
purple1: '#8E44AD',
purple2: '#9B59B6',
purple3: '#BB8FCE',
violet1: '#8E44AD',
violet2: '#9B59B6',
violet3: '#BB8FCE',
violet4: '#7D3C98',
violet5: '#8E44AD',
violet6: '#9B59B6',
// Lavender
lavender1: '#9B59B6',
lavender2: '#AF7AC5',
lavender3: '#C39BD3',
// Oranges (safe ones, not red-ish)
orange4: '#D35400',
orange5: '#E67E22',
orange6: '#EB984E',
coral1: '#E67E22',
coral2: '#F39C12',
coral3: '#F5B041',
},
chartcolors: {
// Blues (3)
dodgerBlue: '#2F80ED',

View File

@@ -123,7 +123,6 @@
&__row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: flex-end;
max-width: 825px;
gap: 25px;

View File

@@ -11,12 +11,6 @@
}
}
.infra-metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 24px;
}
.infra-metrics-card {
margin: 1rem 0;
height: 300px;

View File

@@ -1,6 +1,6 @@
import { useMemo, useRef } from 'react';
import { useQueries, UseQueryResult } from 'react-query';
import { Card, Skeleton, Typography } from 'antd';
import { Card, Col, Row, Skeleton, Typography } from 'antd';
import cx from 'classnames';
import Uplot from 'components/Uplot';
import { ENTITY_VERSION_V4 } from 'constants/app';
@@ -163,16 +163,16 @@ function NodeMetrics({
);
};
return (
<div className="infra-metrics-grid">
<Row gutter={24}>
{queries.map((query, idx) => (
<div key={widgetInfo[idx].title}>
<Col span={12} key={widgetInfo[idx].title}>
<Typography.Text>{widgetInfo[idx].title}</Typography.Text>
<Card bordered className="infra-metrics-card" ref={graphRef}>
{renderCardContent(query, idx)}
</Card>
</div>
</Col>
))}
</div>
</Row>
);
}

View File

@@ -1,6 +1,6 @@
import { useMemo, useRef } from 'react';
import { useQueries, UseQueryResult } from 'react-query';
import { Card, Skeleton, Typography } from 'antd';
import { Card, Col, Row, Skeleton, Typography } from 'antd';
import cx from 'classnames';
import Uplot from 'components/Uplot';
import { ENTITY_VERSION_V4 } from 'constants/app';
@@ -146,16 +146,16 @@ function PodMetrics({
};
return (
<div className="infra-metrics-grid">
<Row gutter={24}>
{queries.map((query, idx) => (
<div key={podWidgetInfo[idx].title}>
<Col span={12} key={podWidgetInfo[idx].title}>
<Typography.Text>{podWidgetInfo[idx].title}</Typography.Text>
<Card bordered className="infra-metrics-card" ref={graphRef}>
{renderCardContent(query, idx)}
</Card>
</div>
</Col>
))}
</div>
</Row>
);
}

View File

@@ -16,7 +16,7 @@ const useGetTraceFlamegraph = (
queryKey: [
REACT_QUERY_KEY.GET_TRACE_V2_FLAMEGRAPH,
props.traceId,
// props.selectedSpanId,
props.selectedSpanId,
],
enabled: !!props.traceId,
keepPreviousData: true,

View File

@@ -7,23 +7,6 @@ export function hashFn(str: string): number {
return hash >>> 0;
}
export function colorToRgb(color: string): string {
// Handle hex colors
const hexMatch = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color);
if (hexMatch) {
return `${parseInt(hexMatch[1], 16)}, ${parseInt(
hexMatch[2],
16,
)}, ${parseInt(hexMatch[3], 16)}`;
}
// Handle rgb() colors
const rgbMatch = /^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/.exec(color);
if (rgbMatch) {
return `${rgbMatch[1]}, ${rgbMatch[2]}, ${rgbMatch[3]}`;
}
return '136, 136, 136';
}
export function generateColor(
key: string,
colorMap: Record<string, string>,

View File

@@ -569,12 +569,8 @@ describe('TooltipPlugin', () => {
}),
);
const resizeCall = addSpy.mock.calls.find(
([type]) => type === ('resize' as keyof WindowEventMap),
);
const scrollCall = addSpy.mock.calls.find(
([type]) => type === ('scroll' as keyof WindowEventMap),
);
const resizeCall = addSpy.mock.calls.find(([type]) => type === 'resize');
const scrollCall = addSpy.mock.calls.find(([type]) => type === 'scroll');
expect(resizeCall).toBeDefined();
expect(scrollCall).toBeDefined();

View File

@@ -4,11 +4,19 @@ import ROUTES from 'constants/routes';
import history from 'lib/history';
import { Compass, Cone, TowerControl } from 'lucide-react';
import TraceDetailsV3 from '../TraceDetailsV3';
import TraceDetailsV2 from './TraceDetailV2';
import './TraceDetailV2.styles.scss';
function NewTraceDetail(props: any): JSX.Element {
interface INewTraceDetailProps {
items: {
label: JSX.Element;
key: string;
children: JSX.Element;
}[];
}
function NewTraceDetail(props: INewTraceDetailProps): JSX.Element {
const { items } = props;
return (
<div className="traces-module-container">
@@ -42,7 +50,7 @@ export default function TraceDetailsPage(): JSX.Element {
</div>
),
key: 'trace-details',
children: <TraceDetailsV3 />,
children: <TraceDetailsV2 />,
},
{
label: (

View File

@@ -1,42 +0,0 @@
import { DetailsHeader } from 'components/DetailsPanel';
import { FloatingPanel } from 'periscope/components/FloatingPanel';
interface AnalyticsPanelProps {
isOpen: boolean;
onClose: () => void;
}
const PANEL_WIDTH = 600;
const PANEL_MARGIN_RIGHT = 20;
const PANEL_MARGIN_TOP = 25;
const PANEL_MARGIN_BOTTOM = 25;
function AnalyticsPanel({
isOpen,
onClose,
}: AnalyticsPanelProps): JSX.Element | null {
if (!isOpen) {
return null;
}
return (
<FloatingPanel
isOpen
className="span-details-panel"
width={PANEL_WIDTH}
height={window.innerHeight - PANEL_MARGIN_TOP - PANEL_MARGIN_BOTTOM}
defaultPosition={{
x: window.innerWidth - PANEL_WIDTH - PANEL_MARGIN_RIGHT,
y: PANEL_MARGIN_TOP,
}}
>
<DetailsHeader
title="Analytics"
onClose={onClose}
className="floating-panel__drag-handle"
/>
</FloatingPanel>
);
}
export default AnalyticsPanel;

View File

@@ -1,34 +0,0 @@
.linked-spans {
position: relative;
&__toggle {
display: inline-flex;
align-items: center;
gap: 4px;
background: none;
border: none;
padding: 0;
cursor: pointer;
color: inherit;
font: inherit;
}
&__label {
white-space: nowrap;
}
&__chevron {
transition: transform 0.15s ease;
&--open {
transform: rotate(90deg);
}
}
&__list {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px 0;
}
}

View File

@@ -1,118 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Badge } from '@signozhq/badge';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import ROUTES from 'constants/routes';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import './LinkedSpans.styles.scss';
interface SpanReference {
traceId: string;
spanId: string;
refType: string;
}
interface LinkedSpansProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
references: any;
}
interface LinkedSpansState {
linkedSpans: SpanReference[];
count: number;
isOpen: boolean;
toggleOpen: () => void;
}
export function useLinkedSpans(references: any): LinkedSpansState {
const [isOpen, setIsOpen] = useState(false);
const linkedSpans: SpanReference[] = useMemo(
() =>
(references || []).filter(
(ref: SpanReference) => ref.refType !== 'CHILD_OF',
),
[references],
);
const toggleOpen = useCallback(() => setIsOpen((prev) => !prev), []);
return {
linkedSpans,
count: linkedSpans.length,
isOpen,
toggleOpen,
};
}
export function LinkedSpansToggle({
count,
isOpen,
toggleOpen,
}: {
count: number;
isOpen: boolean;
toggleOpen: () => void;
}): JSX.Element {
if (count === 0) {
return <span className="linked-spans__label">0 linked spans</span>;
}
return (
<button type="button" className="linked-spans__toggle" onClick={toggleOpen}>
<span className="linked-spans__label">
{count} linked span{count !== 1 ? 's' : ''}
</span>
{isOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
);
}
export function LinkedSpansPanel({
linkedSpans,
isOpen,
}: {
linkedSpans: SpanReference[];
isOpen: boolean;
}): JSX.Element | null {
const getLink = useCallback(
(item: SpanReference): string =>
`${ROUTES.TRACE}/${item.traceId}?spanId=${item.spanId}`,
[],
);
if (!isOpen || linkedSpans.length === 0) {
return null;
}
return (
<div className="linked-spans__list">
{linkedSpans.map((item) => (
<KeyValueLabel
key={item.spanId}
badgeKey="Linked Span ID"
badgeValue={
<Link to={getLink(item)}>
<Badge color="vanilla">{item.spanId}</Badge>
</Link>
}
direction="column"
/>
))}
</div>
);
}
function LinkedSpans({ references }: LinkedSpansProps): JSX.Element {
const { linkedSpans, count, isOpen, toggleOpen } = useLinkedSpans(references);
return (
<div className="linked-spans">
<LinkedSpansToggle count={count} isOpen={isOpen} toggleOpen={toggleOpen} />
<LinkedSpansPanel linkedSpans={linkedSpans} isOpen={isOpen} />
</div>
);
}
export default LinkedSpans;

View File

@@ -1,107 +0,0 @@
.span-details-panel {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
&__header-nav {
display: flex;
align-items: center;
gap: 2px;
}
&__body {
padding: 12px;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: none;
background: var(--l1-background);
font-size: 14px;
[role='tabpanel'] {
padding: 0;
}
.data-viewer {
min-height: 500px;
}
}
&__span-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
justify-content: space-between;
}
&__span-info {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
padding: 8px 0;
}
&__span-info-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--l2-foreground);
white-space: nowrap;
}
&__highlighted-options {
padding: 8px 0;
display: flex;
flex-wrap: wrap;
gap: 0;
.key-value-label {
flex: 1 1 50%;
min-width: 120px;
overflow: hidden;
}
}
&__service-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--bg-forest-500);
}
&__trace-id {
color: var(--accent-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
&__key-attributes {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0;
&-label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--l2-foreground);
text-transform: uppercase;
letter-spacing: 0.48px;
line-height: var(--line-height-20);
}
&-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
}
}

View File

@@ -1,535 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { Button } from '@signozhq/button';
import {
CalendarClock,
ChartBar,
Dock,
Ellipsis,
Link2,
PanelBottom,
Timer,
} from '@signozhq/icons';
import { Tabs } from '@signozhq/ui';
import { Tooltip } from 'antd';
import { DetailsHeader, DetailsPanelDrawer } from 'components/DetailsPanel';
import { HeaderAction } from 'components/DetailsPanel/DetailsHeader/DetailsHeader';
import { DetailsPanelState } from 'components/DetailsPanel/types';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { QueryParams } from 'constants/query';
import {
initialQueryBuilderFormValuesMap,
initialQueryState,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
import Events from 'container/SpanDetailsDrawer/Events/Events';
import SpanLogs from 'container/SpanDetailsDrawer/SpanLogs/SpanLogs';
import { useSpanContextLogs } from 'container/SpanDetailsDrawer/SpanLogs/useSpanContextLogs';
import dayjs from 'dayjs';
import { noop } from 'lodash-es';
import { getSpanAttribute, hasInfraMetadata } from 'pages/TraceDetailsV3/utils';
import { DataViewer } from 'periscope/components/DataViewer';
import { FloatingPanel } from 'periscope/components/FloatingPanel';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Span } from 'types/api/trace/getTraceV2';
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
import AnalyticsPanel from './AnalyticsPanel/AnalyticsPanel';
import { HIGHLIGHTED_OPTIONS } from './config';
import { KEY_ATTRIBUTE_KEYS, SpanDetailVariant } from './constants';
import {
LinkedSpansPanel,
LinkedSpansToggle,
useLinkedSpans,
} from './LinkedSpans/LinkedSpans';
import SpanPercentileBadge from './SpanPercentile/SpanPercentileBadge';
import SpanPercentilePanel from './SpanPercentile/SpanPercentilePanel';
import useSpanPercentile from './SpanPercentile/useSpanPercentile';
import './SpanDetailsPanel.styles.scss';
interface SpanDetailsPanelProps {
panelState: DetailsPanelState;
selectedSpan: Span | undefined;
variant?: SpanDetailVariant;
onVariantChange?: (variant: SpanDetailVariant) => void;
traceStartTime?: number;
traceEndTime?: number;
}
function SpanDetailsContent({
selectedSpan,
traceStartTime,
traceEndTime,
}: {
selectedSpan: Span;
traceStartTime?: number;
traceEndTime?: number;
}): JSX.Element {
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
const percentile = useSpanPercentile(selectedSpan);
const linkedSpans = useLinkedSpans(selectedSpan.references);
const {
logs,
isLoading: isLogsLoading,
isError: isLogsError,
isFetching: isLogsFetching,
isLogSpanRelated,
hasTraceIdLogs,
} = useSpanContextLogs({
traceId: selectedSpan.traceId,
spanId: selectedSpan.spanId,
timeRange: {
startTime: (traceStartTime || 0) - FIVE_MINUTES_IN_MS,
endTime: (traceEndTime || 0) + FIVE_MINUTES_IN_MS,
},
isDrawerOpen: true,
});
const infraMetadata = useMemo(() => {
if (!hasInfraMetadata(selectedSpan)) {
return null;
}
return {
clusterName: getSpanAttribute(selectedSpan, 'k8s.cluster.name') || '',
podName: getSpanAttribute(selectedSpan, 'k8s.pod.name') || '',
nodeName: getSpanAttribute(selectedSpan, 'k8s.node.name') || '',
hostName: getSpanAttribute(selectedSpan, 'host.name') || '',
spanTimestamp: dayjs(selectedSpan.timestamp).format(),
};
}, [selectedSpan]);
const handleExplorerPageRedirect = useCallback((): void => {
const startTimeMs = (traceStartTime || 0) - FIVE_MINUTES_IN_MS;
const endTimeMs = (traceEndTime || 0) + FIVE_MINUTES_IN_MS;
const traceIdFilter = {
op: 'AND',
items: [
{
id: 'trace-id-filter',
key: {
key: 'trace_id',
id: 'trace-id-key',
dataType: 'string' as const,
isColumn: true,
type: '',
isJSON: false,
} as BaseAutocompleteData,
op: '=',
value: selectedSpan.traceId,
},
],
};
const compositeQuery = {
...initialQueryState,
queryType: 'builder',
builder: {
...initialQueryState.builder,
queryData: [
{
...initialQueryBuilderFormValuesMap.logs,
aggregateOperator: LogsAggregatorOperator.NOOP,
filters: traceIdFilter,
},
],
},
};
const searchParams = new URLSearchParams();
searchParams.set(QueryParams.compositeQuery, JSON.stringify(compositeQuery));
searchParams.set(QueryParams.startTime, startTimeMs.toString());
searchParams.set(QueryParams.endTime, endTimeMs.toString());
window.open(
`${window.location.origin}${
ROUTES.LOGS_EXPLORER
}?${searchParams.toString()}`,
'_blank',
'noopener,noreferrer',
);
}, [selectedSpan.traceId, traceStartTime, traceEndTime]);
const emptyLogsStateConfig = useMemo(
() => ({
...getEmptyLogsListConfig(() => {}),
showClearFiltersButton: false,
}),
[],
);
const keyAttributes = useMemo(() => {
const keys = KEY_ATTRIBUTE_KEYS.traces || [];
const allAttrs: Record<string, string> = {
...(selectedSpan.attributes || selectedSpan.attributes_string),
...(selectedSpan.resources || selectedSpan.resources_string),
...(selectedSpan.http_method && { http_method: selectedSpan.http_method }),
...(selectedSpan.http_url && { http_url: selectedSpan.http_url }),
...(selectedSpan.http_host && { http_host: selectedSpan.http_host }),
...(selectedSpan.db_name && { db_name: selectedSpan.db_name }),
...(selectedSpan.db_operation && {
db_operation: selectedSpan.db_operation,
}),
...(selectedSpan.external_http_method && {
external_http_method: selectedSpan.external_http_method,
}),
...(selectedSpan.external_http_url && {
external_http_url: selectedSpan.external_http_url,
}),
...(selectedSpan.response_status_code && {
response_status_code: selectedSpan.response_status_code,
}),
datetime: dayjs(selectedSpan.timestamp).format('MMM D, YYYY — HH:mm:ss'),
duration: getYAxisFormattedValue(
`${selectedSpan.durationNano / 1000000}`,
'ms',
),
'span.kind': selectedSpan.spanKind,
status_code_string: selectedSpan.statusCodeString,
};
return keys
.filter((key) => allAttrs[key])
.map((key) => ({ key, value: String(allAttrs[key]) }));
}, [selectedSpan]);
return (
<div className="span-details-panel__body">
<div className="span-details-panel__span-row">
<KeyValueLabel
badgeKey="Span name"
badgeValue={selectedSpan.name}
maxCharacters={50}
/>
<SpanPercentileBadge
loading={percentile.loading}
percentileValue={percentile.percentileValue}
duration={percentile.duration}
spanPercentileData={percentile.spanPercentileData}
isOpen={percentile.isOpen}
toggleOpen={percentile.toggleOpen}
/>
</div>
<SpanPercentilePanel selectedSpan={selectedSpan} percentile={percentile} />
{/* Span info: exec time + start time */}
<div className="span-details-panel__span-info">
<div className="span-details-panel__span-info-item">
<Timer size={14} />
<span>
{getYAxisFormattedValue(`${selectedSpan.durationNano / 1000000}`, 'ms')}
{traceStartTime && traceEndTime && traceEndTime > traceStartTime && (
<>
{' — '}
<strong>
{(
(selectedSpan.durationNano * 100) /
((traceEndTime - traceStartTime) * 1e6)
).toFixed(2)}
%
</strong>
{' of total exec time'}
</>
)}
</span>
</div>
<div className="span-details-panel__span-info-item">
<CalendarClock size={14} />
<span>
{dayjs(selectedSpan.timestamp).format('HH:mm:ss — MMM D, YYYY')}
</span>
</div>
<div className="span-details-panel__span-info-item">
<Link2 size={14} />
<LinkedSpansToggle
count={linkedSpans.count}
isOpen={linkedSpans.isOpen}
toggleOpen={linkedSpans.toggleOpen}
/>
</div>
</div>
<LinkedSpansPanel
linkedSpans={linkedSpans.linkedSpans}
isOpen={linkedSpans.isOpen}
/>
{/* Step 6: HighlightedOptions */}
<div className="span-details-panel__highlighted-options">
{HIGHLIGHTED_OPTIONS.map((option) => {
const rendered = option.render(selectedSpan);
if (!rendered) {
return null;
}
return (
<KeyValueLabel
key={option.key}
badgeKey={option.label}
badgeValue={rendered}
direction="column"
/>
);
})}
</div>
{/* Step 7: KeyAttributes */}
{keyAttributes.length > 0 && (
<div className="span-details-panel__key-attributes">
<div className="span-details-panel__key-attributes-label">
KEY ATTRIBUTES
</div>
<div className="span-details-panel__key-attributes-chips">
{keyAttributes.map(({ key, value }) => (
<KeyValueLabel key={key} badgeKey={key} badgeValue={value} />
))}
</div>
</div>
)}
{/* Step 8: MiniTraceContext */}
{/* Step 9: ContentTabs */}
<Tabs
defaultValue="overview"
variant="secondary"
items={[
{
key: 'overview',
label: 'Overview',
children: (
<DataViewer
data={selectedSpan}
drawerKey="trace-details"
prettyViewProps={{ showPinned: true }}
/>
),
},
{
key: 'events',
label: `Events (${selectedSpan.event?.length || 0})`,
children: (
<Events
span={selectedSpan}
startTime={traceStartTime || 0}
isSearchVisible
/>
),
},
{
key: 'logs',
label: 'Logs',
children: (
<SpanLogs
traceId={selectedSpan.traceId}
spanId={selectedSpan.spanId}
timeRange={{
startTime: (traceStartTime || 0) - FIVE_MINUTES_IN_MS,
endTime: (traceEndTime || 0) + FIVE_MINUTES_IN_MS,
}}
logs={logs}
isLoading={isLogsLoading}
isError={isLogsError}
isFetching={isLogsFetching}
isLogSpanRelated={isLogSpanRelated}
handleExplorerPageRedirect={handleExplorerPageRedirect}
emptyStateConfig={!hasTraceIdLogs ? emptyLogsStateConfig : undefined}
/>
),
},
...(infraMetadata
? [
{
key: 'metrics',
label: 'Metrics',
children: (
<InfraMetrics
clusterName={infraMetadata.clusterName}
podName={infraMetadata.podName}
nodeName={infraMetadata.nodeName}
hostName={infraMetadata.hostName}
timestamp={infraMetadata.spanTimestamp}
dataSource={DataSource.TRACES}
/>
),
},
]
: []),
]}
/>
</div>
);
}
function SpanDetailsPanel({
panelState,
selectedSpan,
variant = SpanDetailVariant.DIALOG,
onVariantChange,
traceStartTime,
traceEndTime,
}: SpanDetailsPanelProps): JSX.Element {
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
const headerActions = useMemo((): HeaderAction[] => {
const actions: HeaderAction[] = [
{
key: 'overflow',
component: (
<Button variant="ghost" size="icon" color="secondary" onClick={noop}>
<Ellipsis size={14} />
</Button>
),
},
{
key: 'analytics',
component: (
<Button
variant="ghost"
size="sm"
color="secondary"
prefixIcon={<ChartBar size={14} />}
onClick={(): void => setIsAnalyticsOpen((prev) => !prev)}
>
Analytics
</Button>
),
},
// TODO: Add back when driven through separate config for different pages
// {
// key: 'view-full-trace',
// component: (
// <Button variant="ghost" size="sm" color="secondary" prefixIcon={<ExternalLink size={14} />} onClick={noop}>
// View full trace
// </Button>
// ),
// },
// TODO: Add back when used in trace explorer page
// {
// key: 'nav',
// component: (
// <div className="span-details-panel__header-nav">
// <Button variant="ghost" size="icon" color="secondary" onClick={noop}><ChevronUp size={14} /></Button>
// <Button variant="ghost" size="icon" color="secondary" onClick={noop}><ChevronDown size={14} /></Button>
// </div>
// ),
// },
];
if (onVariantChange) {
const isDocked = variant === SpanDetailVariant.DOCKED;
actions.push({
key: 'dock-toggle',
component: (
<Tooltip title={isDocked ? 'Open as floating panel' : 'Dock on the side'}>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={(): void =>
onVariantChange(
isDocked ? SpanDetailVariant.DIALOG : SpanDetailVariant.DOCKED,
)
}
>
{isDocked ? <Dock size={14} /> : <PanelBottom size={14} />}
</Button>
</Tooltip>
),
});
}
return actions;
}, [variant, onVariantChange]);
const PANEL_WIDTH = 500;
const PANEL_MARGIN_RIGHT = 20;
const PANEL_MARGIN_TOP = 25;
const PANEL_MARGIN_BOTTOM = 25;
const content = (
<>
<DetailsHeader
title="Span details"
onClose={panelState.close}
actions={headerActions}
className={
variant === SpanDetailVariant.DIALOG ? 'floating-panel__drag-handle' : ''
}
/>
{selectedSpan && (
<SpanDetailsContent
selectedSpan={selectedSpan}
traceStartTime={traceStartTime}
traceEndTime={traceEndTime}
/>
)}
</>
);
const analyticsPanel = (
<AnalyticsPanel
isOpen={isAnalyticsOpen}
onClose={(): void => setIsAnalyticsOpen(false)}
/>
);
if (variant === SpanDetailVariant.DOCKED) {
return (
<>
<div className="span-details-panel">{content}</div>
{analyticsPanel}
</>
);
}
if (variant === SpanDetailVariant.DRAWER) {
return (
<>
<DetailsPanelDrawer
isOpen={panelState.isOpen}
onClose={panelState.close}
className="span-details-panel"
>
{content}
</DetailsPanelDrawer>
{analyticsPanel}
</>
);
}
return (
<>
<FloatingPanel
isOpen={panelState.isOpen}
className="span-details-panel"
width={PANEL_WIDTH}
height={window.innerHeight - PANEL_MARGIN_TOP - PANEL_MARGIN_BOTTOM}
defaultPosition={{
x: window.innerWidth - PANEL_WIDTH - PANEL_MARGIN_RIGHT,
y: PANEL_MARGIN_TOP,
}}
enableResizing={{
top: true,
right: true,
bottom: true,
left: true,
topRight: false,
bottomRight: false,
bottomLeft: false,
topLeft: false,
}}
>
{content}
</FloatingPanel>
{analyticsPanel}
</>
);
}
export default SpanDetailsPanel;

View File

@@ -1,257 +0,0 @@
// Badge — wraps a KeyValueLabel, clickable to toggle panel
.span-percentile-badge {
cursor: pointer;
// Override key color for the percentile value (p99)
.key-value-label__key {
color: var(--text-sakura-400, #f56c87);
}
&__loader {
display: inline-flex;
align-items: center;
padding: 2px 4px;
}
&__value {
display: inline-flex;
align-items: center;
gap: 4px;
}
&__icon {
flex-shrink: 0;
color: var(--l2-foreground);
}
}
// Panel — collapsible, renders below the row
.span-percentile-panel {
display: flex;
flex-direction: column;
position: relative;
border: 1px solid var(--l1-border);
border-radius: 4px;
filter: drop-shadow(2px 4px 16px rgba(0, 0, 0, 0.2));
backdrop-filter: blur(20px);
margin: 8px 16px;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--l1-border);
&-text {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
&-icon {
color: var(--l2-foreground);
}
}
&__content {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
&-title {
font-size: var(--font-size-sm);
line-height: var(--line-height-20);
}
&-highlight {
color: var(--text-sakura-400, #f56c87);
}
&-loader {
display: inline-flex;
align-items: flex-end;
margin: 0 4px;
line-height: 18px;
}
}
&__timerange {
width: 100%;
&-select {
width: 100%;
margin-top: 8px;
margin-bottom: 16px;
.ant-select-selector {
border-radius: 50px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
color: var(--l1-foreground);
font-size: 12px;
height: 32px;
}
}
}
&__table {
&-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
&-text {
color: var(--l1-foreground);
font-size: 11px;
font-weight: 500;
line-height: 20px;
text-transform: uppercase;
}
}
&-rows {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
&-skeleton {
.ant-skeleton-title {
width: 100% !important;
margin-top: 0 !important;
}
.ant-skeleton-paragraph {
margin-top: 8px;
& > li + li {
margin-top: 10px;
width: 100% !important;
}
}
}
&-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 4px;
&-key {
flex: 0 0 auto;
color: var(--l1-foreground);
font-size: 12px;
font-weight: 500;
line-height: 20px;
}
&-value {
color: var(--l2-foreground);
font-size: 12px;
line-height: 20px;
}
&-dash {
flex: 1;
height: 0;
margin: 0 8px;
border-top: 1px solid transparent;
border-image: repeating-linear-gradient(
to right,
var(--l1-border) 0,
var(--l1-border) 10px,
transparent 10px,
transparent 20px
)
1 stretch;
}
&--current {
border-radius: 2px;
background: rgba(78, 116, 248, 0.2);
.span-percentile-panel__table-row-key {
color: var(--text-robin-300);
}
.span-percentile-panel__table-row-dash {
border-image: repeating-linear-gradient(
to right,
#abbdff 0,
#abbdff 10px,
transparent 10px,
transparent 20px
)
1 stretch;
}
.span-percentile-panel__table-row-value {
color: var(--text-robin-400);
}
}
}
}
&__resource-selector {
overflow: hidden;
width: calc(100% + 16px);
position: absolute;
top: 32px;
left: -8px;
z-index: 1000;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
&-header {
border-bottom: 1px solid var(--l1-border);
}
&-input {
border-radius: 0;
border: none !important;
box-shadow: none !important;
height: 36px;
}
&-items {
height: 200px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 0.3rem;
height: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--l3-background);
}
}
&-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
&-value {
color: var(--l1-foreground);
font-size: 13px;
line-height: 20px;
}
}
}
}

View File

@@ -1,506 +0,0 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery } from 'react-query';
import { Checkbox, Input, Select, Skeleton, Tooltip, Typography } from 'antd';
import getSpanPercentiles from 'api/trace/getSpanPercentiles';
import getUserPreference from 'api/v1/user/preferences/name/get';
import updateUserPreference from 'api/v1/user/preferences/name/update';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { USER_PREFERENCES } from 'constants/userPreferences';
import dayjs from 'dayjs';
import useClickOutside from 'hooks/useClickOutside';
import { Check, ChevronDown, ChevronUp, Loader2, PlusIcon } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { Span } from 'types/api/trace/getTraceV2';
import './SpanPercentile.styles.scss';
interface IResourceAttribute {
key: string;
value: string;
isSelected: boolean;
}
const DEFAULT_RESOURCE_ATTRIBUTES = {
serviceName: 'service.name',
name: 'name',
};
const timerangeOptions = [1, 2, 4, 6, 12, 24].map((hours) => ({
label: `${hours}h`,
value: hours,
}));
interface SpanPercentileProps {
selectedSpan: Span;
}
function SpanPercentile({ selectedSpan }: SpanPercentileProps): JSX.Element {
const [isOpen, setIsOpen] = useState(false);
const [selectedTimeRange, setSelectedTimeRange] = useState(1);
const [
resourceAttributesSearchQuery,
setResourceAttributesSearchQuery,
] = useState('');
const [spanPercentileData, setSpanPercentileData] = useState<{
percentile: number;
description: string;
percentiles: Record<string, number>;
} | null>(null);
const [
showResourceAttributesSelector,
setShowResourceAttributesSelector,
] = useState(false);
const [selectedResourceAttributes, setSelectedResourceAttributes] = useState<
Record<string, string>
>({});
const [spanResourceAttributes, updateSpanResourceAttributes] = useState<
IResourceAttribute[]
>([]);
const [initialWaitCompleted, setInitialWaitCompleted] = useState(false);
const [shouldFetchData, setShouldFetchData] = useState(false);
const [shouldUpdateUserPreference, setShouldUpdateUserPreference] = useState(
false,
);
const resourceAttributesSelectorRef = useRef<HTMLDivElement | null>(null);
useClickOutside({
ref: resourceAttributesSelectorRef,
onClickOutside: () => {
if (resourceAttributesSelectorRef.current) {
setShowResourceAttributesSelector(false);
}
},
eventType: 'mousedown',
});
const endTime = useMemo(
() => Math.floor(Number(selectedSpan.timestamp) / 1000) * 1000,
[selectedSpan.timestamp],
);
const startTime = useMemo(
() =>
dayjs(selectedSpan.timestamp)
.subtract(Number(selectedTimeRange), 'hour')
.unix() * 1000,
[selectedSpan.timestamp, selectedTimeRange],
);
const { mutate: updateUserPreferenceMutation } = useMutation(
updateUserPreference,
);
const {
data: userSelectedResourceAttributes,
isError: isErrorUserSelectedResourceAttributes,
} = useQuery({
queryFn: () =>
getUserPreference({
name: USER_PREFERENCES.SPAN_PERCENTILE_RESOURCE_ATTRIBUTES,
}),
queryKey: [
'getUserPreferenceByPreferenceName',
USER_PREFERENCES.SPAN_PERCENTILE_RESOURCE_ATTRIBUTES,
selectedSpan.spanId,
],
enabled: selectedSpan.tagMap !== undefined,
});
const {
isLoading: isLoadingData,
isFetching: isFetchingData,
data,
refetch: refetchData,
isError: isErrorData,
} = useQuery({
queryFn: () =>
getSpanPercentiles({
start: startTime || 0,
end: endTime || 0,
spanDuration: selectedSpan.durationNano || 0,
serviceName: selectedSpan.serviceName || '',
name: selectedSpan.name || '',
resourceAttributes: selectedResourceAttributes,
}),
queryKey: [
REACT_QUERY_KEY.GET_SPAN_PERCENTILES,
selectedSpan.spanId,
startTime,
endTime,
],
enabled:
shouldFetchData && !showResourceAttributesSelector && initialWaitCompleted,
onSuccess: (response) => {
if (response.httpStatusCode !== 200) {
return;
}
if (shouldUpdateUserPreference) {
updateUserPreferenceMutation({
name: USER_PREFERENCES.SPAN_PERCENTILE_RESOURCE_ATTRIBUTES,
value: [...Object.keys(selectedResourceAttributes)],
});
setShouldUpdateUserPreference(false);
}
},
keepPreviousData: false,
cacheTime: 0,
});
// 2-second delay before initial fetch
useEffect(() => {
setSpanPercentileData(null);
setIsOpen(false);
setInitialWaitCompleted(false);
const timer = setTimeout(() => {
setInitialWaitCompleted(true);
}, 2000);
return (): void => {
clearTimeout(timer);
};
}, [selectedSpan.spanId]);
useEffect(() => {
if (data?.httpStatusCode !== 200) {
setSpanPercentileData(null);
return;
}
if (data) {
setSpanPercentileData({
percentile: data.data?.position?.percentile || 0,
description: data.data?.position?.description || '',
percentiles: data.data?.percentiles || {},
});
}
}, [data]);
useEffect(() => {
if (userSelectedResourceAttributes) {
const userList = (userSelectedResourceAttributes?.data
?.value as string[]).map((attr: string) => attr);
let selectedMap: Record<string, string> = {};
userList.forEach((attr: string) => {
selectedMap[attr] = selectedSpan.tagMap?.[attr] || '';
});
selectedMap = Object.fromEntries(
Object.entries(selectedMap).filter(
([key]) => selectedSpan.tagMap?.[key] !== undefined,
),
);
const resourceAttrs = Object.entries(selectedSpan.tagMap || {}).map(
([key, value]) => ({
key,
value,
isSelected:
key === DEFAULT_RESOURCE_ATTRIBUTES.serviceName ||
key === DEFAULT_RESOURCE_ATTRIBUTES.name ||
(key in selectedMap &&
selectedMap[key] !== '' &&
selectedMap[key] !== undefined),
}),
);
const selected = resourceAttrs.filter((a) => a.isSelected);
const unselected = resourceAttrs.filter((a) => !a.isSelected);
updateSpanResourceAttributes([...selected, ...unselected]);
setSelectedResourceAttributes(selectedMap);
setShouldFetchData(true);
}
if (isErrorUserSelectedResourceAttributes) {
const resourceAttrs = Object.entries(selectedSpan.tagMap || {}).map(
([key, value]) => ({
key,
value,
isSelected:
key === DEFAULT_RESOURCE_ATTRIBUTES.serviceName ||
key === DEFAULT_RESOURCE_ATTRIBUTES.name,
}),
);
updateSpanResourceAttributes(resourceAttrs);
setShouldFetchData(true);
}
}, [
userSelectedResourceAttributes,
isErrorUserSelectedResourceAttributes,
selectedSpan.tagMap,
]);
const handleResourceAttributeChange = useCallback(
(key: string, value: string, isSelected: boolean): void => {
updateSpanResourceAttributes((prev) =>
prev.map((attr) => (attr.key === key ? { ...attr, isSelected } : attr)),
);
const newSelected = { ...selectedResourceAttributes };
if (isSelected) {
newSelected[key] = value;
} else {
delete newSelected[key];
}
setSelectedResourceAttributes(newSelected);
setShouldFetchData(true);
setShouldUpdateUserPreference(true);
},
[selectedResourceAttributes],
);
useEffect(() => {
if (
shouldFetchData &&
!showResourceAttributesSelector &&
initialWaitCompleted
) {
refetchData();
setShouldFetchData(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shouldFetchData, showResourceAttributesSelector, initialWaitCompleted]);
const loading = isLoadingData || isFetchingData;
const percentileValue = Math.floor(spanPercentileData?.percentile || 0);
const tooltipText = useMemo(
() => (
<div className="span-percentile__tooltip-text">
<Typography.Text>
This span duration is{' '}
<span className="span-percentile__tooltip-highlight">
p{percentileValue}
</span>{' '}
out of the distribution for this resource evaluated for {selectedTimeRange}{' '}
hour(s) since the span start time.
</Typography.Text>
<br />
<br />
<Typography.Text className="span-percentile__tooltip-link">
Click to learn more
</Typography.Text>
</div>
),
[percentileValue, selectedTimeRange],
);
return (
<div className="span-percentile">
{/* Badge */}
{loading && (
<div className="span-percentile__loader">
<Loader2 size={16} className="animate-spin" />
</div>
)}
{!loading && spanPercentileData && (
<Tooltip
title={isOpen ? '' : tooltipText}
placement="bottomRight"
overlayClassName="span-percentile__tooltip"
arrow={false}
>
<div
className={`span-percentile__badge ${
isOpen ? 'span-percentile__badge--open' : ''
}`}
>
<Typography.Text
className="span-percentile__badge-text"
onClick={(): void => setIsOpen((prev) => !prev)}
>
<span>p{percentileValue}</span>
{isOpen ? (
<ChevronUp size={16} className="span-percentile__badge-icon" />
) : (
<ChevronDown size={16} className="span-percentile__badge-icon" />
)}
</Typography.Text>
</div>
</Tooltip>
)}
{/* Collapsible panel */}
<AnimatePresence initial={false}>
{isOpen && !isErrorData && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
key="span-percentile-panel"
>
<div className="span-percentile__panel">
<div className="span-percentile__panel-header">
<Typography.Text
className="span-percentile__panel-header-text"
onClick={(): void => setIsOpen((prev) => !prev)}
>
<ChevronDown size={16} /> Span Percentile
</Typography.Text>
{showResourceAttributesSelector ? (
<Check
size={16}
className="cursor-pointer span-percentile__panel-header-icon"
onClick={(): void => setShowResourceAttributesSelector(false)}
/>
) : (
<PlusIcon
size={16}
className="cursor-pointer span-percentile__panel-header-icon"
onClick={(): void => setShowResourceAttributesSelector(true)}
/>
)}
</div>
{showResourceAttributesSelector && (
<div
className="span-percentile__resource-selector"
ref={resourceAttributesSelectorRef}
>
<div className="span-percentile__resource-selector-header">
<Input
placeholder="Search resource attributes"
className="span-percentile__resource-selector-input"
value={resourceAttributesSearchQuery}
onChange={(e): void =>
setResourceAttributesSearchQuery(e.target.value as string)
}
/>
</div>
<div className="span-percentile__resource-selector-items">
{spanResourceAttributes
.filter((attr) =>
attr.key
.toLowerCase()
.includes(resourceAttributesSearchQuery.toLowerCase()),
)
.map((attr) => (
<div
className="span-percentile__resource-selector-item"
key={attr.key}
>
<Checkbox
checked={attr.isSelected}
onChange={(e): void => {
handleResourceAttributeChange(
attr.key,
attr.value,
e.target.checked,
);
}}
disabled={
attr.key === DEFAULT_RESOURCE_ATTRIBUTES.serviceName ||
attr.key === DEFAULT_RESOURCE_ATTRIBUTES.name
}
>
<div className="span-percentile__resource-selector-item-value">
{attr.key}
</div>
</Checkbox>
</div>
))}
</div>
</div>
)}
<div className="span-percentile__content">
<Typography.Text className="span-percentile__content-title">
This span duration is{' '}
{!loading && spanPercentileData ? (
<span className="span-percentile__content-highlight">
p{Math.floor(spanPercentileData.percentile || 0)}
</span>
) : (
<span className="span-percentile__content-loader">
<Loader2 size={12} className="animate-spin" />
</span>
)}{' '}
out of the distribution for this resource evaluated for{' '}
{selectedTimeRange} hour(s) since the span start time.
</Typography.Text>
<div className="span-percentile__timerange">
<Select
labelInValue
placeholder="Select timerange"
className="span-percentile__timerange-select"
value={{
label: `${selectedTimeRange}h : ${dayjs(selectedSpan.timestamp)
.subtract(selectedTimeRange, 'hour')
.format(DATE_TIME_FORMATS.TIME_SPAN_PERCENTILE)} - ${dayjs(
selectedSpan.timestamp,
).format(DATE_TIME_FORMATS.TIME_SPAN_PERCENTILE)}`,
value: selectedTimeRange,
}}
onChange={(value): void => {
setShouldFetchData(true);
setSelectedTimeRange(Number(value.value));
}}
options={timerangeOptions}
/>
</div>
<div className="span-percentile__table">
<div className="span-percentile__table-header">
<Typography.Text className="span-percentile__table-header-text">
Percentile
</Typography.Text>
<Typography.Text className="span-percentile__table-header-text">
Duration
</Typography.Text>
</div>
<div className="span-percentile__table-rows">
{isLoadingData || isFetchingData ? (
<Skeleton
active
paragraph={{ rows: 3 }}
className="span-percentile__table-skeleton"
/>
) : (
<>
{Object.entries(spanPercentileData?.percentiles || {}).map(
([percentile, duration]) => (
<div className="span-percentile__table-row" key={percentile}>
<Typography.Text className="span-percentile__table-row-key">
{percentile}
</Typography.Text>
<div className="span-percentile__table-row-dash" />
<Typography.Text className="span-percentile__table-row-value">
{getYAxisFormattedValue(`${duration / 1000000}`, 'ms')}
</Typography.Text>
</div>
),
)}
<div className="span-percentile__table-row span-percentile__table-row--current">
<Typography.Text className="span-percentile__table-row-key">
p{Math.floor(spanPercentileData?.percentile || 0)}
</Typography.Text>
<div className="span-percentile__table-row-dash" />
<Typography.Text className="span-percentile__table-row-value">
(this span){' '}
{getYAxisFormattedValue(
`${selectedSpan.durationNano / 1000000}`,
'ms',
)}
</Typography.Text>
</div>
</>
)}
</div>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export default SpanPercentile;

View File

@@ -1,67 +0,0 @@
import { ChevronDown, ChevronUp, Loader2 } from 'lucide-react';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { UseSpanPercentileReturn } from './useSpanPercentile';
import './SpanPercentile.styles.scss';
type SpanPercentileBadgeProps = Pick<
UseSpanPercentileReturn,
| 'loading'
| 'percentileValue'
| 'duration'
| 'spanPercentileData'
| 'isOpen'
| 'toggleOpen'
>;
function SpanPercentileBadge({
loading,
percentileValue,
duration,
spanPercentileData,
isOpen,
toggleOpen,
}: SpanPercentileBadgeProps): JSX.Element | null {
if (loading) {
return (
<div className="span-percentile-badge__loader">
<Loader2 size={14} className="animate-spin" />
</div>
);
}
if (!spanPercentileData) {
return null;
}
return (
<div
className="span-percentile-badge"
onClick={toggleOpen}
role="button"
tabIndex={0}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {
toggleOpen();
}
}}
>
<KeyValueLabel
badgeKey={`p${percentileValue}`}
badgeValue={
<span className="span-percentile-badge__value">
{duration}
{isOpen ? (
<ChevronUp size={14} className="span-percentile-badge__icon" />
) : (
<ChevronDown size={14} className="span-percentile-badge__icon" />
)}
</span>
}
/>
</div>
);
}
export default SpanPercentileBadge;

View File

@@ -1,224 +0,0 @@
import { Checkbox, Input, Select, Skeleton, Typography } from 'antd';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { Check, ChevronDown, Loader2, PlusIcon } from 'lucide-react';
import { Span } from 'types/api/trace/getTraceV2';
import { UseSpanPercentileReturn } from './useSpanPercentile';
import './SpanPercentile.styles.scss';
const DEFAULT_RESOURCE_ATTRIBUTES = {
serviceName: 'service.name',
name: 'name',
};
const timerangeOptions = [1, 2, 4, 6, 12, 24].map((hours) => ({
label: `${hours}h`,
value: hours,
}));
interface SpanPercentilePanelProps {
selectedSpan: Span;
percentile: UseSpanPercentileReturn;
}
function SpanPercentilePanel({
selectedSpan,
percentile,
}: SpanPercentilePanelProps): JSX.Element | null {
const {
isOpen,
toggleOpen,
isError,
loading,
spanPercentileData,
selectedTimeRange,
setSelectedTimeRange,
showResourceAttributesSelector,
setShowResourceAttributesSelector,
resourceAttributesSearchQuery,
setResourceAttributesSearchQuery,
spanResourceAttributes,
handleResourceAttributeChange,
resourceAttributesSelectorRef,
isLoadingData,
isFetchingData,
} = percentile;
if (!isOpen || isError) {
return null;
}
return (
<div className="span-percentile-panel">
<div className="span-percentile-panel__header">
<Typography.Text
className="span-percentile-panel__header-text"
onClick={toggleOpen}
>
<ChevronDown size={16} /> Span Percentile
</Typography.Text>
{showResourceAttributesSelector ? (
<Check
size={16}
className="cursor-pointer span-percentile-panel__header-icon"
onClick={(): void => setShowResourceAttributesSelector(false)}
/>
) : (
<PlusIcon
size={16}
className="cursor-pointer span-percentile-panel__header-icon"
onClick={(): void => setShowResourceAttributesSelector(true)}
/>
)}
</div>
{showResourceAttributesSelector && (
<div
className="span-percentile-panel__resource-selector"
ref={resourceAttributesSelectorRef}
>
<div className="span-percentile-panel__resource-selector-header">
<Input
placeholder="Search resource attributes"
className="span-percentile-panel__resource-selector-input"
value={resourceAttributesSearchQuery}
onChange={(e): void =>
setResourceAttributesSearchQuery(e.target.value as string)
}
/>
</div>
<div className="span-percentile-panel__resource-selector-items">
{spanResourceAttributes
.filter((attr) =>
attr.key
.toLowerCase()
.includes(resourceAttributesSearchQuery.toLowerCase()),
)
.map((attr) => (
<div
className="span-percentile-panel__resource-selector-item"
key={attr.key}
>
<Checkbox
checked={attr.isSelected}
onChange={(e): void => {
handleResourceAttributeChange(
attr.key,
attr.value,
e.target.checked,
);
}}
disabled={
attr.key === DEFAULT_RESOURCE_ATTRIBUTES.serviceName ||
attr.key === DEFAULT_RESOURCE_ATTRIBUTES.name
}
>
<div className="span-percentile-panel__resource-selector-item-value">
{attr.key}
</div>
</Checkbox>
</div>
))}
</div>
</div>
)}
<div className="span-percentile-panel__content">
<Typography.Text className="span-percentile-panel__content-title">
This span duration is{' '}
{!loading && spanPercentileData ? (
<span className="span-percentile-panel__content-highlight">
p{Math.floor(spanPercentileData.percentile || 0)}
</span>
) : (
<span className="span-percentile-panel__content-loader">
<Loader2 size={12} className="animate-spin" />
</span>
)}{' '}
out of the distribution for this resource evaluated for {selectedTimeRange}{' '}
hour(s) since the span start time.
</Typography.Text>
<div className="span-percentile-panel__timerange">
<Select
labelInValue
placeholder="Select timerange"
className="span-percentile-panel__timerange-select"
getPopupContainer={(trigger): HTMLElement =>
trigger.parentElement || document.body
}
value={{
label: `${selectedTimeRange}h : ${dayjs(selectedSpan.timestamp)
.subtract(selectedTimeRange, 'hour')
.format(DATE_TIME_FORMATS.TIME_SPAN_PERCENTILE)} - ${dayjs(
selectedSpan.timestamp,
).format(DATE_TIME_FORMATS.TIME_SPAN_PERCENTILE)}`,
value: selectedTimeRange,
}}
onChange={(value): void => {
setSelectedTimeRange(Number(value.value));
}}
options={timerangeOptions}
/>
</div>
<div className="span-percentile-panel__table">
<div className="span-percentile-panel__table-header">
<Typography.Text className="span-percentile-panel__table-header-text">
Percentile
</Typography.Text>
<Typography.Text className="span-percentile-panel__table-header-text">
Duration
</Typography.Text>
</div>
<div className="span-percentile-panel__table-rows">
{isLoadingData || isFetchingData ? (
<Skeleton
active
paragraph={{ rows: 3 }}
className="span-percentile-panel__table-skeleton"
/>
) : (
<>
{Object.entries(spanPercentileData?.percentiles || {}).map(
([pKey, pDuration]) => (
<div className="span-percentile-panel__table-row" key={pKey}>
<Typography.Text className="span-percentile-panel__table-row-key">
{pKey}
</Typography.Text>
<div className="span-percentile-panel__table-row-dash" />
<Typography.Text className="span-percentile-panel__table-row-value">
{getYAxisFormattedValue(`${pDuration / 1000000}`, 'ms')}
</Typography.Text>
</div>
),
)}
<div className="span-percentile-panel__table-row span-percentile-panel__table-row--current">
<Typography.Text className="span-percentile-panel__table-row-key">
p{Math.floor(spanPercentileData?.percentile || 0)}
</Typography.Text>
<div className="span-percentile-panel__table-row-dash" />
<Typography.Text className="span-percentile-panel__table-row-value">
(this span){' '}
{getYAxisFormattedValue(
`${selectedSpan.durationNano / 1000000}`,
'ms',
)}
</Typography.Text>
</div>
</>
)}
</div>
</div>
</div>
</div>
);
}
export default SpanPercentilePanel;

View File

@@ -1,318 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery } from 'react-query';
import getSpanPercentiles from 'api/trace/getSpanPercentiles';
import getUserPreference from 'api/v1/user/preferences/name/get';
import updateUserPreference from 'api/v1/user/preferences/name/update';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { USER_PREFERENCES } from 'constants/userPreferences';
import dayjs from 'dayjs';
import useClickOutside from 'hooks/useClickOutside';
import { Span } from 'types/api/trace/getTraceV2';
export interface IResourceAttribute {
key: string;
value: string;
isSelected: boolean;
}
const DEFAULT_RESOURCE_ATTRIBUTES = {
serviceName: 'service.name',
name: 'name',
};
export interface UseSpanPercentileReturn {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
toggleOpen: () => void;
loading: boolean;
percentileValue: number;
duration: string;
spanPercentileData: {
percentile: number;
description: string;
percentiles: Record<string, number>;
} | null;
isError: boolean;
selectedTimeRange: number;
setSelectedTimeRange: (range: number) => void;
showResourceAttributesSelector: boolean;
setShowResourceAttributesSelector: (show: boolean) => void;
resourceAttributesSearchQuery: string;
setResourceAttributesSearchQuery: (query: string) => void;
spanResourceAttributes: IResourceAttribute[];
handleResourceAttributeChange: (
key: string,
value: string,
isSelected: boolean,
) => void;
resourceAttributesSelectorRef: React.MutableRefObject<HTMLDivElement | null>;
isLoadingData: boolean;
isFetchingData: boolean;
}
function useSpanPercentile(selectedSpan: Span): UseSpanPercentileReturn {
const [isOpen, setIsOpen] = useState(false);
const [selectedTimeRange, setSelectedTimeRange] = useState(1);
const [
resourceAttributesSearchQuery,
setResourceAttributesSearchQuery,
] = useState('');
const [spanPercentileData, setSpanPercentileData] = useState<{
percentile: number;
description: string;
percentiles: Record<string, number>;
} | null>(null);
const [
showResourceAttributesSelector,
setShowResourceAttributesSelector,
] = useState(false);
const [selectedResourceAttributes, setSelectedResourceAttributes] = useState<
Record<string, string>
>({});
const [spanResourceAttributes, updateSpanResourceAttributes] = useState<
IResourceAttribute[]
>([]);
const [initialWaitCompleted, setInitialWaitCompleted] = useState(false);
const [shouldFetchData, setShouldFetchData] = useState(false);
const [shouldUpdateUserPreference, setShouldUpdateUserPreference] = useState(
false,
);
const resourceAttributesSelectorRef = useRef<HTMLDivElement | null>(null);
useClickOutside({
ref: resourceAttributesSelectorRef,
onClickOutside: () => {
if (resourceAttributesSelectorRef.current) {
setShowResourceAttributesSelector(false);
}
},
eventType: 'mousedown',
});
const endTime = useMemo(
() => Math.floor(Number(selectedSpan.timestamp) / 1000) * 1000,
[selectedSpan.timestamp],
);
const startTime = useMemo(
() =>
dayjs(selectedSpan.timestamp)
.subtract(Number(selectedTimeRange), 'hour')
.unix() * 1000,
[selectedSpan.timestamp, selectedTimeRange],
);
const { mutate: updateUserPreferenceMutation } = useMutation(
updateUserPreference,
);
const {
data: userSelectedResourceAttributes,
isError: isErrorUserSelectedResourceAttributes,
} = useQuery({
queryFn: () =>
getUserPreference({
name: USER_PREFERENCES.SPAN_PERCENTILE_RESOURCE_ATTRIBUTES,
}),
queryKey: [
'getUserPreferenceByPreferenceName',
USER_PREFERENCES.SPAN_PERCENTILE_RESOURCE_ATTRIBUTES,
selectedSpan.spanId,
],
enabled: selectedSpan.tagMap !== undefined,
});
const {
isLoading: isLoadingData,
isFetching: isFetchingData,
data,
refetch: refetchData,
isError: isErrorData,
} = useQuery({
queryFn: () =>
getSpanPercentiles({
start: startTime || 0,
end: endTime || 0,
spanDuration: selectedSpan.durationNano || 0,
serviceName: selectedSpan.serviceName || '',
name: selectedSpan.name || '',
resourceAttributes: selectedResourceAttributes,
}),
queryKey: [
REACT_QUERY_KEY.GET_SPAN_PERCENTILES,
selectedSpan.spanId,
startTime,
endTime,
],
enabled:
shouldFetchData && !showResourceAttributesSelector && initialWaitCompleted,
onSuccess: (response) => {
if (response.httpStatusCode !== 200) {
return;
}
if (shouldUpdateUserPreference) {
updateUserPreferenceMutation({
name: USER_PREFERENCES.SPAN_PERCENTILE_RESOURCE_ATTRIBUTES,
value: [...Object.keys(selectedResourceAttributes)],
});
setShouldUpdateUserPreference(false);
}
},
keepPreviousData: false,
cacheTime: 0,
});
// 2-second delay before initial fetch
useEffect(() => {
setSpanPercentileData(null);
setIsOpen(false);
setInitialWaitCompleted(false);
const timer = setTimeout(() => {
setInitialWaitCompleted(true);
}, 2000);
return (): void => {
clearTimeout(timer);
};
}, [selectedSpan.spanId]);
useEffect(() => {
if (data?.httpStatusCode !== 200) {
setSpanPercentileData(null);
return;
}
if (data) {
setSpanPercentileData({
percentile: data.data?.position?.percentile || 0,
description: data.data?.position?.description || '',
percentiles: data.data?.percentiles || {},
});
}
}, [data]);
useEffect(() => {
if (userSelectedResourceAttributes) {
const userList = (userSelectedResourceAttributes?.data
?.value as string[]).map((attr: string) => attr);
let selectedMap: Record<string, string> = {};
userList.forEach((attr: string) => {
selectedMap[attr] = selectedSpan.tagMap?.[attr] || '';
});
selectedMap = Object.fromEntries(
Object.entries(selectedMap).filter(
([key]) => selectedSpan.tagMap?.[key] !== undefined,
),
);
const resourceAttrs = Object.entries(selectedSpan.tagMap || {}).map(
([key, value]) => ({
key,
value,
isSelected:
key === DEFAULT_RESOURCE_ATTRIBUTES.serviceName ||
key === DEFAULT_RESOURCE_ATTRIBUTES.name ||
(key in selectedMap &&
selectedMap[key] !== '' &&
selectedMap[key] !== undefined),
}),
);
const selected = resourceAttrs.filter((a) => a.isSelected);
const unselected = resourceAttrs.filter((a) => !a.isSelected);
updateSpanResourceAttributes([...selected, ...unselected]);
setSelectedResourceAttributes(selectedMap);
setShouldFetchData(true);
}
if (isErrorUserSelectedResourceAttributes) {
const resourceAttrs = Object.entries(selectedSpan.tagMap || {}).map(
([key, value]) => ({
key,
value,
isSelected:
key === DEFAULT_RESOURCE_ATTRIBUTES.serviceName ||
key === DEFAULT_RESOURCE_ATTRIBUTES.name,
}),
);
updateSpanResourceAttributes(resourceAttrs);
setShouldFetchData(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
userSelectedResourceAttributes,
isErrorUserSelectedResourceAttributes,
selectedSpan.tagMap,
]);
const handleResourceAttributeChange = useCallback(
(key: string, value: string, isSelected: boolean): void => {
updateSpanResourceAttributes((prev) =>
prev.map((attr) => (attr.key === key ? { ...attr, isSelected } : attr)),
);
const newSelected = { ...selectedResourceAttributes };
if (isSelected) {
newSelected[key] = value;
} else {
delete newSelected[key];
}
setSelectedResourceAttributes(newSelected);
setShouldFetchData(true);
setShouldUpdateUserPreference(true);
},
[selectedResourceAttributes],
);
useEffect(() => {
if (
shouldFetchData &&
!showResourceAttributesSelector &&
initialWaitCompleted
) {
refetchData();
setShouldFetchData(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shouldFetchData, showResourceAttributesSelector, initialWaitCompleted]);
const loading = isLoadingData || isFetchingData;
const percentileValue = Math.floor(spanPercentileData?.percentile || 0);
const duration = getYAxisFormattedValue(
`${selectedSpan.durationNano / 1000000}`,
'ms',
);
const toggleOpen = useCallback(() => setIsOpen((prev) => !prev), []);
const handleTimeRangeChange = useCallback((range: number): void => {
setShouldFetchData(true);
setSelectedTimeRange(range);
}, []);
return {
isOpen,
setIsOpen,
toggleOpen,
loading,
percentileValue,
duration,
spanPercentileData,
isError: isErrorData,
selectedTimeRange,
setSelectedTimeRange: handleTimeRangeChange,
showResourceAttributesSelector,
setShowResourceAttributesSelector,
resourceAttributesSearchQuery,
setResourceAttributesSearchQuery,
spanResourceAttributes,
handleResourceAttributeChange,
resourceAttributesSelectorRef,
isLoadingData,
isFetchingData,
};
}
export default useSpanPercentile;

View File

@@ -1,54 +0,0 @@
import { ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { Badge } from '@signozhq/badge';
import { Span } from 'types/api/trace/getTraceV2';
interface HighlightedOption {
key: string;
label: string;
render: (span: Span) => ReactNode | null;
}
export const HIGHLIGHTED_OPTIONS: HighlightedOption[] = [
{
key: 'service',
label: 'SERVICE',
render: (span): ReactNode | null =>
span.serviceName ? (
<Badge color="vanilla">
<span className="span-details-panel__service-dot" />
{span.serviceName}
</Badge>
) : null,
},
{
key: 'statusCodeString',
label: 'STATUS CODE STRING',
render: (span): ReactNode | null =>
span.statusCodeString ? (
<Badge color="vanilla">{span.statusCodeString}</Badge>
) : null,
},
{
key: 'traceId',
label: 'TRACE ID',
render: (span): ReactNode | null =>
span.traceId ? (
<Link
to={{
pathname: `/trace/${span.traceId}`,
search: window.location.search,
}}
className="span-details-panel__trace-id"
>
{span.traceId}
</Link>
) : null,
},
{
key: 'spanKind',
label: 'SPAN KIND',
render: (span): ReactNode | null =>
span.spanKind ? <Badge color="vanilla">{span.spanKind}</Badge> : null,
},
];

View File

@@ -1,25 +0,0 @@
export enum SpanDetailVariant {
DRAWER = 'drawer',
DIALOG = 'dialog',
DOCKED = 'docked',
}
export const KEY_ATTRIBUTE_KEYS: Record<string, string[]> = {
traces: [
'service.name',
'service.namespace',
'deployment.environment',
'datetime',
'duration',
'span.kind',
'status_code_string',
'http_method',
'http_url',
'http_host',
'db_name',
'db_operation',
'external_http_method',
'external_http_url',
'response_status_code',
],
};

View File

@@ -1,60 +0,0 @@
.event-tooltip-content {
font-family: Inter, sans-serif;
font-size: 12px;
color: #fff;
max-width: 300px;
&__header {
display: inline-flex;
align-items: center;
gap: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
padding: 2px 6px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 6px;
}
&__name {
font-weight: 600;
margin-bottom: 2px;
color: rgb(14, 165, 233);
&.error {
color: rgb(239, 68, 68);
}
}
&__time {
font-size: 11px;
opacity: 0.8;
margin-bottom: 4px;
}
&__divider {
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin: 6px 0;
}
&__attributes {
font-size: 11px;
}
&__kv {
margin-bottom: 2px;
line-height: 1.4;
word-break: break-all;
}
&__key {
opacity: 0.6;
}
&__value {
opacity: 0.9;
}
}

View File

@@ -1,49 +0,0 @@
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import { Diamond } from 'lucide-react';
import { toFixed } from 'utils/toFixed';
import './EventTooltipContent.styles.scss';
export interface EventTooltipContentProps {
eventName: string;
timeOffsetMs: number;
isError: boolean;
attributeMap: Record<string, string>;
}
export function EventTooltipContent({
eventName,
timeOffsetMs,
isError,
attributeMap,
}: EventTooltipContentProps): JSX.Element {
const { time, timeUnitName } = convertTimeToRelevantUnit(timeOffsetMs);
return (
<div className="event-tooltip-content">
<div className="event-tooltip-content__header">
<Diamond size={10} />
<span>EVENT DETAILS</span>
</div>
<div className={`event-tooltip-content__name ${isError ? 'error' : ''}`}>
{eventName}
</div>
<div className="event-tooltip-content__time">
{toFixed(time, 2)} {timeUnitName} from start
</div>
{Object.keys(attributeMap).length > 0 && (
<>
<div className="event-tooltip-content__divider" />
<div className="event-tooltip-content__attributes">
{Object.entries(attributeMap).map(([key, value]) => (
<div key={key} className="event-tooltip-content__kv">
<span className="event-tooltip-content__key">{key}:</span>{' '}
<span className="event-tooltip-content__value">{value}</span>
</div>
))}
</div>
</>
)}
</div>
);
}

View File

@@ -1,28 +0,0 @@
.span-hover-card-popover {
.ant-popover-inner {
background-color: rgba(30, 30, 30, 0.95);
padding: 8px 12px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: none;
}
.ant-popover-inner-content {
padding: 0;
}
}
.span-hover-card-content {
font-family: Inter, sans-serif;
font-size: 12px;
color: #fff;
&__name {
font-weight: 600;
margin-bottom: 4px;
}
&__row {
line-height: 1.5;
}
}

View File

@@ -1,94 +0,0 @@
import { ReactNode } from 'react';
import { Popover } from 'antd';
import { themeColors } from 'constants/theme';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { Span } from 'types/api/trace/getTraceV2';
import { toFixed } from 'utils/toFixed';
import './SpanHoverCard.styles.scss';
interface ITraceMetadata {
startTime: number;
endTime: number;
}
export interface SpanTooltipContentProps {
spanName: string;
color: string;
hasError: boolean;
relativeStartMs: number;
durationMs: number;
}
export function SpanTooltipContent({
spanName,
color,
hasError,
relativeStartMs,
durationMs,
}: SpanTooltipContentProps): JSX.Element {
const { time: formattedDuration, timeUnitName } = convertTimeToRelevantUnit(
durationMs,
);
return (
<div className="span-hover-card-content">
<div className="span-hover-card-content__name" style={{ color }}>
{spanName}
</div>
<div className="span-hover-card-content__row">
Status: {hasError ? 'error' : 'ok'}
</div>
<div className="span-hover-card-content__row">
Start: {toFixed(relativeStartMs, 2)} ms
</div>
<div className="span-hover-card-content__row">
Duration: {toFixed(formattedDuration, 2)} {timeUnitName}
</div>
</div>
);
}
interface SpanHoverCardProps {
span: Span;
traceMetadata: ITraceMetadata;
children: ReactNode;
}
function SpanHoverCard({
span,
traceMetadata,
children,
}: SpanHoverCardProps): JSX.Element {
const durationMs = span.durationNano / 1e6;
const relativeStartMs = span.timestamp - traceMetadata.startTime;
let color = generateColor(span.serviceName, themeColors.traceDetailColorsV3);
if (span.hasError) {
color = 'var(--bg-cherry-500)';
}
return (
<Popover
mouseEnterDelay={0.2}
content={
<SpanTooltipContent
spanName={span.name}
color={color}
hasError={span.hasError}
relativeStartMs={relativeStartMs}
durationMs={durationMs}
/>
}
trigger="hover"
rootClassName="span-hover-card-popover"
autoAdjustOverflow
arrow={false}
>
{children}
</Popover>
);
}
export default SpanHoverCard;

View File

@@ -1,91 +0,0 @@
.trace-details-header {
display: flex;
align-items: center;
padding: 0px 16px;
.previous-btn {
display: flex;
height: 30px;
padding: 6px 8px;
align-items: center;
gap: 4px;
border: 1px solid var(--bg-slate-300);
background: var(--bg-slate-500);
border-radius: 4px;
box-shadow: none;
}
.trace-name {
display: flex;
padding: 6px 8px;
margin-left: 6px;
align-items: center;
gap: 4px;
border: 1px solid var(--bg-slate-300);
border-radius: 4px 0px 0px 4px;
background: var(--bg-slate-500);
.drafting {
color: white;
}
.trace-id {
color: #fff;
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
}
}
.trace-id-value {
display: flex;
padding: 6px 8px;
justify-content: center;
align-items: center;
gap: 10px;
background: var(--bg-slate-400);
color: var(--Vanilla-400, #c0c1c3);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
border: 1px solid var(--bg-slate-300);
border-left: unset;
border-radius: 0px 4px 4px 0px;
}
}
.lightMode {
.trace-details-header {
.previous-btn {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-200);
}
.trace-name {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-200);
border-right: none;
.drafting {
color: var(--bg-ink-100);
}
.trace-id {
color: var(--bg-ink-100);
}
}
.trace-id-value {
background: var(--bg-vanilla-300);
color: var(--bg-ink-400);
border: 1px solid var(--bg-vanilla-300);
}
}
}
// TODO: move to new css module name system

View File

@@ -1,38 +0,0 @@
import { useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { Button, Typography } from 'antd';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { ArrowLeft } from 'lucide-react';
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
import './TraceDetailsHeader.styles.scss';
function TraceDetailsHeader(): JSX.Element {
const { id: traceID } = useParams<TraceDetailV2URLProps>();
const handlePreviousBtnClick = useCallback((): void => {
const isSpaNavigate =
document.referrer &&
new URL(document.referrer).origin === window.location.origin;
if (isSpaNavigate) {
history.goBack();
} else {
history.push(ROUTES.TRACES_EXPLORER);
}
}, []);
return (
<div className="trace-details-header">
<Button className="previous-btn" onClick={handlePreviousBtnClick}>
<ArrowLeft size={14} />
</Button>
<div className="trace-name">
<Typography.Text className="trace-id">Trace ID</Typography.Text>
</div>
<Typography.Text className="trace-id-value">{traceID}</Typography.Text>
</div>
);
}
export default TraceDetailsHeader;

View File

@@ -1,91 +0,0 @@
.trace-details-v3 {
height: calc(100vh);
display: flex;
flex-direction: column;
&__content {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
&__flame-collapse,
&__waterfall-collapse {
border: none;
border-radius: 0;
background: transparent;
.ant-collapse-item {
border: none;
}
.ant-collapse-header {
border: 1px solid var(--l2-border);
}
.ant-collapse-content {
background: transparent;
border-top: none;
// Disable collapse animation — virtualizer and canvas flicker during height transitions
transition: none !important;
}
}
&__flame-collapse {
flex-shrink: 0;
.ant-collapse-content-box {
padding: 0 !important;
}
}
&__waterfall-collapse {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
.ant-collapse-item {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.ant-collapse-content.ant-collapse-content-active {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.ant-collapse-content-box {
padding: 0 !important;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
&--docked {
flex: none;
.ant-collapse-item {
flex: none;
display: block;
}
.ant-collapse-content-box {
flex: none;
display: block;
}
}
}
&__docked-span-details {
flex: 1;
overflow: auto;
min-height: 0;
}
}

View File

@@ -1,250 +0,0 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import TimelineV3 from 'components/TimelineV3/TimelineV3';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { EventTooltipContent } from '../SpanHoverCard/EventTooltipContent';
import { SpanTooltipContent } from '../SpanHoverCard/SpanHoverCard';
import { DEFAULT_ROW_HEIGHT } from './constants';
import { useCanvasSetup } from './hooks/useCanvasSetup';
import { useFlamegraphDrag } from './hooks/useFlamegraphDrag';
import { useFlamegraphDraw } from './hooks/useFlamegraphDraw';
import { useFlamegraphHover } from './hooks/useFlamegraphHover';
import { useFlamegraphZoom } from './hooks/useFlamegraphZoom';
import { useScrollToSpan } from './hooks/useScrollToSpan';
import { useVisualLayoutWorker } from './hooks/useVisualLayoutWorker';
import { EventRect, FlamegraphCanvasProps, SpanRect } from './types';
function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
const { spans, traceMetadata, firstSpanAtFetchLevel, onSpanClick } = props;
const isDarkMode = useIsDarkMode(); //TODO: see if can be removed or use a new hook
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const spanRectsRef = useRef<SpanRect[]>([]);
const eventRectsRef = useRef<EventRect[]>([]);
const [viewStartTs, setViewStartTs] = useState<number>(
traceMetadata.startTime,
);
const [viewEndTs, setViewEndTs] = useState<number>(traceMetadata.endTime);
const [scrollTop, setScrollTop] = useState<number>(0);
const [rowHeight, setRowHeight] = useState<number>(DEFAULT_ROW_HEIGHT);
// Mutable refs for zoom and drag hooks to read during rAF / mouse callbacks
const viewStartRef = useRef(viewStartTs);
const viewEndRef = useRef(viewEndTs);
const rowHeightRef = useRef(rowHeight);
const scrollTopRef = useRef(scrollTop);
useEffect(() => {
viewStartRef.current = viewStartTs;
}, [viewStartTs]);
useEffect(() => {
viewEndRef.current = viewEndTs;
}, [viewEndTs]);
useEffect(() => {
rowHeightRef.current = rowHeight;
}, [rowHeight]);
useEffect(() => {
scrollTopRef.current = scrollTop;
}, [scrollTop]);
useEffect(() => {
//TODO: see if this can be removed as once loaded the view start and end ts will not change
setViewStartTs(traceMetadata.startTime);
setViewEndTs(traceMetadata.endTime);
viewStartRef.current = traceMetadata.startTime;
viewEndRef.current = traceMetadata.endTime;
}, [traceMetadata.startTime, traceMetadata.endTime]);
const { layout, isComputing: _isComputing } = useVisualLayoutWorker(spans);
const totalHeight = layout.totalVisualRows * rowHeight;
const { isOverFlamegraphRef } = useFlamegraphZoom({
canvasRef,
traceMetadata,
viewStartRef,
viewEndRef,
rowHeightRef,
setViewStartTs,
setViewEndTs,
setRowHeight,
});
const {
handleMouseDown,
handleMouseMove: handleDragMouseMove,
handleMouseUp,
handleDragMouseLeave,
isDraggingRef,
} = useFlamegraphDrag({
canvasRef,
containerRef,
traceMetadata,
viewStartRef,
viewEndRef,
setViewStartTs,
setViewEndTs,
scrollTopRef,
setScrollTop,
totalHeight,
});
const {
hoveredSpanId,
hoveredEventKey,
handleHoverMouseMove,
handleHoverMouseLeave,
handleMouseDownForClick,
handleClick,
tooltipContent,
} = useFlamegraphHover({
canvasRef,
spanRectsRef,
eventRectsRef,
traceMetadata,
viewStartTs,
viewEndTs,
isDraggingRef,
onSpanClick,
isDarkMode,
});
const { drawFlamegraph } = useFlamegraphDraw({
canvasRef,
containerRef,
spans: layout.visualRows,
connectors: layout.connectors,
viewStartTs,
viewEndTs,
scrollTop,
rowHeight,
selectedSpanId: firstSpanAtFetchLevel || undefined,
hoveredSpanId: hoveredSpanId ?? '',
isDarkMode,
spanRectsRef,
eventRectsRef,
hoveredEventKey,
});
useScrollToSpan({
firstSpanAtFetchLevel,
spans: layout.visualRows,
traceMetadata,
containerRef,
viewStartRef,
viewEndRef,
scrollTopRef,
rowHeight,
setViewStartTs,
setViewEndTs,
setScrollTop,
});
useCanvasSetup(canvasRef, containerRef, drawFlamegraph);
const handleMouseMove = useCallback(
(e: React.MouseEvent): void => {
handleDragMouseMove(e);
handleHoverMouseMove(e);
},
[handleDragMouseMove, handleHoverMouseMove],
);
const handleMouseLeave = useCallback((): void => {
isOverFlamegraphRef.current = false;
handleDragMouseLeave();
handleHoverMouseLeave();
}, [isOverFlamegraphRef, handleDragMouseLeave, handleHoverMouseLeave]);
const tooltipElement = tooltipContent
? createPortal(
<div
className="span-hover-card-popover"
style={{
position: 'fixed',
left: Math.min(tooltipContent.clientX + 15, window.innerWidth - 220),
top: Math.min(tooltipContent.clientY + 15, window.innerHeight - 100),
zIndex: 1000,
backgroundColor: 'rgba(30, 30, 30, 0.95)',
padding: '8px 12px',
borderRadius: 4,
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
pointerEvents: 'none',
}}
>
{tooltipContent.event ? (
<EventTooltipContent
eventName={tooltipContent.event.name}
timeOffsetMs={tooltipContent.event.timeOffsetMs}
isError={tooltipContent.event.isError}
attributeMap={tooltipContent.event.attributeMap}
/>
) : (
<SpanTooltipContent
spanName={tooltipContent.spanName}
color={tooltipContent.spanColor}
hasError={tooltipContent.status === 'error'}
relativeStartMs={tooltipContent.startMs}
durationMs={tooltipContent.durationMs}
/>
)}
</div>,
document.body,
)
: null;
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
padding: '0 15px',
}}
>
{tooltipElement}
<TimelineV3
startTimestamp={viewStartTs}
endTimestamp={viewEndTs}
offsetTimestamp={viewStartTs - traceMetadata.startTime}
timelineHeight={10}
/>
<div
ref={containerRef}
style={{
flex: 1,
overflow: 'hidden',
position: 'relative',
}}
onMouseEnter={(): void => {
isOverFlamegraphRef.current = true;
}}
onMouseLeave={handleMouseLeave}
>
<canvas
ref={canvasRef}
style={{
display: 'block',
width: '100%',
cursor: 'grab',
}}
onMouseDown={(e): void => {
handleMouseDown(e);
handleMouseDownForClick(e);
}}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onClick={handleClick}
/>
</div>
</div>
);
}
export default FlamegraphCanvas;

View File

@@ -1,108 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useHistory, useLocation, useParams } from 'react-router-dom';
import useGetTraceFlamegraph from 'hooks/trace/useGetTraceFlamegraph';
import useUrlQuery from 'hooks/useUrlQuery';
import { TraceDetailFlamegraphURLProps } from 'types/api/trace/getTraceFlamegraph';
import FlamegraphCanvas from './FlamegraphCanvas';
//TODO: analyse if this is needed or not and move to separate file if needed else delete this enum.
enum TraceFlamegraphState {
LOADING = 'LOADING',
SUCCESS = 'SUCCESS',
NO_DATA = 'NO_DATA',
ERROR = 'ERROR',
FETCHING_WITH_OLD_DATA = 'FETCHING_WITH_OLD_DATA',
}
function TraceFlamegraph(): JSX.Element {
const { id: traceId } = useParams<TraceDetailFlamegraphURLProps>();
const urlQuery = useUrlQuery();
const history = useHistory();
const { search } = useLocation();
const [firstSpanAtFetchLevel, setFirstSpanAtFetchLevel] = useState<string>(
urlQuery.get('spanId') || '',
);
useEffect(() => {
setFirstSpanAtFetchLevel(urlQuery.get('spanId') || '');
}, [urlQuery]);
const handleSpanClick = useCallback(
(spanId: string): void => {
setFirstSpanAtFetchLevel(spanId);
const searchParams = new URLSearchParams(search);
//tood: use from query params constants
if (searchParams.get('spanId') !== spanId) {
searchParams.set('spanId', spanId);
history.replace({ search: searchParams.toString() });
}
},
[history, search],
);
const { data, isFetching, error } = useGetTraceFlamegraph({
traceId,
// selectedSpanId: firstSpanAtFetchLevel,
limit: 120000,
});
const flamegraphState = useMemo(() => {
if (isFetching) {
if (data?.payload?.spans && data.payload.spans.length > 0) {
return TraceFlamegraphState.FETCHING_WITH_OLD_DATA;
}
return TraceFlamegraphState.LOADING;
}
if (error) {
return TraceFlamegraphState.ERROR;
}
if (data?.payload?.spans && data.payload.spans.length === 0) {
return TraceFlamegraphState.NO_DATA;
}
return TraceFlamegraphState.SUCCESS;
}, [error, isFetching, data]);
const spans = useMemo(() => data?.payload?.spans || [], [
data?.payload?.spans,
]);
const content = useMemo(() => {
switch (flamegraphState) {
case TraceFlamegraphState.LOADING:
return <div>Loading...</div>;
case TraceFlamegraphState.ERROR:
return <div>Error loading flamegraph</div>;
case TraceFlamegraphState.NO_DATA:
return <div>No data found for trace {traceId}</div>;
case TraceFlamegraphState.SUCCESS:
case TraceFlamegraphState.FETCHING_WITH_OLD_DATA:
return (
<FlamegraphCanvas
spans={spans}
firstSpanAtFetchLevel={firstSpanAtFetchLevel}
setFirstSpanAtFetchLevel={setFirstSpanAtFetchLevel}
onSpanClick={handleSpanClick}
traceMetadata={{
startTime: data?.payload?.startTimestampMillis || 0,
endTime: data?.payload?.endTimestampMillis || 0,
}}
/>
);
default:
return <div>Fetching the trace...</div>;
}
}, [
data?.payload?.endTimestampMillis,
data?.payload?.startTimestampMillis,
firstSpanAtFetchLevel,
flamegraphState,
spans,
traceId,
handleSpanClick,
]);
return <>{content}</>;
}
export default TraceFlamegraph;

View File

@@ -1,475 +0,0 @@
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { computeVisualLayout } from '../computeVisualLayout';
function makeSpan(
overrides: Partial<FlamegraphSpan> & {
spanId: string;
timestamp: number;
durationNano: number;
},
): FlamegraphSpan {
return {
parentSpanId: '',
traceId: 'trace-1',
hasError: false,
serviceName: 'svc',
name: 'op',
level: 0,
event: [],
...overrides,
};
}
describe('computeVisualLayout', () => {
it('should handle empty input', () => {
const layout = computeVisualLayout([]);
expect(layout.totalVisualRows).toBe(0);
expect(layout.visualRows).toEqual([]);
});
it('should handle single root, no children — 1 visual row', () => {
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: 100e6,
});
const layout = computeVisualLayout([[root]]);
expect(layout.totalVisualRows).toBe(1);
expect(layout.visualRows[0]).toEqual([root]);
expect(layout.spanToVisualRow['root']).toBe(0);
});
it('should keep non-overlapping siblings on the same row (compact)', () => {
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: 500e6,
});
const a = makeSpan({
spanId: 'a',
parentSpanId: 'root',
timestamp: 0,
durationNano: 100e6,
});
const b = makeSpan({
spanId: 'b',
parentSpanId: 'root',
timestamp: 200,
durationNano: 100e6,
});
const c = makeSpan({
spanId: 'c',
parentSpanId: 'root',
timestamp: 400,
durationNano: 100e6,
});
const layout = computeVisualLayout([[root], [a, b, c]]);
// root on row 0, all children on row 1
expect(layout.totalVisualRows).toBe(2);
expect(layout.spanToVisualRow['root']).toBe(0);
expect(layout.spanToVisualRow['a']).toBe(1);
expect(layout.spanToVisualRow['b']).toBe(1);
expect(layout.spanToVisualRow['c']).toBe(1);
});
it('should pack non-overlapping siblings into shared lanes (greedy packing)', () => {
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: 300e6,
});
// A and B overlap; C does not overlap with either
const a = makeSpan({
spanId: 'a',
parentSpanId: 'root',
timestamp: 0,
durationNano: 100e6, // ends at 100ms
});
const b = makeSpan({
spanId: 'b',
parentSpanId: 'root',
timestamp: 50,
durationNano: 100e6, // starts at 50ms < 100ms end of A → overlap → lane 1
});
const c = makeSpan({
spanId: 'c',
parentSpanId: 'root',
timestamp: 200,
durationNano: 100e6, // 200 >= 100, fits lane 0 with A
});
const layout = computeVisualLayout([[root], [a, b, c]]);
// root on row 0, C placed first (latest) → row 1, B doesn't overlap C → row 1, A overlaps B → row 2
expect(layout.totalVisualRows).toBe(3);
expect(layout.spanToVisualRow['root']).toBe(0);
expect(layout.spanToVisualRow['c']).toBe(1);
expect(layout.spanToVisualRow['b']).toBe(1);
expect(layout.spanToVisualRow['a']).toBe(2);
});
it('should handle full overlap — all siblings get own row', () => {
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: 200e6,
});
const a = makeSpan({
spanId: 'a',
parentSpanId: 'root',
timestamp: 0,
durationNano: 200e6,
});
const b = makeSpan({
spanId: 'b',
parentSpanId: 'root',
timestamp: 0,
durationNano: 200e6,
});
const layout = computeVisualLayout([[root], [a, b]]);
expect(layout.totalVisualRows).toBe(3);
expect(layout.spanToVisualRow['a']).toBe(1);
expect(layout.spanToVisualRow['b']).toBe(2);
});
it('should stack children correctly below overlapping parents', () => {
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: 300e6,
});
const a = makeSpan({
spanId: 'a',
parentSpanId: 'root',
timestamp: 0,
durationNano: 200e6,
});
const b = makeSpan({
spanId: 'b',
parentSpanId: 'root',
timestamp: 50,
durationNano: 200e6,
});
// Child of A
const childA = makeSpan({
spanId: 'childA',
parentSpanId: 'a',
timestamp: 10,
durationNano: 50e6,
});
// Child of B
const childB = makeSpan({
spanId: 'childB',
parentSpanId: 'b',
timestamp: 60,
durationNano: 50e6,
});
const layout = computeVisualLayout([[root], [a, b], [childA, childB]]);
// DFS processes b's subtree first (latest):
// root → row 0
// b → row 1 (parentRow 0 + 1)
// childB → row 2 (parentRow 1 + 1)
// a → try row 1 (parentRow 0 + 1), overlaps b → try row 2, overlaps childB → row 3
// childA → row 4 (parentRow 3 + 1)
expect(layout.spanToVisualRow['root']).toBe(0);
expect(layout.spanToVisualRow['b']).toBe(1);
expect(layout.spanToVisualRow['childB']).toBe(2);
expect(layout.spanToVisualRow['a']).toBe(3);
expect(layout.spanToVisualRow['childA']).toBe(4);
expect(layout.totalVisualRows).toBe(5);
});
it('should handle multiple roots as a sibling group', () => {
// Two overlapping roots
const r1 = makeSpan({
spanId: 'r1',
timestamp: 0,
durationNano: 100e6,
});
const r2 = makeSpan({
spanId: 'r2',
timestamp: 50,
durationNano: 100e6,
});
const layout = computeVisualLayout([[r1, r2]]);
expect(layout.spanToVisualRow['r1']).toBe(0);
expect(layout.spanToVisualRow['r2']).toBe(1);
expect(layout.totalVisualRows).toBe(2);
});
it('should produce compact layout for deep nesting without overlap', () => {
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: 1000e6,
});
const child = makeSpan({
spanId: 'child',
parentSpanId: 'root',
timestamp: 10,
durationNano: 500e6,
});
const grandchild = makeSpan({
spanId: 'grandchild',
parentSpanId: 'child',
timestamp: 20,
durationNano: 200e6,
});
const layout = computeVisualLayout([[root], [child], [grandchild]]);
// No overlap at any level → visual rows == tree depth
expect(layout.totalVisualRows).toBe(3);
expect(layout.spanToVisualRow['root']).toBe(0);
expect(layout.spanToVisualRow['child']).toBe(1);
expect(layout.spanToVisualRow['grandchild']).toBe(2);
});
it('should pack many sequential siblings into 1 row (no diagonal staircase)', () => {
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: 500e6,
});
// 6 sequential children — like checkoutservice/PlaceOrder scenario
const spans = [
makeSpan({
spanId: 's1',
parentSpanId: 'root',
timestamp: 3,
durationNano: 30e6,
}),
makeSpan({
spanId: 's2',
parentSpanId: 'root',
timestamp: 35,
durationNano: 4e6,
}),
makeSpan({
spanId: 's3',
parentSpanId: 'root',
timestamp: 39,
durationNano: 1e6,
}),
makeSpan({
spanId: 's4',
parentSpanId: 'root',
timestamp: 40,
durationNano: 4e6,
}),
makeSpan({
spanId: 's5',
parentSpanId: 'root',
timestamp: 44,
durationNano: 5e6,
}),
makeSpan({
spanId: 's6',
parentSpanId: 'root',
timestamp: 49,
durationNano: 1e6,
}),
];
const layout = computeVisualLayout([[root], spans]);
// All 6 sequential siblings should share 1 row
expect(layout.totalVisualRows).toBe(2);
expect(layout.spanToVisualRow['root']).toBe(0);
for (const span of spans) {
expect(layout.spanToVisualRow[span.spanId]).toBe(1);
}
});
it('should keep children below parents even with misparented spans', () => {
// Simulates the dd_sig2 bug: /route spans have parentSpanId pointing
// to the wrong ancestor, but they are at level 2 in the spans[][] input.
// Level-based packing must place them below level 1 regardless.
const httpGet = makeSpan({
spanId: 'http-get',
timestamp: 0,
durationNano: 500e6,
});
const route = makeSpan({
spanId: 'route',
parentSpanId: 'some-wrong-ancestor', // misparented!
timestamp: 10,
durationNano: 200e6,
});
const layout = computeVisualLayout([[httpGet], [route]]);
// httpGet at level 0 → row 0, route at level 1 → row 1
expect(layout.spanToVisualRow['http-get']).toBe(0);
expect(layout.spanToVisualRow['route']).toBe(1);
expect(layout.totalVisualRows).toBe(2);
});
it('should keep parent-child pairs adjacent when sibling subtrees overlap', () => {
// Multiple overlapping parents each with a child — the subtree-unit
// guarantee means every parent→child gap should be exactly 1.
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: 500e6,
});
// Three overlapping HTTP GET children of root, each with its own /route child
const get1 = makeSpan({
spanId: 'get1',
parentSpanId: 'root',
timestamp: 0,
durationNano: 200e6,
});
const route1 = makeSpan({
spanId: 'route1',
parentSpanId: 'get1',
timestamp: 10,
durationNano: 180e6,
});
const get2 = makeSpan({
spanId: 'get2',
parentSpanId: 'root',
timestamp: 50,
durationNano: 200e6,
});
const route2 = makeSpan({
spanId: 'route2',
parentSpanId: 'get2',
timestamp: 60,
durationNano: 180e6,
});
const get3 = makeSpan({
spanId: 'get3',
parentSpanId: 'root',
timestamp: 100,
durationNano: 200e6,
});
const route3 = makeSpan({
spanId: 'route3',
parentSpanId: 'get3',
timestamp: 110,
durationNano: 180e6,
});
const layout = computeVisualLayout([
[root],
[get1, get2, get3],
[route1, route2, route3],
]);
// Each parent-child pair should have a gap of exactly 1
const get1Row = layout.spanToVisualRow['get1'];
const route1Row = layout.spanToVisualRow['route1'];
const get2Row = layout.spanToVisualRow['get2'];
const route2Row = layout.spanToVisualRow['route2'];
const get3Row = layout.spanToVisualRow['get3'];
const route3Row = layout.spanToVisualRow['route3'];
expect(route1Row - get1Row).toBe(1);
expect(route2Row - get2Row).toBe(1);
expect(route3Row - get3Row).toBe(1);
});
it('should handle mixed levels — overlap at level 2 but not level 1', () => {
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: 1000e6,
});
// Non-overlapping children
const a = makeSpan({
spanId: 'a',
parentSpanId: 'root',
timestamp: 0,
durationNano: 400e6,
});
const b = makeSpan({
spanId: 'b',
parentSpanId: 'root',
timestamp: 500,
durationNano: 400e6,
});
// Overlapping grandchildren under A
const ga1 = makeSpan({
spanId: 'ga1',
parentSpanId: 'a',
timestamp: 0,
durationNano: 200e6,
});
const ga2 = makeSpan({
spanId: 'ga2',
parentSpanId: 'a',
timestamp: 100,
durationNano: 200e6,
});
const layout = computeVisualLayout([[root], [a, b], [ga1, ga2]]);
// root → row 0
// a, b → row 1 (no overlap, share row)
// ga1 → row 2, ga2 → row 3 (overlap, expanded)
// b has no children, so nothing after ga2
expect(layout.spanToVisualRow['root']).toBe(0);
expect(layout.spanToVisualRow['a']).toBe(1);
expect(layout.spanToVisualRow['b']).toBe(1);
expect(layout.spanToVisualRow['ga2']).toBe(2);
expect(layout.spanToVisualRow['ga1']).toBe(3);
expect(layout.totalVisualRows).toBe(4);
});
it('should not place a span where it covers an existing connector point (Check 2)', () => {
// Scenario: root has 3 leaf children. Sorted latest-first: C(200), B(100), A(80).
//
// C placed at row 1 [200, 400].
// B overlaps C → placed at row 2 [100, 300]. Connector from row 0→2 at x=100
// passes through row 1, recording connector point at (row 1, x=100).
// A [80, 110] does NOT overlap C's span [200, 400] at row 1 (110 < 200),
// so without connector reservation A would fit at row 1.
// But A's span [80, 110) contains the connector point x=100 at row 1.
// Check 2 prevents this placement, pushing A further down.
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: 500e6,
});
const c = makeSpan({
spanId: 'c',
parentSpanId: 'root',
timestamp: 200,
durationNano: 200e6, // [200, 400]
});
const b = makeSpan({
spanId: 'b',
parentSpanId: 'root',
timestamp: 100,
durationNano: 200e6, // [100, 300]
});
const a = makeSpan({
spanId: 'a',
parentSpanId: 'root',
timestamp: 80,
durationNano: 30e6, // [80, 110]
});
const layout = computeVisualLayout([[root], [a, b, c]]);
expect(layout.spanToVisualRow['root']).toBe(0);
expect(layout.spanToVisualRow['c']).toBe(1); // latest, placed first
expect(layout.spanToVisualRow['b']).toBe(2); // overlaps C → row 2
// A would fit at row 1 by span overlap alone, but connector point at
// (row 1, x=100) falls within A's span [80, 110). Check 2 pushes A down.
const aRow = layout.spanToVisualRow['a']!;
expect(aRow).toBeGreaterThan(1); // must NOT be at row 1
expect(aRow).toBe(3); // next free row after B at row 2 (A overlaps B)
});
});

View File

@@ -1,539 +0,0 @@
import { DASHED_BORDER_LINE_DASH, MIN_WIDTH_FOR_NAME } from '../constants';
import type { FlamegraphRowMetrics } from '../utils';
import { getFlamegraphRowMetrics } from '../utils';
import { drawEventDot, drawSpanBar, getEventDotColor } from '../utils';
import { MOCK_SPAN } from './testUtils';
jest.mock('container/TraceDetail/utils', () => ({
convertTimeToRelevantUnit: (): { time: number; timeUnitName: string } => ({
time: 50,
timeUnitName: 'ms',
}),
}));
/** Minimal 2D context for createStripePattern's internal canvas (jsdom getContext often returns null) */
const mockPatternCanvasCtx = {
beginPath: jest.fn(),
moveTo: jest.fn(),
lineTo: jest.fn(),
stroke: jest.fn(),
globalAlpha: 1,
};
const originalCreateElement = document.createElement.bind(document);
document.createElement = function (
tagName: string,
): ReturnType<typeof originalCreateElement> {
const el = originalCreateElement(tagName);
if (tagName.toLowerCase() === 'canvas') {
(el as HTMLCanvasElement).getContext = (() =>
mockPatternCanvasCtx as unknown) as HTMLCanvasElement['getContext'];
}
return el;
};
function createMockCtx(): jest.Mocked<CanvasRenderingContext2D> {
return ({
beginPath: jest.fn(),
roundRect: jest.fn(),
fill: jest.fn(),
stroke: jest.fn(),
save: jest.fn(),
restore: jest.fn(),
translate: jest.fn(),
rotate: jest.fn(),
fillRect: jest.fn(),
strokeRect: jest.fn(),
setLineDash: jest.fn(),
measureText: jest.fn(
(text: string) => ({ width: text.length * 6 } as TextMetrics),
),
createPattern: jest.fn(() => ({} as CanvasPattern)),
clip: jest.fn(),
rect: jest.fn(),
fillText: jest.fn(),
font: '',
fillStyle: '',
strokeStyle: '',
textAlign: '',
textBaseline: '',
lineWidth: 0,
globalAlpha: 1,
} as unknown) as jest.Mocked<CanvasRenderingContext2D>;
}
const METRICS: FlamegraphRowMetrics = getFlamegraphRowMetrics(24);
describe('Canvas Draw Utils', () => {
describe('drawSpanBar', () => {
it('draws rect + fill for normal span (no selected/hovered)', () => {
const ctx = createMockCtx();
const spanRectsArray: {
span: typeof MOCK_SPAN;
x: number;
y: number;
width: number;
height: number;
level: number;
}[] = [];
drawSpanBar({
ctx,
span: { ...MOCK_SPAN, event: [] },
x: 10,
y: 0,
width: 100,
levelIndex: 0,
spanRectsArray,
eventRectsArray: [],
color: '#1890ff',
isDarkMode: false,
metrics: METRICS,
});
expect(ctx.beginPath).toHaveBeenCalled();
expect(ctx.roundRect).toHaveBeenCalledWith(10, 1, 100, 22, 2);
expect(ctx.fill).toHaveBeenCalled();
expect(ctx.stroke).not.toHaveBeenCalled();
expect(spanRectsArray).toHaveLength(1);
expect(spanRectsArray[0]).toMatchObject({
x: 10,
y: 1,
width: 100,
height: 22,
level: 0,
});
});
it('uses stripe pattern + dashed stroke + 2px when selected', () => {
const ctx = createMockCtx();
const spanRectsArray: {
span: typeof MOCK_SPAN;
x: number;
y: number;
width: number;
height: number;
level: number;
}[] = [];
drawSpanBar({
ctx,
span: { ...MOCK_SPAN, spanId: 'sel', event: [] },
x: 20,
y: 0,
width: 80,
levelIndex: 1,
spanRectsArray,
eventRectsArray: [],
color: '#2F80ED',
isDarkMode: false,
metrics: METRICS,
selectedSpanId: 'sel',
});
expect(ctx.createPattern).toHaveBeenCalled();
expect(ctx.setLineDash).toHaveBeenCalledWith(DASHED_BORDER_LINE_DASH);
expect(ctx.strokeStyle).toBe('#2F80ED');
expect(ctx.lineWidth).toBe(2);
expect(ctx.stroke).toHaveBeenCalled();
expect(ctx.setLineDash).toHaveBeenLastCalledWith([]);
});
it('uses stripe pattern + solid stroke + 1px when hovered (not selected)', () => {
const ctx = createMockCtx();
const spanRectsArray: {
span: typeof MOCK_SPAN;
x: number;
y: number;
width: number;
height: number;
level: number;
}[] = [];
drawSpanBar({
ctx,
span: { ...MOCK_SPAN, spanId: 'hov', event: [] },
x: 30,
y: 0,
width: 60,
levelIndex: 0,
spanRectsArray,
eventRectsArray: [],
color: '#2F80ED',
isDarkMode: false,
metrics: METRICS,
hoveredSpanId: 'hov',
});
expect(ctx.createPattern).toHaveBeenCalled();
expect(ctx.setLineDash).not.toHaveBeenCalled();
expect(ctx.lineWidth).toBe(1);
expect(ctx.stroke).toHaveBeenCalled();
});
it('pushes spanRectsArray with correct dimensions', () => {
const ctx = createMockCtx();
const spanRectsArray: {
span: typeof MOCK_SPAN;
x: number;
y: number;
width: number;
height: number;
level: number;
}[] = [];
drawSpanBar({
ctx,
span: { ...MOCK_SPAN, spanId: 'rect-test', event: [] },
x: 5,
y: 24,
width: 200,
levelIndex: 2,
spanRectsArray,
eventRectsArray: [],
color: '#000',
isDarkMode: false,
metrics: METRICS,
});
expect(spanRectsArray[0]).toMatchObject({
x: 5,
y: 25,
width: 200,
height: 22,
level: 2,
});
expect(spanRectsArray[0].span.spanId).toBe('rect-test');
});
});
describe('drawSpanLabel (via drawSpanBar)', () => {
it('skips label when width < MIN_WIDTH_FOR_NAME', () => {
const ctx = createMockCtx();
const spanRectsArray: {
span: typeof MOCK_SPAN;
x: number;
y: number;
width: number;
height: number;
level: number;
}[] = [];
drawSpanBar({
ctx,
span: { ...MOCK_SPAN, name: 'long-span-name', event: [] },
x: 0,
y: 0,
width: MIN_WIDTH_FOR_NAME - 1,
levelIndex: 0,
spanRectsArray,
eventRectsArray: [],
color: '#000',
isDarkMode: false,
metrics: METRICS,
});
expect(ctx.clip).not.toHaveBeenCalled();
expect(ctx.fillText).not.toHaveBeenCalled();
});
it('draws name only when width >= MIN_WIDTH_FOR_NAME but < MIN_WIDTH_FOR_NAME_AND_DURATION', () => {
const ctx = createMockCtx();
ctx.measureText = jest.fn(
(t: string) => ({ width: t.length * 6 } as TextMetrics),
);
drawSpanBar({
ctx,
span: { ...MOCK_SPAN, name: 'foo', event: [] },
x: 0,
y: 0,
width: 50,
levelIndex: 0,
spanRectsArray: [],
eventRectsArray: [],
color: '#000',
isDarkMode: false,
metrics: METRICS,
});
expect(ctx.clip).toHaveBeenCalled();
expect(ctx.fillText).toHaveBeenCalled();
expect(ctx.textAlign).toBe('left');
});
it('draws name + duration when width >= MIN_WIDTH_FOR_NAME_AND_DURATION', () => {
const ctx = createMockCtx();
ctx.measureText = jest.fn(
(t: string) => ({ width: t.length * 6 } as TextMetrics),
);
drawSpanBar({
ctx,
span: { ...MOCK_SPAN, name: 'my-span', event: [] },
x: 0,
y: 0,
width: 100,
levelIndex: 0,
spanRectsArray: [],
eventRectsArray: [],
color: '#000',
isDarkMode: false,
metrics: METRICS,
});
expect(ctx.fillText).toHaveBeenCalledTimes(2);
expect(ctx.fillText).toHaveBeenCalledWith(
'50ms',
expect.any(Number),
expect.any(Number),
);
expect(ctx.fillText).toHaveBeenCalledWith(
'my-span',
expect.any(Number),
expect.any(Number),
);
});
});
describe('truncateText (via drawSpanBar)', () => {
it('uses full text when it fits', () => {
const ctx = createMockCtx();
ctx.measureText = jest.fn(
(t: string) => ({ width: t.length * 4 } as TextMetrics),
);
drawSpanBar({
ctx,
span: { ...MOCK_SPAN, name: 'short', event: [] },
x: 0,
y: 0,
width: 100,
levelIndex: 0,
spanRectsArray: [],
eventRectsArray: [],
color: '#000',
isDarkMode: false,
metrics: METRICS,
});
expect(ctx.fillText).toHaveBeenCalledWith(
'short',
expect.any(Number),
expect.any(Number),
);
});
it('truncates text when it exceeds available width', () => {
const ctx = createMockCtx();
ctx.measureText = jest.fn(
(t: string) =>
({
width: t.includes('...') ? 24 : t.length * 10,
} as TextMetrics),
);
drawSpanBar({
ctx,
span: { ...MOCK_SPAN, name: 'very-long-span-name', event: [] },
x: 0,
y: 0,
width: 50,
levelIndex: 0,
spanRectsArray: [],
eventRectsArray: [],
color: '#000',
isDarkMode: false,
metrics: METRICS,
});
const fillTextCalls = (ctx.fillText as jest.Mock).mock.calls;
const nameArg = fillTextCalls.find((c) => c[0] !== '50ms')?.[0];
expect(nameArg).toBeDefined();
expect(nameArg).toMatch(/\.\.\.$/);
});
});
describe('drawEventDot', () => {
it('uses error styling when isError is true', () => {
const ctx = createMockCtx();
const color = getEventDotColor('#000', true, false);
drawEventDot({
ctx,
x: 50,
y: 11,
color,
eventDotSize: 6,
});
expect(ctx.save).toHaveBeenCalled();
expect(ctx.translate).toHaveBeenCalledWith(50, 11);
expect(ctx.rotate).toHaveBeenCalledWith(Math.PI / 4);
expect(ctx.fillStyle).toBe('rgb(220, 38, 38)');
expect(ctx.strokeStyle).toBe('rgb(153, 27, 27)');
expect(ctx.fillRect).toHaveBeenCalledWith(-3, -3, 6, 6);
expect(ctx.strokeRect).toHaveBeenCalledWith(-3, -3, 6, 6);
expect(ctx.restore).toHaveBeenCalled();
});
it('derives color from span color when isError is false', () => {
const ctx = createMockCtx();
const color = getEventDotColor('rgb(100, 200, 150)', false, false);
drawEventDot({
ctx,
x: 0,
y: 0,
color,
eventDotSize: 6,
});
// Darkened by 20% for fill
expect(ctx.fillStyle).toBe('rgb(80, 160, 120)');
// Darkened by 40% for stroke
expect(ctx.strokeStyle).toBe('rgb(60, 120, 90)');
});
it('uses dark mode colors for error', () => {
const ctx = createMockCtx();
const color = getEventDotColor('#000', true, true);
drawEventDot({
ctx,
x: 0,
y: 0,
color,
eventDotSize: 6,
});
expect(ctx.fillStyle).toBe('rgb(239, 68, 68)');
expect(ctx.strokeStyle).toBe('rgb(185, 28, 28)');
});
it('falls back to cyan/blue for unparseable span colors', () => {
const ctx = createMockCtx();
const color = getEventDotColor('hsl(200, 50%, 50%)', false, false);
drawEventDot({
ctx,
x: 0,
y: 0,
color,
eventDotSize: 6,
});
expect(ctx.fillStyle).toBe('rgb(6, 182, 212)');
expect(ctx.strokeStyle).toBe('rgb(8, 145, 178)');
});
it('calls save, translate, rotate, restore', () => {
const ctx = createMockCtx();
const color = getEventDotColor('#000', false, false);
drawEventDot({
ctx,
x: 10,
y: 20,
color,
eventDotSize: 4,
});
expect(ctx.save).toHaveBeenCalled();
expect(ctx.translate).toHaveBeenCalledWith(10, 20);
expect(ctx.rotate).toHaveBeenCalledWith(Math.PI / 4);
expect(ctx.restore).toHaveBeenCalled();
});
});
describe('createStripePattern (via drawSpanBar)', () => {
it('uses pattern when createPattern returns non-null', () => {
const ctx = createMockCtx();
const mockPattern = {} as CanvasPattern;
(ctx.createPattern as jest.Mock).mockReturnValue(mockPattern);
drawSpanBar({
ctx,
span: { ...MOCK_SPAN, spanId: 'p', event: [] },
x: 0,
y: 0,
width: MIN_WIDTH_FOR_NAME - 1,
levelIndex: 0,
spanRectsArray: [],
eventRectsArray: [],
color: '#000',
isDarkMode: false,
metrics: METRICS,
hoveredSpanId: 'p',
});
expect(ctx.createPattern).toHaveBeenCalled();
expect(ctx.fillStyle).toBe(mockPattern);
expect(ctx.fill).toHaveBeenCalled();
});
it('skips fill when createPattern returns null', () => {
const ctx = createMockCtx();
(ctx.createPattern as jest.Mock).mockReturnValue(null);
drawSpanBar({
ctx,
span: { ...MOCK_SPAN, spanId: 'p', event: [] },
x: 0,
y: 0,
width: MIN_WIDTH_FOR_NAME - 1,
levelIndex: 0,
spanRectsArray: [],
eventRectsArray: [],
color: '#000',
isDarkMode: false,
metrics: METRICS,
selectedSpanId: 'p',
});
expect(ctx.fill).not.toHaveBeenCalled();
expect(ctx.stroke).toHaveBeenCalled();
});
});
describe('drawSpanBar with events', () => {
it('draws event dots for each span event', () => {
const ctx = createMockCtx();
const spanWithEvents = {
...MOCK_SPAN,
event: [
{
name: 'e1',
timeUnixNano: 1_010_000_000,
attributeMap: {},
isError: false,
},
{
name: 'e2',
timeUnixNano: 1_025_000_000,
attributeMap: {},
isError: true,
},
],
};
drawSpanBar({
ctx,
span: spanWithEvents,
x: 0,
y: 0,
width: 100,
levelIndex: 0,
spanRectsArray: [],
eventRectsArray: [],
color: '#000',
isDarkMode: false,
metrics: METRICS,
});
expect(ctx.save).toHaveBeenCalledTimes(3);
expect(ctx.translate).toHaveBeenCalledTimes(2);
expect(ctx.fillRect).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -1,54 +0,0 @@
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
/** Minimal FlamegraphSpan for unit tests */
export const MOCK_SPAN: FlamegraphSpan = {
timestamp: 1000,
durationNano: 50_000_000, // 50ms
spanId: 'span-1',
parentSpanId: '',
traceId: 'trace-1',
hasError: false,
serviceName: 'test-service',
name: 'test-span',
level: 0,
event: [],
};
/** Nested spans structure for findSpanById tests */
export const MOCK_SPANS: FlamegraphSpan[][] = [
[
{
...MOCK_SPAN,
spanId: 'root',
parentSpanId: '',
level: 0,
},
],
[
{
...MOCK_SPAN,
spanId: 'child-a',
parentSpanId: 'root',
level: 1,
},
{
...MOCK_SPAN,
spanId: 'child-b',
parentSpanId: 'root',
level: 1,
},
],
[
{
...MOCK_SPAN,
spanId: 'grandchild',
parentSpanId: 'child-a',
level: 2,
},
],
];
export const MOCK_TRACE_METADATA = {
startTime: 0,
endTime: 1000,
};

View File

@@ -1,144 +0,0 @@
import React from 'react';
import { act, renderHook } from '@testing-library/react';
import { useFlamegraphDrag } from '../hooks/useFlamegraphDrag';
import { MOCK_TRACE_METADATA } from './testUtils';
function createMockCanvas(): HTMLCanvasElement {
const canvas = document.createElement('canvas');
canvas.getBoundingClientRect = jest.fn(
(): DOMRect =>
({
left: 0,
top: 0,
width: 800,
height: 400,
x: 0,
y: 0,
bottom: 400,
right: 800,
toJSON: (): Record<string, unknown> => ({}),
} as DOMRect),
);
return canvas;
}
function createMockContainer(): HTMLDivElement {
const div = document.createElement('div');
Object.defineProperty(div, 'clientHeight', { value: 400 });
return div;
}
const defaultArgs = {
canvasRef: { current: createMockCanvas() },
containerRef: { current: createMockContainer() },
traceMetadata: MOCK_TRACE_METADATA,
viewStartRef: { current: 0 },
viewEndRef: { current: 1000 },
setViewStartTs: jest.fn(),
setViewEndTs: jest.fn(),
scrollTopRef: { current: 0 },
setScrollTop: jest.fn(),
totalHeight: 1000,
};
describe('useFlamegraphDrag', () => {
beforeEach(() => {
jest.clearAllMocks();
defaultArgs.viewStartRef.current = 0;
defaultArgs.viewEndRef.current = 1000;
defaultArgs.scrollTopRef.current = 0;
});
it('starts drag state on mousedown', () => {
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
act(() => {
result.current.handleMouseDown(({
button: 0,
clientX: 100,
clientY: 50,
preventDefault: jest.fn(),
} as unknown) as React.MouseEvent);
});
expect(result.current.isDraggingRef.current).toBe(true);
});
it('ignores non-left button mousedown', () => {
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
act(() => {
result.current.handleMouseDown(({
button: 1,
clientX: 100,
clientY: 50,
preventDefault: jest.fn(),
} as unknown) as React.MouseEvent);
});
expect(result.current.isDraggingRef.current).toBe(false);
});
it('updates pan/scroll on mousemove', () => {
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
act(() => {
result.current.handleMouseDown(({
button: 0,
clientX: 100,
clientY: 50,
preventDefault: jest.fn(),
} as unknown) as React.MouseEvent);
});
act(() => {
result.current.handleMouseMove(({
clientX: 150,
clientY: 100,
} as unknown) as React.MouseEvent);
});
expect(defaultArgs.setViewStartTs).toHaveBeenCalled();
expect(defaultArgs.setViewEndTs).toHaveBeenCalled();
expect(defaultArgs.setScrollTop).toHaveBeenCalled();
});
it('resets drag state on mouseup', () => {
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
act(() => {
result.current.handleMouseDown(({
button: 0,
clientX: 100,
clientY: 50,
preventDefault: jest.fn(),
} as unknown) as React.MouseEvent);
});
act(() => {
result.current.handleMouseUp();
});
expect(result.current.isDraggingRef.current).toBe(false);
});
it('cancels drag on mouseleave', () => {
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
act(() => {
result.current.handleMouseDown(({
button: 0,
clientX: 100,
clientY: 50,
preventDefault: jest.fn(),
} as unknown) as React.MouseEvent);
});
act(() => {
result.current.handleDragMouseLeave();
});
expect(result.current.isDraggingRef.current).toBe(false);
});
});

View File

@@ -1,179 +0,0 @@
import type React from 'react';
import { act, renderHook } from '@testing-library/react';
import { useFlamegraphHover } from '../hooks/useFlamegraphHover';
import type { SpanRect } from '../types';
import { MOCK_SPAN, MOCK_TRACE_METADATA } from './testUtils';
function createMockCanvas(): HTMLCanvasElement {
const canvas = document.createElement('canvas');
canvas.width = 800;
canvas.height = 400;
canvas.getBoundingClientRect = jest.fn(
(): DOMRect =>
({
left: 0,
top: 0,
width: 800,
height: 400,
x: 0,
y: 0,
bottom: 400,
right: 800,
toJSON: (): Record<string, unknown> => ({}),
} as DOMRect),
);
return canvas;
}
const spanRect: SpanRect = {
span: { ...MOCK_SPAN, spanId: 'hover-span', name: 'test-span' },
x: 100,
y: 50,
width: 200,
height: 22,
level: 0,
};
const defaultArgs = {
canvasRef: { current: createMockCanvas() },
spanRectsRef: { current: [spanRect] },
eventRectsRef: { current: [] as any[] },
traceMetadata: MOCK_TRACE_METADATA,
viewStartTs: MOCK_TRACE_METADATA.startTime,
viewEndTs: MOCK_TRACE_METADATA.endTime,
isDraggingRef: { current: false },
onSpanClick: jest.fn(),
isDarkMode: false,
};
describe('useFlamegraphHover', () => {
beforeEach(() => {
Object.defineProperty(window, 'devicePixelRatio', {
configurable: true,
value: 1,
});
jest.clearAllMocks();
defaultArgs.spanRectsRef.current = [spanRect];
defaultArgs.isDraggingRef.current = false;
});
it('sets hoveredSpanId and tooltipContent when hovering on span', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
act(() => {
result.current.handleHoverMouseMove({
clientX: 150,
clientY: 61,
} as React.MouseEvent);
});
expect(result.current.hoveredSpanId).toBe('hover-span');
expect(result.current.tooltipContent).not.toBeNull();
expect(result.current.tooltipContent?.spanName).toBe('test-span');
expect(result.current.tooltipContent?.clientX).toBe(150);
expect(result.current.tooltipContent?.clientY).toBe(61);
});
it('clears hover when moving to empty area', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
act(() => {
result.current.handleHoverMouseMove({
clientX: 150,
clientY: 61,
} as React.MouseEvent);
});
expect(result.current.hoveredSpanId).toBe('hover-span');
act(() => {
result.current.handleHoverMouseMove({
clientX: 10,
clientY: 10,
} as React.MouseEvent);
});
expect(result.current.hoveredSpanId).toBeNull();
expect(result.current.tooltipContent).toBeNull();
});
it('clears hover on mouse leave', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
act(() => {
result.current.handleHoverMouseMove({
clientX: 150,
clientY: 61,
} as React.MouseEvent);
});
act(() => {
result.current.handleHoverMouseLeave();
});
expect(result.current.hoveredSpanId).toBeNull();
expect(result.current.tooltipContent).toBeNull();
});
it('suppresses click when drag distance exceeds threshold', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
act(() => {
result.current.handleMouseDownForClick({
clientX: 100,
clientY: 50,
} as React.MouseEvent);
});
act(() => {
result.current.handleClick({
clientX: 150,
clientY: 100,
} as React.MouseEvent);
});
expect(defaultArgs.onSpanClick).not.toHaveBeenCalled();
});
it('calls onSpanClick when clicking on span', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
act(() => {
result.current.handleClick({
clientX: 150,
clientY: 61,
} as React.MouseEvent);
});
expect(defaultArgs.onSpanClick).toHaveBeenCalledWith('hover-span');
});
it('uses clientX/clientY for tooltip positioning', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
act(() => {
result.current.handleHoverMouseMove({
clientX: 200,
clientY: 60,
} as React.MouseEvent);
});
expect(result.current.tooltipContent?.clientX).toBe(200);
expect(result.current.tooltipContent?.clientY).toBe(60);
});
it('does not update hover during drag', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
defaultArgs.isDraggingRef.current = true;
act(() => {
result.current.handleHoverMouseMove({
clientX: 150,
clientY: 61,
} as React.MouseEvent);
});
expect(result.current.hoveredSpanId).toBeNull();
});
});

View File

@@ -1,279 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { DEFAULT_ROW_HEIGHT, MIN_VISIBLE_SPAN_MS } from '../constants';
import { useFlamegraphZoom } from '../hooks/useFlamegraphZoom';
import { MOCK_TRACE_METADATA } from './testUtils';
function createMockCanvas(): HTMLCanvasElement {
const canvas = document.createElement('canvas');
canvas.width = 800;
canvas.height = 400;
canvas.getBoundingClientRect = jest.fn(
(): DOMRect =>
({
left: 0,
top: 0,
width: 800,
height: 400,
x: 0,
y: 0,
bottom: 400,
right: 800,
toJSON: (): Record<string, unknown> => ({}),
} as DOMRect),
);
return canvas;
}
describe('useFlamegraphZoom', () => {
const traceMetadata = { ...MOCK_TRACE_METADATA };
beforeEach(() => {
Object.defineProperty(window, 'devicePixelRatio', {
configurable: true,
value: 1,
});
});
it('handleResetZoom restores traceMetadata.startTime/endTime', () => {
const setViewStartTs = jest.fn();
const setViewEndTs = jest.fn();
const setRowHeight = jest.fn();
const viewStartRef = { current: 100 };
const viewEndRef = { current: 500 };
const rowHeightRef = { current: 30 };
const canvasRef = { current: createMockCanvas() };
const { result } = renderHook(() =>
useFlamegraphZoom({
canvasRef,
traceMetadata,
viewStartRef,
viewEndRef,
rowHeightRef,
setViewStartTs,
setViewEndTs,
setRowHeight,
}),
);
act(() => {
result.current.handleResetZoom();
});
expect(setViewStartTs).toHaveBeenCalledWith(traceMetadata.startTime);
expect(setViewEndTs).toHaveBeenCalledWith(traceMetadata.endTime);
expect(setRowHeight).toHaveBeenCalledWith(DEFAULT_ROW_HEIGHT);
expect(viewStartRef.current).toBe(traceMetadata.startTime);
expect(viewEndRef.current).toBe(traceMetadata.endTime);
expect(rowHeightRef.current).toBe(DEFAULT_ROW_HEIGHT);
});
it('wheel zoom in decreases visible time range', async () => {
const setViewStartTs = jest.fn();
const setViewEndTs = jest.fn();
const setRowHeight = jest.fn();
const viewStartRef = { current: traceMetadata.startTime };
const viewEndRef = { current: traceMetadata.endTime };
const rowHeightRef = { current: DEFAULT_ROW_HEIGHT };
const canvas = createMockCanvas();
const canvasRef = { current: canvas };
renderHook(() =>
useFlamegraphZoom({
canvasRef,
traceMetadata,
viewStartRef,
viewEndRef,
rowHeightRef,
setViewStartTs,
setViewEndTs,
setRowHeight,
}),
);
const initialSpan = viewEndRef.current - viewStartRef.current;
await act(async () => {
canvas.dispatchEvent(
new WheelEvent('wheel', {
clientX: 400,
deltaY: -100,
bubbles: true,
}),
);
});
await act(async () => {
await new Promise((r) => requestAnimationFrame(r));
});
expect(setViewStartTs).toHaveBeenCalled();
expect(setViewEndTs).toHaveBeenCalled();
const [newStart] = setViewStartTs.mock.calls[0] ?? [];
const [newEnd] = setViewEndTs.mock.calls[0] ?? [];
if (newStart != null && newEnd != null) {
const newSpan = newEnd - newStart;
expect(newSpan).toBeLessThan(initialSpan);
}
});
it('wheel zoom out increases visible time range', async () => {
const setViewStartTs = jest.fn();
const setViewEndTs = jest.fn();
const setRowHeight = jest.fn();
const halfSpan = (traceMetadata.endTime - traceMetadata.startTime) / 2;
const viewStartRef = { current: traceMetadata.startTime + halfSpan * 0.25 };
const viewEndRef = { current: traceMetadata.startTime + halfSpan * 0.75 };
const rowHeightRef = { current: DEFAULT_ROW_HEIGHT };
const canvas = createMockCanvas();
const canvasRef = { current: canvas };
renderHook(() =>
useFlamegraphZoom({
canvasRef,
traceMetadata,
viewStartRef,
viewEndRef,
rowHeightRef,
setViewStartTs,
setViewEndTs,
setRowHeight,
}),
);
const initialSpan = viewEndRef.current - viewStartRef.current;
await act(async () => {
canvas.dispatchEvent(
new WheelEvent('wheel', {
clientX: 400,
deltaY: 100,
bubbles: true,
}),
);
});
await act(async () => {
await new Promise((r) => requestAnimationFrame(r));
});
expect(setViewStartTs).toHaveBeenCalled();
expect(setViewEndTs).toHaveBeenCalled();
const [newStart] = setViewStartTs.mock.calls[0] ?? [];
const [newEnd] = setViewEndTs.mock.calls[0] ?? [];
if (newStart != null && newEnd != null) {
const newSpan = newEnd - newStart;
expect(newSpan).toBeGreaterThanOrEqual(initialSpan);
}
});
it('clamps zoom to MIN_VISIBLE_SPAN_MS', async () => {
const setViewStartTs = jest.fn();
const setViewEndTs = jest.fn();
const setRowHeight = jest.fn();
const viewStartRef = { current: traceMetadata.startTime };
const viewEndRef = { current: traceMetadata.startTime + 100 };
const rowHeightRef = { current: DEFAULT_ROW_HEIGHT };
const canvas = createMockCanvas();
const canvasRef = { current: canvas };
renderHook(() =>
useFlamegraphZoom({
canvasRef,
traceMetadata,
viewStartRef,
viewEndRef,
rowHeightRef,
setViewStartTs,
setViewEndTs,
setRowHeight,
}),
);
await act(async () => {
canvas.dispatchEvent(
new WheelEvent('wheel', {
clientX: 400,
deltaY: 10000,
bubbles: true,
}),
);
});
await act(async () => {
await new Promise((r) => requestAnimationFrame(r));
});
const [newStart] = setViewStartTs.mock.calls[0] ?? [];
const [newEnd] = setViewEndTs.mock.calls[0] ?? [];
if (newStart != null && newEnd != null) {
const newSpan = newEnd - newStart;
expect(newSpan).toBeGreaterThanOrEqual(MIN_VISIBLE_SPAN_MS);
}
});
it('clamps viewStart/viewEnd to trace bounds', async () => {
const setViewStartTs = jest.fn();
const setViewEndTs = jest.fn();
const setRowHeight = jest.fn();
const viewStartRef = { current: traceMetadata.startTime };
const viewEndRef = { current: traceMetadata.endTime };
const rowHeightRef = { current: DEFAULT_ROW_HEIGHT };
const canvas = createMockCanvas();
const canvasRef = { current: canvas };
renderHook(() =>
useFlamegraphZoom({
canvasRef,
traceMetadata,
viewStartRef,
viewEndRef,
rowHeightRef,
setViewStartTs,
setViewEndTs,
setRowHeight,
}),
);
await act(async () => {
canvas.dispatchEvent(
new WheelEvent('wheel', {
clientX: 400,
deltaY: -5000,
bubbles: true,
}),
);
});
await act(async () => {
await new Promise((r) => requestAnimationFrame(r));
});
const [newStart] = setViewStartTs.mock.calls[0] ?? [];
const [newEnd] = setViewEndTs.mock.calls[0] ?? [];
if (newStart != null && newEnd != null) {
expect(newStart).toBeGreaterThanOrEqual(traceMetadata.startTime);
expect(newEnd).toBeLessThanOrEqual(traceMetadata.endTime);
}
});
it('returns isOverFlamegraphRef', () => {
const canvasRef = { current: createMockCanvas() };
const { result } = renderHook(() =>
useFlamegraphZoom({
canvasRef,
traceMetadata,
viewStartRef: { current: 0 },
viewEndRef: { current: 1000 },
rowHeightRef: { current: 24 },
setViewStartTs: jest.fn(),
setViewEndTs: jest.fn(),
setRowHeight: jest.fn(),
}),
);
expect(result.current.isOverFlamegraphRef).toBeDefined();
expect(result.current.isOverFlamegraphRef.current).toBe(false);
});
});

View File

@@ -1,212 +0,0 @@
import type { Dispatch, SetStateAction } from 'react';
import { useRef } from 'react';
import { act, render, waitFor } from '@testing-library/react';
import { useScrollToSpan } from '../hooks/useScrollToSpan';
import { MOCK_SPANS, MOCK_TRACE_METADATA } from './testUtils';
function TestWrapper({
firstSpanAtFetchLevel,
spans,
traceMetadata,
setViewStartTs,
setViewEndTs,
setScrollTop,
}: {
firstSpanAtFetchLevel: string;
spans: typeof MOCK_SPANS;
traceMetadata: typeof MOCK_TRACE_METADATA;
setViewStartTs: Dispatch<SetStateAction<number>>;
setViewEndTs: Dispatch<SetStateAction<number>>;
setScrollTop: Dispatch<SetStateAction<number>>;
}): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null);
const viewStartRef = useRef(traceMetadata.startTime);
const viewEndRef = useRef(traceMetadata.endTime);
const scrollTopRef = useRef(0);
useScrollToSpan({
firstSpanAtFetchLevel,
spans,
traceMetadata,
containerRef,
viewStartRef,
viewEndRef,
scrollTopRef,
rowHeight: 24,
setViewStartTs,
setViewEndTs,
setScrollTop,
});
return <div ref={containerRef} data-testid="container" />;
}
describe('useScrollToSpan', () => {
beforeEach(() => {
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 400,
});
});
it('does not update when firstSpanAtFetchLevel is empty', async () => {
const setViewStartTs = jest.fn();
const setViewEndTs = jest.fn();
const setScrollTop = jest.fn();
render(
<TestWrapper
firstSpanAtFetchLevel=""
spans={MOCK_SPANS}
traceMetadata={MOCK_TRACE_METADATA}
setViewStartTs={setViewStartTs}
setViewEndTs={setViewEndTs}
setScrollTop={setScrollTop}
/>,
);
await waitFor(() => {
expect(setViewStartTs).not.toHaveBeenCalled();
expect(setViewEndTs).not.toHaveBeenCalled();
expect(setScrollTop).not.toHaveBeenCalled();
});
});
it('does not update when spans are empty', async () => {
const setViewStartTs = jest.fn();
const setViewEndTs = jest.fn();
const setScrollTop = jest.fn();
render(
<TestWrapper
firstSpanAtFetchLevel="root"
spans={[]}
traceMetadata={MOCK_TRACE_METADATA}
setViewStartTs={setViewStartTs}
setViewEndTs={setViewEndTs}
setScrollTop={setScrollTop}
/>,
);
await waitFor(() => {
expect(setViewStartTs).not.toHaveBeenCalled();
expect(setViewEndTs).not.toHaveBeenCalled();
expect(setScrollTop).not.toHaveBeenCalled();
});
});
it('does not update when target span not found', async () => {
const setViewStartTs = jest.fn();
const setViewEndTs = jest.fn();
const setScrollTop = jest.fn();
render(
<TestWrapper
firstSpanAtFetchLevel="nonexistent"
spans={MOCK_SPANS}
traceMetadata={MOCK_TRACE_METADATA}
setViewStartTs={setViewStartTs}
setViewEndTs={setViewEndTs}
setScrollTop={setScrollTop}
/>,
);
await waitFor(() => {
expect(setViewStartTs).not.toHaveBeenCalled();
expect(setViewEndTs).not.toHaveBeenCalled();
expect(setScrollTop).not.toHaveBeenCalled();
});
});
it('calls setters when target span found', async () => {
const setViewStartTs = jest.fn();
const setViewEndTs = jest.fn();
const setScrollTop = jest.fn();
const { getByTestId } = render(
<TestWrapper
firstSpanAtFetchLevel="grandchild"
spans={MOCK_SPANS}
traceMetadata={MOCK_TRACE_METADATA}
setViewStartTs={setViewStartTs}
setViewEndTs={setViewEndTs}
setScrollTop={setScrollTop}
/>,
);
expect(getByTestId('container')).toBeInTheDocument();
await waitFor(() => {
expect(setViewStartTs).toHaveBeenCalled();
expect(setViewEndTs).toHaveBeenCalled();
expect(setScrollTop).toHaveBeenCalled();
});
const [viewStart] = setViewStartTs.mock.calls[0];
const [viewEnd] = setViewEndTs.mock.calls[0];
const [scrollTop] = setScrollTop.mock.calls[0];
expect(viewEnd - viewStart).toBeGreaterThan(0);
expect(viewStart).toBeGreaterThanOrEqual(MOCK_TRACE_METADATA.startTime);
expect(viewEnd).toBeLessThanOrEqual(MOCK_TRACE_METADATA.endTime);
expect(scrollTop).toBeGreaterThanOrEqual(0);
});
it('centers span vertically (scrollTop centers span row)', async () => {
const setScrollTop = jest.fn();
await act(async () => {
render(
<TestWrapper
firstSpanAtFetchLevel="grandchild"
spans={MOCK_SPANS}
traceMetadata={MOCK_TRACE_METADATA}
setViewStartTs={jest.fn()}
setViewEndTs={jest.fn()}
setScrollTop={setScrollTop}
/>,
);
});
await waitFor(() => expect(setScrollTop).toHaveBeenCalled());
const [scrollTop] = setScrollTop.mock.calls[0];
const levelIndex = 2;
const rowHeight = 24;
const viewportHeight = 400;
const expectedCenter =
levelIndex * rowHeight - viewportHeight / 2 + rowHeight / 2;
expect(scrollTop).toBeCloseTo(Math.max(0, expectedCenter), -1);
});
it('zooms horizontally to span with 2x duration padding', async () => {
const setViewStartTs = jest.fn();
const setViewEndTs = jest.fn();
await act(async () => {
render(
<TestWrapper
firstSpanAtFetchLevel="root"
spans={MOCK_SPANS}
traceMetadata={MOCK_TRACE_METADATA}
setViewStartTs={setViewStartTs}
setViewEndTs={setViewEndTs}
setScrollTop={jest.fn()}
/>,
);
});
await waitFor(() => {
expect(setViewStartTs).toHaveBeenCalled();
expect(setViewEndTs).toHaveBeenCalled();
});
const [viewStart] = setViewStartTs.mock.calls[0];
const [viewEnd] = setViewEndTs.mock.calls[0];
const visibleWindow = viewEnd - viewStart;
const rootSpan = MOCK_SPANS[0][0];
const spanDurationMs = rootSpan.durationNano / 1e6;
expect(visibleWindow).toBeGreaterThanOrEqual(Math.max(spanDurationMs * 2, 5));
});
});

View File

@@ -1,135 +0,0 @@
import {
clamp,
findSpanById,
formatDuration,
getFlamegraphRowMetrics,
} from '../utils';
import { MOCK_SPANS } from './testUtils';
jest.mock('container/TraceDetail/utils', () => ({
convertTimeToRelevantUnit: (
valueMs: number,
): { time: number; timeUnitName: string } => {
if (valueMs === 0) {
return { time: 0, timeUnitName: 'ms' };
}
if (valueMs < 1) {
return { time: valueMs, timeUnitName: 'ms' };
}
if (valueMs < 1000) {
return { time: valueMs, timeUnitName: 'ms' };
}
if (valueMs < 60_000) {
return { time: valueMs / 1000, timeUnitName: 's' };
}
if (valueMs < 3_600_000) {
return { time: valueMs / 60_000, timeUnitName: 'm' };
}
return { time: valueMs / 3_600_000, timeUnitName: 'hr' };
},
}));
describe('Pure Math and Data Utils', () => {
describe('clamp', () => {
it('returns value when within range', () => {
expect(clamp(5, 0, 10)).toBe(5);
expect(clamp(-3, -5, 5)).toBe(-3);
});
it('returns min when value is below min', () => {
expect(clamp(-1, 0, 10)).toBe(0);
expect(clamp(2, 5, 10)).toBe(5);
});
it('returns max when value is above max', () => {
expect(clamp(11, 0, 10)).toBe(10);
expect(clamp(100, 0, 50)).toBe(50);
});
it('handles min === max', () => {
expect(clamp(5, 7, 7)).toBe(7);
expect(clamp(7, 7, 7)).toBe(7);
});
});
describe('findSpanById', () => {
it('finds span in first level', () => {
const result = findSpanById(MOCK_SPANS, 'root');
expect(result).not.toBeNull();
expect(result?.span.spanId).toBe('root');
expect(result?.levelIndex).toBe(0);
});
it('finds span in nested level', () => {
const result = findSpanById(MOCK_SPANS, 'grandchild');
expect(result).not.toBeNull();
expect(result?.span.spanId).toBe('grandchild');
expect(result?.levelIndex).toBe(2);
});
it('returns null when span not found', () => {
expect(findSpanById(MOCK_SPANS, 'nonexistent')).toBeNull();
});
it('handles empty spans', () => {
expect(findSpanById([], 'root')).toBeNull();
expect(findSpanById([[], []], 'root')).toBeNull();
});
});
describe('getFlamegraphRowMetrics', () => {
it('computes normal row height metrics (24px)', () => {
const m = getFlamegraphRowMetrics(24);
expect(m.ROW_HEIGHT).toBe(24);
expect(m.SPAN_BAR_HEIGHT).toBe(22);
expect(m.SPAN_BAR_Y_OFFSET).toBe(1);
expect(m.EVENT_DOT_SIZE).toBe(6);
});
it('clamps span bar height to max for large row heights', () => {
const m = getFlamegraphRowMetrics(100);
expect(m.SPAN_BAR_HEIGHT).toBe(22);
expect(m.SPAN_BAR_Y_OFFSET).toBe(39);
});
it('clamps span bar height to min for small row heights', () => {
const m = getFlamegraphRowMetrics(6);
expect(m.SPAN_BAR_HEIGHT).toBe(8);
// spanBarYOffset = floor((6-8)/2) = -1 when bar exceeds row height
expect(m.SPAN_BAR_Y_OFFSET).toBe(-1);
});
it('clamps event dot size within min/max', () => {
const mSmall = getFlamegraphRowMetrics(6);
expect(mSmall.EVENT_DOT_SIZE).toBe(4);
const mLarge = getFlamegraphRowMetrics(24);
expect(mLarge.EVENT_DOT_SIZE).toBe(6);
});
});
describe('formatDuration', () => {
it('formats nanos as ms', () => {
// 1e6 nanos = 1ms
expect(formatDuration(1_000_000)).toBe('1ms');
});
it('formats larger durations as s/m/hr', () => {
// 2e9 nanos = 2000ms = 2s
expect(formatDuration(2_000_000_000)).toBe('2s');
});
it('formats zero duration', () => {
expect(formatDuration(0)).toBe('0ms');
});
it('formats very small values', () => {
// 1000 nanos = 0.001ms → mock returns { time: 0.001, timeUnitName: 'ms' }
expect(formatDuration(1000)).toBe('0ms');
});
it('formats decimal seconds correctly', () => {
expect(formatDuration(1_500_000_000)).toBe('1.5s');
});
});
});

View File

@@ -1,67 +0,0 @@
import { getSpanColor } from '../utils';
import { MOCK_SPAN } from './testUtils';
const mockGenerateColor = jest.fn();
jest.mock('lib/uPlotLib/utils/generateColor', () => ({
generateColor: (key: string, colorMap: Record<string, string>): string =>
mockGenerateColor(key, colorMap),
}));
describe('Presentation / Styling Utils', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGenerateColor.mockReturnValue('#2F80ED');
});
describe('getSpanColor', () => {
it('uses generated service color for normal span', () => {
mockGenerateColor.mockReturnValue('#1890ff');
const color = getSpanColor({
span: { ...MOCK_SPAN, hasError: false },
isDarkMode: false,
});
expect(mockGenerateColor).toHaveBeenCalledWith(
MOCK_SPAN.serviceName,
expect.any(Object),
);
expect(color).toBe('#1890ff');
});
it('overrides with error color in light mode when span has error', () => {
mockGenerateColor.mockReturnValue('#1890ff');
const color = getSpanColor({
span: { ...MOCK_SPAN, hasError: true },
isDarkMode: false,
});
expect(color).toBe('rgb(220, 38, 38)');
});
it('overrides with error color in dark mode when span has error', () => {
mockGenerateColor.mockReturnValue('#1890ff');
const color = getSpanColor({
span: { ...MOCK_SPAN, hasError: true },
isDarkMode: true,
});
expect(color).toBe('rgb(239, 68, 68)');
});
it('passes serviceName to generateColor', () => {
getSpanColor({
span: { ...MOCK_SPAN, serviceName: 'my-service' },
isDarkMode: false,
});
expect(mockGenerateColor).toHaveBeenCalledWith(
'my-service',
expect.any(Object),
);
});
});
});

View File

@@ -1,370 +0,0 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
export interface ConnectorLine {
parentRow: number;
childRow: number;
timestampMs: number;
serviceName: string;
}
export interface VisualLayout {
visualRows: FlamegraphSpan[][];
spanToVisualRow: Record<string, number>;
connectors: ConnectorLine[];
totalVisualRows: number;
}
/**
* Computes an overlap-safe visual layout for flamegraph spans using DFS ordering.
*
* Builds a parent→children tree from parentSpanId, then traverses in DFS pre-order.
* Each span is placed at parentRow+1 if free, otherwise scans upward row-by-row
* until finding a non-overlapping row. This keeps children visually close to their
* parents and avoids the BFS problem where distant siblings push children far down.
*/
export function computeVisualLayout(spans: FlamegraphSpan[][]): VisualLayout {
const spanToVisualRow = new Map<string, number>();
const visualRowsMap = new Map<number, FlamegraphSpan[]>();
let maxRow = -1;
// Per-row interval list for overlap detection
// Each entry: [startTime, endTime]
const rowIntervals = new Map<number, Array<[number, number]>>();
// function hasOverlap(row: number, startTime: number, endTime: number): boolean {
// const intervals = rowIntervals.get(row);
// if (!intervals) {
// return false;
// }
// for (const [s, e] of intervals) {
// if (startTime < e && endTime > s) {
// return true;
// }
// }
// return false;
// }
function addToRow(row: number, span: FlamegraphSpan): void {
spanToVisualRow.set(span.spanId, row);
let arr = visualRowsMap.get(row);
if (!arr) {
arr = [];
visualRowsMap.set(row, arr);
}
arr.push(span);
const startTime = span.timestamp;
const endTime = span.timestamp + span.durationNano / 1e6;
let intervals = rowIntervals.get(row);
if (!intervals) {
intervals = [];
rowIntervals.set(row, intervals);
}
intervals.push([startTime, endTime]);
if (row > maxRow) {
maxRow = row;
}
}
// Flatten all spans and build lookup + children map
const spanMap = new Map<string, FlamegraphSpan>();
const childrenMap = new Map<string, FlamegraphSpan[]>();
const allSpans: FlamegraphSpan[] = [];
for (const level of spans) {
for (const span of level) {
allSpans.push(span);
spanMap.set(span.spanId, span);
}
}
// Extract parentSpanId — the field may be missing at runtime when the API
// returns `references` instead. Fall back to the first CHILD_OF reference.
function getParentId(span: FlamegraphSpan): string {
if (span.parentSpanId) {
return span.parentSpanId;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const refs = (span as any).references as
| Array<{ spanId?: string; refType?: string }>
| undefined;
if (refs) {
for (const ref of refs) {
if (ref.refType === 'CHILD_OF' && ref.spanId) {
return ref.spanId;
}
}
}
return '';
}
// Build children map and identify roots
const roots: FlamegraphSpan[] = [];
for (const span of allSpans) {
const parentId = getParentId(span);
if (!parentId || !spanMap.has(parentId)) {
roots.push(span);
} else {
let children = childrenMap.get(parentId);
if (!children) {
children = [];
childrenMap.set(parentId, children);
}
children.push(span);
}
}
// Sort children by timestamp for deterministic ordering
for (const [, children] of childrenMap) {
children.sort((a, b) => b.timestamp - a.timestamp);
}
// --- Subtree-unit placement ---
// Compute each subtree's layout in isolation, then place as a unit
// to guarantee parent-child adjacency within subtrees.
interface ShapeEntry {
span: FlamegraphSpan;
relativeRow: number;
}
function hasOverlapIn(
intervals: Map<number, Array<[number, number]>>,
row: number,
startTime: number,
endTime: number,
): boolean {
const rowIntervals = intervals.get(row);
if (!rowIntervals) {
return false;
}
for (const [s, e] of rowIntervals) {
if (startTime < e && endTime > s) {
return true;
}
}
return false;
}
function addIntervalTo(
intervals: Map<number, Array<[number, number]>>,
row: number,
startTime: number,
endTime: number,
): void {
let arr = intervals.get(row);
if (!arr) {
arr = [];
intervals.set(row, arr);
}
arr.push([startTime, endTime]);
}
function hasConnectorConflict(
intervals: Map<number, Array<[number, number]>>,
row: number,
point: number,
): boolean {
const rowIntervals = intervals.get(row);
if (!rowIntervals) {
return false;
}
for (const [s, e] of rowIntervals) {
if (point >= s && point < e) {
return true;
}
}
return false;
}
function hasPointInSpan(
connectorPoints: Map<number, number[]>,
row: number,
startTime: number,
endTime: number,
): boolean {
const points = connectorPoints.get(row);
if (!points) {
return false;
}
for (const p of points) {
if (p >= startTime && p < endTime) {
return true;
}
}
return false;
}
function addConnectorPoint(
connectorPoints: Map<number, number[]>,
row: number,
point: number,
): void {
let arr = connectorPoints.get(row);
if (!arr) {
arr = [];
connectorPoints.set(row, arr);
}
arr.push(point);
}
function computeSubtreeShape(rootSpan: FlamegraphSpan): ShapeEntry[] {
const localIntervals = new Map<number, Array<[number, number]>>();
const localConnectorPoints = new Map<number, number[]>();
const shape: ShapeEntry[] = [];
// Place root span at relative row 0
const rootStart = rootSpan.timestamp;
const rootEnd = rootSpan.timestamp + rootSpan.durationNano / 1e6;
shape.push({ span: rootSpan, relativeRow: 0 });
addIntervalTo(localIntervals, 0, rootStart, rootEnd);
const children = childrenMap.get(rootSpan.spanId);
if (children) {
for (const child of children) {
const childShape = computeSubtreeShape(child);
const connectorX = child.timestamp;
const offset = findPlacement(
childShape,
1,
localIntervals,
localConnectorPoints,
connectorX,
);
// Record connector points for intermediate rows (1 to offset-1)
for (let r = 1; r < offset; r++) {
addConnectorPoint(localConnectorPoints, r, connectorX);
}
// Place child shape into local state at offset
for (const entry of childShape) {
const actualRow = entry.relativeRow + offset;
shape.push({ span: entry.span, relativeRow: actualRow });
const s = entry.span.timestamp;
const e = entry.span.timestamp + entry.span.durationNano / 1e6;
addIntervalTo(localIntervals, actualRow, s, e);
}
}
}
return shape;
}
function findPlacement(
shape: ShapeEntry[],
minOffset: number,
intervals: Map<number, Array<[number, number]>>,
connectorPoints?: Map<number, number[]>,
connectorX?: number,
): number {
// Track the first offset that passes Checks 1 & 2 as a fallback.
// Check 3 (connector vs span) is monotonically failing: once it fails
// at offset K, all offsets > K also fail (more intermediate rows).
// If we can't satisfy Check 3, fall back to the best offset without it.
let fallbackOffset = -1;
for (let offset = minOffset; ; offset++) {
let passesSpanChecks = true;
// Check 1: span vs span (existing)
for (const entry of shape) {
const targetRow = entry.relativeRow + offset;
const s = entry.span.timestamp;
const e = entry.span.timestamp + entry.span.durationNano / 1e6;
if (hasOverlapIn(intervals, targetRow, s, e)) {
passesSpanChecks = false;
break;
}
}
// Check 2: span vs existing connector points
if (passesSpanChecks && connectorPoints) {
for (const entry of shape) {
const targetRow = entry.relativeRow + offset;
const s = entry.span.timestamp;
const e = entry.span.timestamp + entry.span.durationNano / 1e6;
if (hasPointInSpan(connectorPoints, targetRow, s, e)) {
passesSpanChecks = false;
break;
}
}
}
if (!passesSpanChecks) {
continue;
}
// This offset passes Checks 1 & 2 — record as fallback
if (fallbackOffset === -1) {
fallbackOffset = offset;
}
// Check 3: new connector vs existing spans
if (connectorX !== undefined) {
let connectorClear = true;
for (let r = 1; r < offset; r++) {
if (hasConnectorConflict(intervals, r, connectorX)) {
connectorClear = false;
break;
}
}
if (!connectorClear) {
// Check 3 will fail for all larger offsets too.
// Fall back to the first offset that passed Checks 1 & 2.
return fallbackOffset;
}
}
return offset;
}
}
// Process roots sorted by timestamp
roots.sort((a, b) => a.timestamp - b.timestamp);
for (const root of roots) {
const shape = computeSubtreeShape(root);
const offset = findPlacement(shape, 0, rowIntervals);
for (const entry of shape) {
addToRow(entry.relativeRow + offset, entry.span);
}
}
// Build the visualRows array
const totalVisualRows = maxRow + 1;
const visualRows: FlamegraphSpan[][] = [];
for (let i = 0; i < totalVisualRows; i++) {
visualRows.push(visualRowsMap.get(i) || []);
}
// Build connector lines for parent-child pairs with row gap > 1
const connectors: ConnectorLine[] = [];
for (const [parentId, children] of childrenMap) {
const parentRow = spanToVisualRow.get(parentId);
if (parentRow === undefined) {
continue;
}
for (const child of children) {
const childRow = spanToVisualRow.get(child.spanId);
if (childRow === undefined || childRow - parentRow <= 1) {
continue;
}
connectors.push({
parentRow,
childRow,
timestampMs: child.timestamp,
serviceName: child.serviceName,
});
}
}
return {
visualRows,
spanToVisualRow: Object.fromEntries(spanToVisualRow),
connectors,
totalVisualRows,
};
}

View File

@@ -1,36 +0,0 @@
export const ROW_HEIGHT = 24;
export const SPAN_BAR_HEIGHT = 22;
export const SPAN_BAR_Y_OFFSET = Math.floor((ROW_HEIGHT - SPAN_BAR_HEIGHT) / 2);
export const EVENT_DOT_SIZE = 6;
// Span bar sizing relative to row height (used by getFlamegraphRowMetrics)
export const SPAN_BAR_HEIGHT_RATIO = SPAN_BAR_HEIGHT / ROW_HEIGHT;
export const MIN_SPAN_BAR_HEIGHT = 8;
export const MAX_SPAN_BAR_HEIGHT = SPAN_BAR_HEIGHT;
// Event dot sizing relative to span bar height
export const EVENT_DOT_SIZE_RATIO = EVENT_DOT_SIZE / SPAN_BAR_HEIGHT;
export const MIN_EVENT_DOT_SIZE = 4;
export const MAX_EVENT_DOT_SIZE = EVENT_DOT_SIZE;
export const LABEL_FONT = '11px Inter, sans-serif';
export const LABEL_PADDING_X = 8;
export const MIN_WIDTH_FOR_NAME = 30;
export const MIN_WIDTH_FOR_NAME_AND_DURATION = 80;
// Dynamic row height (vertical zoom) -- disabled for now (MIN === MAX)
export const MIN_ROW_HEIGHT = 24;
export const MAX_ROW_HEIGHT = 24;
export const DEFAULT_ROW_HEIGHT = MIN_ROW_HEIGHT;
// Zoom intensity -- how fast zoom reacts to wheel/pinch delta
export const PINCH_ZOOM_INTENSITY_H = 0.01;
export const SCROLL_ZOOM_INTENSITY_H = 0.0015;
export const PINCH_ZOOM_INTENSITY_V = 0.008;
export const SCROLL_ZOOM_INTENSITY_V = 0.001;
// Minimum visible time span in ms (prevents zooming to sub-pixel)
export const MIN_VISIBLE_SPAN_MS = 5;
// Selected span style (dashed border)
export const DASHED_BORDER_LINE_DASH = [4, 2];

View File

@@ -1,55 +0,0 @@
import { RefObject, useCallback, useEffect } from 'react';
export function useCanvasSetup(
canvasRef: RefObject<HTMLCanvasElement>,
containerRef: RefObject<HTMLDivElement>,
onDraw: () => void,
): void {
const updateCanvasSize = useCallback(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) {
return;
}
const dpr = window.devicePixelRatio || 1;
const rect = container.getBoundingClientRect();
const viewportHeight = container.clientHeight;
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${viewportHeight}px`;
const newWidth = Math.floor(rect.width * dpr);
const newHeight = Math.floor(viewportHeight * dpr);
if (canvas.width !== newWidth || canvas.height !== newHeight) {
canvas.width = newWidth;
canvas.height = newHeight;
onDraw();
}
}, [canvasRef, containerRef, onDraw]);
useEffect(() => {
const container = containerRef.current;
if (!container) {
return (): void => {};
}
const resizeObserver = new ResizeObserver(updateCanvasSize);
resizeObserver.observe(container);
updateCanvasSize();
// when dpr changes, update the canvas size
const dprQuery = window.matchMedia('(resolution: 1dppx)');
dprQuery.addEventListener('change', updateCanvasSize);
return (): void => {
resizeObserver.disconnect();
dprQuery.removeEventListener('change', updateCanvasSize);
};
}, [containerRef, updateCanvasSize]);
useEffect(() => {
onDraw();
}, [onDraw]);
}

View File

@@ -1,170 +0,0 @@
import {
Dispatch,
MouseEvent as ReactMouseEvent,
MutableRefObject,
RefObject,
SetStateAction,
useCallback,
useRef,
} from 'react';
import { ITraceMetadata } from '../types';
import { clamp } from '../utils';
interface UseFlamegraphDragArgs {
canvasRef: RefObject<HTMLCanvasElement>;
containerRef: RefObject<HTMLDivElement>;
traceMetadata: ITraceMetadata;
viewStartRef: MutableRefObject<number>;
viewEndRef: MutableRefObject<number>;
setViewStartTs: Dispatch<SetStateAction<number>>;
setViewEndTs: Dispatch<SetStateAction<number>>;
scrollTopRef: MutableRefObject<number>;
setScrollTop: Dispatch<SetStateAction<number>>;
totalHeight: number;
}
interface UseFlamegraphDragResult {
handleMouseDown: (e: ReactMouseEvent) => void;
handleMouseMove: (e: ReactMouseEvent) => void;
handleMouseUp: () => void;
handleDragMouseLeave: () => void;
isDraggingRef: MutableRefObject<boolean>;
}
export function useFlamegraphDrag(
args: UseFlamegraphDragArgs,
): UseFlamegraphDragResult {
const {
canvasRef,
containerRef,
traceMetadata,
viewStartRef,
viewEndRef,
setViewStartTs,
setViewEndTs,
scrollTopRef,
setScrollTop,
totalHeight,
} = args;
const isDraggingRef = useRef(false);
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
const dragDistanceRef = useRef(0);
const clampScrollTop = useCallback(
(next: number): number => {
const container = containerRef.current;
if (!container) {
return 0;
}
const viewportHeight = container.clientHeight;
const maxScroll = Math.max(0, totalHeight - viewportHeight);
return clamp(next, 0, maxScroll);
},
[containerRef, totalHeight],
);
const handleMouseDown = useCallback(
(event: ReactMouseEvent): void => {
if (event.button !== 0) {
return;
}
event.preventDefault();
isDraggingRef.current = true;
dragStartRef.current = { x: event.clientX, y: event.clientY };
dragDistanceRef.current = 0;
const canvas = canvasRef.current;
if (canvas) {
canvas.style.cursor = 'grabbing';
}
},
[canvasRef],
);
const handleMouseMove = useCallback(
(event: ReactMouseEvent): void => {
if (!isDraggingRef.current || !dragStartRef.current) {
return;
}
const canvas = canvasRef.current;
if (!canvas) {
return;
}
const rect = canvas.getBoundingClientRect();
const deltaX = event.clientX - dragStartRef.current.x;
const deltaY = event.clientY - dragStartRef.current.y;
dragDistanceRef.current = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
// --- Horizontal pan ---
const timeSpan = viewEndRef.current - viewStartRef.current;
const deltaTime = (deltaX / rect.width) * timeSpan;
const newStart = viewStartRef.current - deltaTime;
const clampedStart = clamp(
newStart,
traceMetadata.startTime,
traceMetadata.endTime - timeSpan,
);
const clampedEnd = clampedStart + timeSpan;
viewStartRef.current = clampedStart;
viewEndRef.current = clampedEnd;
setViewStartTs(clampedStart);
setViewEndTs(clampedEnd);
// --- Vertical scroll pan ---
const nextScrollTop = clampScrollTop(scrollTopRef.current - deltaY);
scrollTopRef.current = nextScrollTop;
setScrollTop(nextScrollTop);
dragStartRef.current = { x: event.clientX, y: event.clientY };
},
[
canvasRef,
traceMetadata,
viewStartRef,
viewEndRef,
setViewStartTs,
setViewEndTs,
scrollTopRef,
setScrollTop,
clampScrollTop,
],
);
const handleMouseUp = useCallback((): void => {
isDraggingRef.current = false;
dragStartRef.current = null;
dragDistanceRef.current = 0;
const canvas = canvasRef.current;
if (canvas) {
canvas.style.cursor = 'grab';
}
}, [canvasRef]);
// const handleDragMouseLeave = useCallback((): void => {
// isDraggingRef.current = false;
// dragStartRef.current = null;
// dragDistanceRef.current = 0;
// const canvas = canvasRef.current;
// if (canvas) {
// canvas.style.cursor = 'grab';
// }
// }, [canvasRef]);
return {
handleMouseDown,
handleMouseMove,
handleMouseUp,
handleDragMouseLeave: handleMouseUp, // Same logic for mouse up and leaving the canvas
isDraggingRef,
};
}

View File

@@ -1,317 +0,0 @@
import React, { RefObject, useCallback, useRef } from 'react';
import { themeColors } from 'constants/theme';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { ConnectorLine } from '../computeVisualLayout';
import { EventRect, SpanRect } from '../types';
import {
clamp,
drawSpanBar,
FlamegraphRowMetrics,
getFlamegraphRowMetrics,
getSpanColor,
} from '../utils';
interface UseFlamegraphDrawArgs {
canvasRef: RefObject<HTMLCanvasElement>;
containerRef: RefObject<HTMLDivElement>;
spans: FlamegraphSpan[][];
connectors: ConnectorLine[];
viewStartTs: number;
viewEndTs: number;
scrollTop: number;
rowHeight: number;
selectedSpanId: string | undefined;
hoveredSpanId: string;
isDarkMode: boolean;
spanRectsRef?: React.MutableRefObject<SpanRect[]>;
eventRectsRef?: React.MutableRefObject<EventRect[]>;
hoveredEventKey?: string | null;
}
interface UseFlamegraphDrawResult {
drawFlamegraph: () => void;
spanRectsRef: RefObject<SpanRect[]>;
eventRectsRef: RefObject<EventRect[]>;
}
const OVERSCAN_ROWS = 4;
interface DrawLevelArgs {
ctx: CanvasRenderingContext2D;
levelSpans: FlamegraphSpan[];
levelIndex: number;
y: number;
viewStartTs: number;
timeSpan: number;
cssWidth: number;
selectedSpanId: string | undefined;
hoveredSpanId: string;
isDarkMode: boolean;
spanRectsArray: SpanRect[];
eventRectsArray: EventRect[];
metrics: FlamegraphRowMetrics;
hoveredEventKey?: string | null;
}
function drawLevel(args: DrawLevelArgs): void {
const {
ctx,
levelSpans,
levelIndex,
y,
viewStartTs,
timeSpan,
cssWidth,
selectedSpanId,
hoveredSpanId,
isDarkMode,
spanRectsArray,
eventRectsArray,
metrics,
hoveredEventKey,
} = args;
const viewEndTs = viewStartTs + timeSpan;
for (let i = 0; i < levelSpans.length; i++) {
const span = levelSpans[i];
const spanStartMs = span.timestamp;
const spanEndMs = span.timestamp + span.durationNano / 1e6;
// Time culling -- skip spans entirely outside the visible time window
if (spanEndMs < viewStartTs || spanStartMs > viewEndTs) {
continue;
}
const leftOffset = ((spanStartMs - viewStartTs) / timeSpan) * cssWidth;
const rightEdge = ((spanEndMs - viewStartTs) / timeSpan) * cssWidth;
let width = rightEdge - leftOffset;
// Clamp to visible x-range
if (leftOffset < 0) {
width += leftOffset;
if (width <= 0) {
continue;
}
}
if (rightEdge > cssWidth) {
width = cssWidth - Math.max(0, leftOffset);
if (width <= 0) {
continue;
}
}
// Minimum 1px width so tiny spans remain visible
width = clamp(width, 1, Infinity);
const color = getSpanColor({ span, isDarkMode });
drawSpanBar({
ctx,
span,
x: Math.max(0, leftOffset),
y,
width,
levelIndex,
spanRectsArray,
eventRectsArray,
color,
isDarkMode,
metrics,
selectedSpanId,
hoveredSpanId,
hoveredEventKey,
});
}
}
interface DrawConnectorLinesArgs {
ctx: CanvasRenderingContext2D;
connectors: ConnectorLine[];
scrollTop: number;
viewStartTs: number;
timeSpan: number;
cssWidth: number;
viewportHeight: number;
metrics: FlamegraphRowMetrics;
}
function drawConnectorLines(args: DrawConnectorLinesArgs): void {
const {
ctx,
connectors,
scrollTop,
viewStartTs,
timeSpan,
cssWidth,
viewportHeight,
metrics,
} = args;
ctx.save();
ctx.lineWidth = 1;
ctx.globalAlpha = 0.6;
for (const conn of connectors) {
const xFrac = (conn.timestampMs - viewStartTs) / timeSpan;
if (xFrac < -0.01 || xFrac > 1.01) {
continue;
}
const parentY =
conn.parentRow * metrics.ROW_HEIGHT -
scrollTop +
metrics.SPAN_BAR_Y_OFFSET +
metrics.SPAN_BAR_HEIGHT;
const childY =
conn.childRow * metrics.ROW_HEIGHT - scrollTop + metrics.SPAN_BAR_Y_OFFSET;
// Skip if entirely outside viewport
if (parentY > viewportHeight || childY < 0) {
continue;
}
const color = generateColor(
conn.serviceName,
themeColors.traceDetailColorsV3,
);
ctx.strokeStyle = color;
const x = clamp(xFrac * cssWidth, 0, cssWidth);
ctx.beginPath();
ctx.moveTo(x, parentY);
ctx.lineTo(x, childY);
ctx.stroke();
}
ctx.restore();
}
export function useFlamegraphDraw(
args: UseFlamegraphDrawArgs,
): UseFlamegraphDrawResult {
const {
canvasRef,
containerRef,
spans,
connectors,
viewStartTs,
viewEndTs,
scrollTop,
rowHeight,
selectedSpanId,
hoveredSpanId,
isDarkMode,
spanRectsRef: spanRectsRefProp,
eventRectsRef: eventRectsRefProp,
hoveredEventKey,
} = args;
const spanRectsRefInternal = useRef<SpanRect[]>([]);
const spanRectsRef = spanRectsRefProp ?? spanRectsRefInternal;
const eventRectsRefInternal = useRef<EventRect[]>([]);
const eventRectsRef = eventRectsRefProp ?? eventRectsRefInternal;
const drawFlamegraph = useCallback(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) {
return;
}
const ctx = canvas.getContext('2d');
if (!ctx) {
return;
}
const dpr = window.devicePixelRatio || 1;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const timeSpan = viewEndTs - viewStartTs;
if (timeSpan <= 0) {
return;
}
const cssWidth = canvas.width / dpr;
const metrics = getFlamegraphRowMetrics(rowHeight);
// ---- Vertical clipping window ----
const viewportHeight = container.clientHeight;
//starts drawing OVERSCAN_ROWS(4) rows above the visible area.
const firstLevel = Math.max(
0,
Math.floor(scrollTop / metrics.ROW_HEIGHT) - OVERSCAN_ROWS,
);
// adds 2*OVERSCAN_ROWS extra rows above and below the visible area.
const visibleLevelCount =
Math.ceil(viewportHeight / metrics.ROW_HEIGHT) + 2 * OVERSCAN_ROWS;
const lastLevel = Math.min(spans.length - 1, firstLevel + visibleLevelCount);
ctx.clearRect(0, 0, cssWidth, viewportHeight);
// ---- Draw connector lines (behind span bars) ----
drawConnectorLines({
ctx,
connectors,
scrollTop,
viewStartTs,
timeSpan,
cssWidth,
viewportHeight,
metrics,
});
const spanRectsArray: SpanRect[] = [];
const eventRectsArray: EventRect[] = [];
const currentHoveredEventKey = hoveredEventKey ?? null;
// ---- Draw only visible levels ----
for (let levelIndex = firstLevel; levelIndex <= lastLevel; levelIndex++) {
const levelSpans = spans[levelIndex];
if (!levelSpans) {
continue;
}
drawLevel({
ctx,
levelSpans,
levelIndex,
y: levelIndex * metrics.ROW_HEIGHT - scrollTop,
viewStartTs,
timeSpan,
cssWidth,
selectedSpanId,
hoveredSpanId,
isDarkMode,
spanRectsArray,
eventRectsArray,
metrics,
hoveredEventKey: currentHoveredEventKey,
});
}
spanRectsRef.current = spanRectsArray;
eventRectsRef.current = eventRectsArray;
}, [
canvasRef,
containerRef,
spanRectsRef,
eventRectsRef,
spans,
connectors,
viewStartTs,
viewEndTs,
scrollTop,
rowHeight,
selectedSpanId,
hoveredSpanId,
hoveredEventKey,
isDarkMode,
]);
return { drawFlamegraph, spanRectsRef, eventRectsRef };
}

View File

@@ -1,295 +0,0 @@
import {
Dispatch,
MouseEvent as ReactMouseEvent,
MutableRefObject,
RefObject,
SetStateAction,
useCallback,
useRef,
useState,
} from 'react';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { EventRect, SpanRect } from '../types';
import { ITraceMetadata } from '../types';
import { getSpanColor } from '../utils';
function getCanvasPointer(
canvas: HTMLCanvasElement,
clientX: number,
clientY: number,
): { cssX: number; cssY: number } | null {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cssWidth = canvas.width / dpr;
const cssHeight = canvas.height / dpr;
const cssX = (clientX - rect.left) * (cssWidth / rect.width);
const cssY = (clientY - rect.top) * (cssHeight / rect.height);
return { cssX, cssY };
}
function findSpanAtPosition(
cssX: number,
cssY: number,
spanRects: SpanRect[],
): FlamegraphSpan | null {
for (let i = spanRects.length - 1; i >= 0; i--) {
const r = spanRects[i];
if (
cssX >= r.x &&
cssX <= r.x + r.width &&
cssY >= r.y &&
cssY <= r.y + r.height
) {
return r.span;
}
}
return null;
}
function findEventAtPosition(
cssX: number,
cssY: number,
eventRects: EventRect[],
): EventRect | null {
for (let i = eventRects.length - 1; i >= 0; i--) {
const r = eventRects[i];
// Manhattan distance check for diamond shape with padding
if (Math.abs(r.cx - cssX) + Math.abs(r.cy - cssY) <= r.halfSize * 1.5) {
return r;
}
}
return null;
}
export interface EventTooltipData {
name: string;
timeOffsetMs: number;
isError: boolean;
attributeMap: Record<string, string>;
}
export interface TooltipContent {
serviceName: string;
spanName: string;
status: 'ok' | 'warning' | 'error';
startMs: number;
durationMs: number;
clientX: number;
clientY: number;
spanColor: string;
event?: EventTooltipData;
}
interface UseFlamegraphHoverArgs {
canvasRef: RefObject<HTMLCanvasElement>;
spanRectsRef: MutableRefObject<SpanRect[]>;
eventRectsRef: MutableRefObject<EventRect[]>;
traceMetadata: ITraceMetadata;
viewStartTs: number;
viewEndTs: number;
isDraggingRef: MutableRefObject<boolean>;
onSpanClick: (spanId: string) => void;
isDarkMode: boolean;
}
interface UseFlamegraphHoverResult {
hoveredSpanId: string | null;
setHoveredSpanId: Dispatch<SetStateAction<string | null>>;
hoveredEventKey: string | null;
handleHoverMouseMove: (e: ReactMouseEvent) => void;
handleHoverMouseLeave: () => void;
handleMouseDownForClick: (e: ReactMouseEvent) => void;
handleClick: (e: ReactMouseEvent) => void;
tooltipContent: TooltipContent | null;
}
export function useFlamegraphHover(
args: UseFlamegraphHoverArgs,
): UseFlamegraphHoverResult {
const {
canvasRef,
spanRectsRef,
eventRectsRef,
traceMetadata,
viewStartTs,
viewEndTs,
isDraggingRef,
onSpanClick,
isDarkMode,
} = args;
const [hoveredSpanId, setHoveredSpanId] = useState<string | null>(null);
const [hoveredEventKey, setHoveredEventKey] = useState<string | null>(null);
const [tooltipContent, setTooltipContent] = useState<TooltipContent | null>(
null,
);
const isZoomed =
viewStartTs !== traceMetadata.startTime ||
viewEndTs !== traceMetadata.endTime;
const updateCursor = useCallback(
(canvas: HTMLCanvasElement, span: FlamegraphSpan | null): void => {
if (span) {
canvas.style.cursor = 'pointer';
} else if (isZoomed) {
canvas.style.cursor = 'grab';
} else {
canvas.style.cursor = 'default';
}
},
[isZoomed],
);
const handleHoverMouseMove = useCallback(
(e: ReactMouseEvent): void => {
if (isDraggingRef.current) {
return;
}
const canvas = canvasRef.current;
if (!canvas) {
return;
}
const pointer = getCanvasPointer(canvas, e.clientX, e.clientY);
if (!pointer) {
return;
}
// Check event dots first — they're drawn on top of spans
const eventRect = findEventAtPosition(
pointer.cssX,
pointer.cssY,
eventRectsRef.current,
);
if (eventRect) {
const { event, span } = eventRect;
const eventTimeMs = event.timeUnixNano / 1e6;
setHoveredEventKey(`${span.spanId}-${event.name}-${event.timeUnixNano}`);
setHoveredSpanId(span.spanId);
setTooltipContent({
serviceName: span.serviceName || '',
spanName: span.name || 'unknown',
status: span.hasError ? 'error' : 'ok',
startMs: span.timestamp - traceMetadata.startTime,
durationMs: span.durationNano / 1e6,
clientX: e.clientX,
clientY: e.clientY,
spanColor: getSpanColor({ span, isDarkMode }),
event: {
name: event.name,
timeOffsetMs: eventTimeMs - span.timestamp,
isError: event.isError,
attributeMap: event.attributeMap || {},
},
});
updateCursor(canvas, eventRect.span);
return;
}
const span = findSpanAtPosition(
pointer.cssX,
pointer.cssY,
spanRectsRef.current,
);
if (span) {
setHoveredEventKey(null);
setHoveredSpanId(span.spanId);
setTooltipContent({
serviceName: span.serviceName || '',
spanName: span.name || 'unknown',
status: span.hasError ? 'error' : 'ok',
startMs: span.timestamp - traceMetadata.startTime,
durationMs: span.durationNano / 1e6,
clientX: e.clientX,
clientY: e.clientY,
spanColor: getSpanColor({ span, isDarkMode }),
});
updateCursor(canvas, span);
} else {
setHoveredEventKey(null);
setHoveredSpanId(null);
setTooltipContent(null);
updateCursor(canvas, null);
}
},
[
canvasRef,
spanRectsRef,
eventRectsRef,
traceMetadata.startTime,
isDraggingRef,
updateCursor,
isDarkMode,
],
);
const handleHoverMouseLeave = useCallback((): void => {
setHoveredEventKey(null);
setHoveredSpanId(null);
setTooltipContent(null);
const canvas = canvasRef.current;
if (canvas) {
updateCursor(canvas, null);
}
}, [canvasRef, updateCursor]);
const mouseDownPosRef = useRef<{ x: number; y: number } | null>(null);
const CLICK_THRESHOLD = 5;
const handleMouseDownForClick = useCallback((e: ReactMouseEvent): void => {
mouseDownPosRef.current = { x: e.clientX, y: e.clientY };
}, []);
const handleClick = useCallback(
(e: ReactMouseEvent): void => {
// Detect drag: if mouse moved more than threshold, skip click
if (mouseDownPosRef.current) {
const dx = e.clientX - mouseDownPosRef.current.x;
const dy = e.clientY - mouseDownPosRef.current.y;
if (Math.sqrt(dx * dx + dy * dy) > CLICK_THRESHOLD) {
mouseDownPosRef.current = null;
return;
}
}
mouseDownPosRef.current = null;
const canvas = canvasRef.current;
if (!canvas) {
return;
}
const pointer = getCanvasPointer(canvas, e.clientX, e.clientY);
if (!pointer) {
return;
}
const span = findSpanAtPosition(
pointer.cssX,
pointer.cssY,
spanRectsRef.current,
);
if (span) {
onSpanClick(span.spanId);
}
},
[canvasRef, spanRectsRef, onSpanClick],
);
return {
hoveredSpanId,
setHoveredSpanId,
hoveredEventKey,
handleHoverMouseMove,
handleHoverMouseLeave,
handleMouseDownForClick,
handleClick,
tooltipContent,
};
}

View File

@@ -1,224 +0,0 @@
import {
Dispatch,
MutableRefObject,
RefObject,
SetStateAction,
useCallback,
useEffect,
useRef,
} from 'react';
import {
DEFAULT_ROW_HEIGHT,
MAX_ROW_HEIGHT,
MIN_ROW_HEIGHT,
MIN_VISIBLE_SPAN_MS,
PINCH_ZOOM_INTENSITY_H,
PINCH_ZOOM_INTENSITY_V,
SCROLL_ZOOM_INTENSITY_H,
SCROLL_ZOOM_INTENSITY_V,
} from '../constants';
import { ITraceMetadata } from '../types';
import { clamp } from '../utils';
interface UseFlamegraphZoomArgs {
canvasRef: RefObject<HTMLCanvasElement>;
traceMetadata: ITraceMetadata;
viewStartRef: MutableRefObject<number>;
viewEndRef: MutableRefObject<number>;
rowHeightRef: MutableRefObject<number>;
setViewStartTs: Dispatch<SetStateAction<number>>;
setViewEndTs: Dispatch<SetStateAction<number>>;
setRowHeight: Dispatch<SetStateAction<number>>;
}
interface UseFlamegraphZoomResult {
handleResetZoom: () => void;
isOverFlamegraphRef: MutableRefObject<boolean>;
}
function getCanvasPointer(
canvasRef: RefObject<HTMLCanvasElement>,
clientX: number,
): { cssX: number; cssWidth: number } | null {
const canvas = canvasRef.current;
if (!canvas) {
return null;
}
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cssWidth = canvas.width / dpr;
const cssX = (clientX - rect.left) * (cssWidth / rect.width);
return { cssX, cssWidth };
}
export function useFlamegraphZoom(
args: UseFlamegraphZoomArgs,
): UseFlamegraphZoomResult {
const {
canvasRef,
traceMetadata,
viewStartRef,
viewEndRef,
rowHeightRef,
setViewStartTs,
setViewEndTs,
setRowHeight,
} = args;
const isOverFlamegraphRef = useRef(false);
const wheelDeltaRef = useRef(0);
const rafRef = useRef<number | null>(null);
const lastCursorXRef = useRef(0);
const lastCssWidthRef = useRef(1);
const lastIsPinchRef = useRef(false);
const lastWheelClientXRef = useRef<number | null>(null);
// Prevent browser zoom when pinching over the flamegraph
useEffect(() => {
const onWheel = (e: WheelEvent): void => {
if (isOverFlamegraphRef.current && e.ctrlKey) {
e.preventDefault();
}
};
window.addEventListener('wheel', onWheel, { passive: false, capture: true });
return (): void => {
window.removeEventListener('wheel', onWheel, {
capture: true,
} as EventListenerOptions);
};
}, []);
const applyWheelZoom = useCallback(() => {
rafRef.current = null;
const cssWidth = lastCssWidthRef.current || 1;
const cursorX = lastCursorXRef.current;
const fullSpanMs = traceMetadata.endTime - traceMetadata.startTime;
const oldStart = viewStartRef.current;
const oldEnd = viewEndRef.current;
const oldSpan = oldEnd - oldStart;
const deltaY = wheelDeltaRef.current;
wheelDeltaRef.current = 0;
if (deltaY === 0) {
return;
}
const zoomH = lastIsPinchRef.current
? PINCH_ZOOM_INTENSITY_H
: SCROLL_ZOOM_INTENSITY_H;
const zoomV = lastIsPinchRef.current
? PINCH_ZOOM_INTENSITY_V
: SCROLL_ZOOM_INTENSITY_V;
const factorH = Math.exp(deltaY * zoomH);
const factorV = Math.exp(deltaY * zoomV);
// --- Horizontal zoom ---
const desiredSpan = oldSpan * factorH;
const minSpanMs = Math.max(
MIN_VISIBLE_SPAN_MS,
oldSpan / Math.max(cssWidth, 1),
);
const clampedSpan = clamp(desiredSpan, minSpanMs, fullSpanMs);
const cursorRatio = clamp(cursorX / cssWidth, 0, 1);
const anchorTs = oldStart + cursorRatio * oldSpan;
let nextStart = anchorTs - cursorRatio * clampedSpan;
nextStart = clamp(
nextStart,
traceMetadata.startTime,
traceMetadata.endTime - clampedSpan,
);
const nextEnd = nextStart + clampedSpan;
// --- Vertical zoom (row height) ---
const desiredRow = rowHeightRef.current * (1 / factorV);
const nextRow = clamp(desiredRow, MIN_ROW_HEIGHT, MAX_ROW_HEIGHT);
// Write refs immediately so rapid wheel events read fresh values
viewStartRef.current = nextStart;
viewEndRef.current = nextEnd;
rowHeightRef.current = nextRow;
setViewStartTs(nextStart);
setViewEndTs(nextEnd);
setRowHeight(nextRow);
}, [
traceMetadata,
viewStartRef,
viewEndRef,
rowHeightRef,
setViewStartTs,
setViewEndTs,
setRowHeight,
]);
// Native wheel listener on the canvas (passive: false for reliable preventDefault)
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) {
return (): void => {};
}
const onWheel = (e: WheelEvent): void => {
e.preventDefault();
const pointer = getCanvasPointer(canvasRef, e.clientX);
if (!pointer) {
return;
}
// Flush accumulated delta if cursor moved significantly
if (lastWheelClientXRef.current !== null) {
const moved = Math.abs(e.clientX - lastWheelClientXRef.current);
if (moved > 6) {
wheelDeltaRef.current = 0;
}
}
lastWheelClientXRef.current = e.clientX;
lastIsPinchRef.current = e.ctrlKey;
lastCssWidthRef.current = pointer.cssWidth;
lastCursorXRef.current = pointer.cssX;
wheelDeltaRef.current += e.deltaY;
if (rafRef.current == null) {
rafRef.current = requestAnimationFrame(applyWheelZoom);
}
};
canvas.addEventListener('wheel', onWheel, { passive: false });
return (): void => {
canvas.removeEventListener('wheel', onWheel);
};
}, [canvasRef, applyWheelZoom]);
const handleResetZoom = useCallback(() => {
viewStartRef.current = traceMetadata.startTime;
viewEndRef.current = traceMetadata.endTime;
rowHeightRef.current = DEFAULT_ROW_HEIGHT;
setViewStartTs(traceMetadata.startTime);
setViewEndTs(traceMetadata.endTime);
setRowHeight(DEFAULT_ROW_HEIGHT);
}, [
traceMetadata,
viewStartRef,
viewEndRef,
rowHeightRef,
setViewStartTs,
setViewEndTs,
setRowHeight,
]);
return { handleResetZoom, isOverFlamegraphRef };
}

View File

@@ -1,118 +0,0 @@
import {
Dispatch,
MutableRefObject,
RefObject,
SetStateAction,
useEffect,
} from 'react';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { MIN_VISIBLE_SPAN_MS } from '../constants';
import { ITraceMetadata } from '../types';
import { clamp, findSpanById, getFlamegraphRowMetrics } from '../utils';
interface UseScrollToSpanArgs {
firstSpanAtFetchLevel: string;
spans: FlamegraphSpan[][];
traceMetadata: ITraceMetadata;
containerRef: RefObject<HTMLDivElement>;
viewStartRef: MutableRefObject<number>;
viewEndRef: MutableRefObject<number>;
scrollTopRef: MutableRefObject<number>;
rowHeight: number;
setViewStartTs: Dispatch<SetStateAction<number>>;
setViewEndTs: Dispatch<SetStateAction<number>>;
setScrollTop: Dispatch<SetStateAction<number>>;
}
/**
* When firstSpanAtFetchLevel (from URL spanId) changes, scroll and zoom the
* flamegraph so the selected span is centered in view.
*/
export function useScrollToSpan(args: UseScrollToSpanArgs): void {
const {
firstSpanAtFetchLevel,
spans,
traceMetadata,
containerRef,
viewStartRef,
viewEndRef,
scrollTopRef,
rowHeight,
setViewStartTs,
setViewEndTs,
setScrollTop,
} = args;
useEffect(() => {
if (!firstSpanAtFetchLevel || spans.length === 0) {
return;
}
const result = findSpanById(spans, firstSpanAtFetchLevel);
if (!result) {
return;
}
const { span, levelIndex } = result;
const container = containerRef.current;
if (!container) {
return;
}
const metrics = getFlamegraphRowMetrics(rowHeight);
const viewportHeight = container.clientHeight;
const totalHeight = spans.length * metrics.ROW_HEIGHT;
const maxScroll = Math.max(0, totalHeight - viewportHeight);
// Vertical: center the span's row in the viewport
const targetScrollTop = clamp(
levelIndex * metrics.ROW_HEIGHT -
viewportHeight / 2 +
metrics.ROW_HEIGHT / 2,
0,
maxScroll,
);
// Horizontal: zoom to span with padding (2x span duration), center it
const spanStartMs = span.timestamp;
const spanEndMs = span.timestamp + span.durationNano / 1e6;
const spanDurationMs = spanEndMs - spanStartMs;
const spanCenterMs = (spanStartMs + spanEndMs) / 2;
const visibleWindowMs = Math.max(spanDurationMs * 2, MIN_VISIBLE_SPAN_MS);
const fullSpanMs = traceMetadata.endTime - traceMetadata.startTime;
const clampedWindow = clamp(visibleWindowMs, MIN_VISIBLE_SPAN_MS, fullSpanMs);
let targetViewStart = spanCenterMs - clampedWindow / 2;
let targetViewEnd = spanCenterMs + clampedWindow / 2;
targetViewStart = clamp(
targetViewStart,
traceMetadata.startTime,
traceMetadata.endTime - clampedWindow,
);
targetViewEnd = targetViewStart + clampedWindow;
// Apply immediately (instant jump)
viewStartRef.current = targetViewStart;
viewEndRef.current = targetViewEnd;
scrollTopRef.current = targetScrollTop;
setViewStartTs(targetViewStart);
setViewEndTs(targetViewEnd);
setScrollTop(targetScrollTop);
}, [
firstSpanAtFetchLevel,
spans,
traceMetadata,
containerRef,
viewStartRef,
viewEndRef,
scrollTopRef,
rowHeight,
setViewStartTs,
setViewEndTs,
setScrollTop,
]);
}

View File

@@ -1,98 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { computeVisualLayout, VisualLayout } from '../computeVisualLayout';
import { LayoutWorkerResponse } from '../visualLayoutWorkerTypes';
const EMPTY_LAYOUT: VisualLayout = {
visualRows: [],
spanToVisualRow: {},
connectors: [],
totalVisualRows: 0,
};
function computeLayoutOrEmpty(spans: FlamegraphSpan[][]): VisualLayout {
return spans.length ? computeVisualLayout(spans) : EMPTY_LAYOUT;
}
function createLayoutWorker(): Worker {
return new Worker(new URL('../visualLayout.worker.ts', import.meta.url), {
type: 'module',
});
}
export function useVisualLayoutWorker(
spans: FlamegraphSpan[][],
): { layout: VisualLayout; isComputing: boolean } {
const [layout, setLayout] = useState<VisualLayout>(EMPTY_LAYOUT);
const [isComputing, setIsComputing] = useState(false);
const workerRef = useRef<Worker | null>(null);
const requestIdRef = useRef(0);
const fallbackRef = useRef(typeof Worker === 'undefined');
// Effect: post message to worker when spans change
useEffect(() => {
if (fallbackRef.current) {
setLayout(computeLayoutOrEmpty(spans));
return;
}
if (!workerRef.current) {
try {
workerRef.current = createLayoutWorker();
} catch {
fallbackRef.current = true;
setLayout(computeLayoutOrEmpty(spans));
return;
}
}
if (!spans.length) {
setLayout(EMPTY_LAYOUT);
return;
}
const currentId = ++requestIdRef.current;
setIsComputing(true);
const worker = workerRef.current;
const onMessage = (e: MessageEvent<LayoutWorkerResponse>): void => {
if (e.data.requestId !== requestIdRef.current) {
return;
}
if (e.data.type === 'result') {
setLayout(e.data.layout);
} else {
setLayout(computeVisualLayout(spans));
}
setIsComputing(false);
};
const onError = (): void => {
if (requestIdRef.current === currentId) {
setLayout(computeVisualLayout(spans));
setIsComputing(false);
}
};
worker.addEventListener('message', onMessage);
worker.addEventListener('error', onError);
worker.postMessage({ type: 'compute', requestId: currentId, spans });
return (): void => {
worker.removeEventListener('message', onMessage);
worker.removeEventListener('error', onError);
};
}, [spans]);
// Cleanup worker on unmount
useEffect(
() => (): void => {
workerRef.current?.terminate();
},
[],
);
return { layout, isComputing };
}

View File

@@ -1,32 +0,0 @@
import { Dispatch, SetStateAction } from 'react';
import { Event, FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
export interface ITraceMetadata {
startTime: number;
endTime: number;
}
export interface FlamegraphCanvasProps {
spans: FlamegraphSpan[][];
firstSpanAtFetchLevel: string;
setFirstSpanAtFetchLevel: Dispatch<SetStateAction<string>>;
onSpanClick: (spanId: string) => void;
traceMetadata: ITraceMetadata;
}
export interface SpanRect {
span: FlamegraphSpan;
x: number;
y: number;
width: number;
height: number;
level: number;
}
export interface EventRect {
event: Event;
span: FlamegraphSpan;
cx: number;
cy: number;
halfSize: number;
}

View File

@@ -1,424 +0,0 @@
import { themeColors } from 'constants/theme';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import {
DASHED_BORDER_LINE_DASH,
EVENT_DOT_SIZE_RATIO,
LABEL_FONT,
LABEL_PADDING_X,
MAX_EVENT_DOT_SIZE,
MAX_SPAN_BAR_HEIGHT,
MIN_EVENT_DOT_SIZE,
MIN_SPAN_BAR_HEIGHT,
MIN_WIDTH_FOR_NAME,
MIN_WIDTH_FOR_NAME_AND_DURATION,
SPAN_BAR_HEIGHT_RATIO,
} from './constants';
import { EventRect, SpanRect } from './types';
export function clamp(v: number, min: number, max: number): number {
return Math.max(min, Math.min(max, v));
}
/** Create diagonal stripe pattern for selected/hovered span (repeating-linear-gradient -45deg style). */
function createStripePattern(
ctx: CanvasRenderingContext2D,
color: string,
): CanvasPattern | null {
const size = 20;
const patternCanvas = document.createElement('canvas');
patternCanvas.width = size;
patternCanvas.height = size;
const pCtx = patternCanvas.getContext('2d');
if (!pCtx) {
return null;
}
// Diagonal stripes at -45deg: 10px transparent, 10px colored (0.04 opacity), repeat
pCtx.globalAlpha = 0.04;
pCtx.strokeStyle = color;
pCtx.lineWidth = 10;
pCtx.lineCap = 'butt';
for (let i = -size; i < size * 2; i += size) {
pCtx.beginPath();
pCtx.moveTo(i + size, 0);
pCtx.lineTo(i, size);
pCtx.stroke();
}
pCtx.globalAlpha = 1;
return ctx.createPattern(patternCanvas, 'repeat');
}
export function findSpanById(
spans: FlamegraphSpan[][],
spanId: string,
): { span: FlamegraphSpan; levelIndex: number } | null {
for (let levelIndex = 0; levelIndex < spans.length; levelIndex++) {
const span = spans[levelIndex]?.find((s) => s.spanId === spanId);
if (span) {
return { span, levelIndex };
}
}
return null;
}
export interface FlamegraphRowMetrics {
ROW_HEIGHT: number;
SPAN_BAR_HEIGHT: number;
SPAN_BAR_Y_OFFSET: number;
EVENT_DOT_SIZE: number;
}
export function getFlamegraphRowMetrics(
rowHeight: number,
): FlamegraphRowMetrics {
const spanBarHeight = clamp(
Math.round(rowHeight * SPAN_BAR_HEIGHT_RATIO),
MIN_SPAN_BAR_HEIGHT,
MAX_SPAN_BAR_HEIGHT,
);
const spanBarYOffset = Math.floor((rowHeight - spanBarHeight) / 2);
const eventDotSize = clamp(
Math.round(spanBarHeight * EVENT_DOT_SIZE_RATIO),
MIN_EVENT_DOT_SIZE,
MAX_EVENT_DOT_SIZE,
);
return {
ROW_HEIGHT: rowHeight,
SPAN_BAR_HEIGHT: spanBarHeight,
SPAN_BAR_Y_OFFSET: spanBarYOffset,
EVENT_DOT_SIZE: eventDotSize,
};
}
interface GetSpanColorArgs {
span: FlamegraphSpan;
isDarkMode: boolean;
}
export function getSpanColor(args: GetSpanColorArgs): string {
const { span, isDarkMode } = args;
let color = generateColor(span.serviceName, themeColors.traceDetailColorsV3);
if (span.hasError) {
color = isDarkMode ? 'rgb(239, 68, 68)' : 'rgb(220, 38, 38)';
}
return color;
}
export interface EventDotColor {
fill: string;
stroke: string;
}
/** Derive event dot colors from parent span color. Error events always use red. */
export function getEventDotColor(
spanColor: string,
isError: boolean,
isDarkMode: boolean,
): EventDotColor {
if (isError) {
return {
fill: isDarkMode ? 'rgb(239, 68, 68)' : 'rgb(220, 38, 38)',
stroke: isDarkMode ? 'rgb(185, 28, 28)' : 'rgb(153, 27, 27)',
};
}
// Parse the span color (hex or rgb) to darken it for the event dot
let r: number | undefined;
let g: number | undefined;
let b: number | undefined;
const rgbMatch = spanColor.match(
/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+)?\s*\)/,
);
const hexMatch = spanColor.match(
/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i,
);
if (rgbMatch) {
r = parseInt(rgbMatch[1], 10);
g = parseInt(rgbMatch[2], 10);
b = parseInt(rgbMatch[3], 10);
} else if (hexMatch) {
r = parseInt(hexMatch[1], 16);
g = parseInt(hexMatch[2], 16);
b = parseInt(hexMatch[3], 16);
}
if (r !== undefined && g !== undefined && b !== undefined) {
// Darken by 20% for fill, 40% for stroke
const darken = (v: number, factor: number): number =>
Math.round(v * (1 - factor));
return {
fill: `rgb(${darken(r, 0.2)}, ${darken(g, 0.2)}, ${darken(b, 0.2)})`,
stroke: `rgb(${darken(r, 0.4)}, ${darken(g, 0.4)}, ${darken(b, 0.4)})`,
};
}
// Fallback to original cyan/blue
return {
fill: isDarkMode ? 'rgb(14, 165, 233)' : 'rgb(6, 182, 212)',
stroke: isDarkMode ? 'rgb(2, 132, 199)' : 'rgb(8, 145, 178)',
};
}
interface DrawEventDotArgs {
ctx: CanvasRenderingContext2D;
x: number;
y: number;
color: EventDotColor;
eventDotSize: number;
}
export function drawEventDot(args: DrawEventDotArgs): void {
const { ctx, x, y, color, eventDotSize } = args;
ctx.save();
ctx.translate(x, y);
ctx.rotate(Math.PI / 4);
ctx.fillStyle = color.fill;
ctx.strokeStyle = color.stroke;
ctx.lineWidth = 1;
const half = eventDotSize / 2;
ctx.fillRect(-half, -half, eventDotSize, eventDotSize);
ctx.strokeRect(-half, -half, eventDotSize, eventDotSize);
ctx.restore();
}
interface DrawSpanBarArgs {
ctx: CanvasRenderingContext2D;
span: FlamegraphSpan;
x: number;
y: number;
width: number;
levelIndex: number;
spanRectsArray: SpanRect[];
eventRectsArray: EventRect[];
color: string;
isDarkMode: boolean;
metrics: FlamegraphRowMetrics;
selectedSpanId?: string | null;
hoveredSpanId?: string | null;
hoveredEventKey?: string | null;
}
export function drawSpanBar(args: DrawSpanBarArgs): void {
const {
ctx,
span,
x,
y,
width,
levelIndex,
spanRectsArray,
eventRectsArray,
color,
isDarkMode,
metrics,
selectedSpanId,
hoveredSpanId,
hoveredEventKey,
} = args;
const spanY = y + metrics.SPAN_BAR_Y_OFFSET;
const isSelected = selectedSpanId === span.spanId;
const isHovered = hoveredSpanId === span.spanId;
const isSelectedOrHovered = isSelected || isHovered;
ctx.beginPath();
ctx.roundRect(x, spanY, width, metrics.SPAN_BAR_HEIGHT, 2);
if (isSelectedOrHovered) {
// Diagonal stripe pattern (repeating-linear-gradient -45deg style) + border in span color
const pattern = createStripePattern(ctx, color);
if (pattern) {
ctx.fillStyle = pattern;
ctx.fill();
}
if (isSelected) {
ctx.setLineDash(DASHED_BORDER_LINE_DASH);
}
ctx.strokeStyle = color;
ctx.lineWidth = isSelected ? 2 : 1;
ctx.stroke();
if (isSelected) {
ctx.setLineDash([]);
}
} else {
ctx.fillStyle = color;
ctx.fill();
}
spanRectsArray.push({
span,
x,
y: spanY,
width,
height: metrics.SPAN_BAR_HEIGHT,
level: levelIndex,
});
span.event?.forEach((event) => {
const spanDurationMs = span.durationNano / 1e6;
if (spanDurationMs <= 0) {
return;
}
const eventTimeMs = event.timeUnixNano / 1e6;
const eventOffsetPercent =
((eventTimeMs - span.timestamp) / spanDurationMs) * 100;
const clampedOffset = clamp(eventOffsetPercent, 1, 99);
const eventX = x + (clampedOffset / 100) * width;
const eventY = spanY + metrics.SPAN_BAR_HEIGHT / 2;
const dotColor = getEventDotColor(color, event.isError, isDarkMode);
const eventKey = `${span.spanId}-${event.name}-${event.timeUnixNano}`;
const isEventHovered = hoveredEventKey === eventKey;
const dotSize = isEventHovered
? Math.round(metrics.EVENT_DOT_SIZE * 1.5)
: metrics.EVENT_DOT_SIZE;
drawEventDot({
ctx,
x: eventX,
y: eventY,
color: dotColor,
eventDotSize: dotSize,
});
eventRectsArray.push({
event,
span,
cx: eventX,
cy: eventY,
halfSize: metrics.EVENT_DOT_SIZE / 2,
});
});
drawSpanLabel({
ctx,
span,
x,
y: spanY,
width,
color,
isSelectedOrHovered,
isDarkMode,
spanBarHeight: metrics.SPAN_BAR_HEIGHT,
});
}
export function formatDuration(durationNano: number): string {
const durationMs = durationNano / 1e6;
const { time, timeUnitName } = convertTimeToRelevantUnit(durationMs);
return `${parseFloat(time.toFixed(2))}${timeUnitName}`;
}
interface DrawSpanLabelArgs {
ctx: CanvasRenderingContext2D;
span: FlamegraphSpan;
x: number;
y: number;
width: number;
color: string;
isSelectedOrHovered: boolean;
isDarkMode: boolean;
spanBarHeight: number;
}
function drawSpanLabel(args: DrawSpanLabelArgs): void {
const {
ctx,
span,
x,
y,
width,
color,
isSelectedOrHovered,
isDarkMode,
spanBarHeight,
} = args;
if (width < MIN_WIDTH_FOR_NAME) {
return;
}
const name = span.name;
ctx.save();
// Clip text to span bar bounds
ctx.beginPath();
ctx.rect(x, y, width, spanBarHeight);
ctx.clip();
ctx.font = LABEL_FONT;
ctx.fillStyle = isSelectedOrHovered
? color
: isDarkMode
? 'rgba(0, 0, 0, 0.9)'
: 'rgba(255, 255, 255, 0.9)';
ctx.textBaseline = 'middle';
const textY = y + spanBarHeight / 2;
const leftX = x + LABEL_PADDING_X;
const rightX = x + width - LABEL_PADDING_X;
const availableWidth = width - LABEL_PADDING_X * 2;
if (width >= MIN_WIDTH_FOR_NAME_AND_DURATION) {
const duration = formatDuration(span.durationNano);
const durationWidth = ctx.measureText(duration).width;
const minGap = 6;
const nameSpace = availableWidth - durationWidth - minGap;
// Duration right-aligned
ctx.textAlign = 'right';
ctx.fillText(duration, rightX, textY);
// Name left-aligned, truncated to fit remaining space
if (nameSpace > 20) {
ctx.textAlign = 'left';
ctx.fillText(truncateText(ctx, name, nameSpace), leftX, textY);
}
} else {
// Name only, truncated to fit
ctx.textAlign = 'left';
ctx.fillText(truncateText(ctx, name, availableWidth), leftX, textY);
}
ctx.restore();
}
function truncateText(
ctx: CanvasRenderingContext2D,
text: string,
maxWidth: number,
): string {
const ellipsis = '...';
const ellipsisWidth = ctx.measureText(ellipsis).width;
if (ctx.measureText(text).width <= maxWidth) {
return text;
}
let lo = 0;
let hi = text.length;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (ctx.measureText(text.slice(0, mid)).width + ellipsisWidth <= maxWidth) {
lo = mid;
} else {
hi = mid - 1;
}
}
return lo > 0 ? `${text.slice(0, lo)}${ellipsis}` : ellipsis;
}

View File

@@ -1,26 +0,0 @@
/// <reference lib="webworker" />
import { computeVisualLayout } from './computeVisualLayout';
import {
LayoutWorkerRequest,
LayoutWorkerResponse,
} from './visualLayoutWorkerTypes';
self.onmessage = (event: MessageEvent<LayoutWorkerRequest>): void => {
const { requestId, spans } = event.data;
try {
const layout = computeVisualLayout(spans);
const response: LayoutWorkerResponse = {
type: 'result',
requestId,
layout,
};
self.postMessage(response);
} catch (err) {
const response: LayoutWorkerResponse = {
type: 'error',
requestId,
message: String(err),
};
self.postMessage(response);
}
};

View File

@@ -1,13 +0,0 @@
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { VisualLayout } from './computeVisualLayout';
export interface LayoutWorkerRequest {
type: 'compute';
requestId: number;
spans: FlamegraphSpan[][];
}
export type LayoutWorkerResponse =
| { type: 'result'; requestId: number; layout: VisualLayout }
| { type: 'error'; requestId: number; message: string };

View File

@@ -1,297 +0,0 @@
// Modal base styles
.add-span-to-funnel-modal {
&__loading-spinner {
display: flex;
align-items: center;
justify-content: center;
height: 400px;
}
&-container {
.ant-modal {
&-content,
&-header {
background: var(--bg-ink-500);
}
&-header {
border-bottom: none;
.ant-modal-title {
color: var(--bg-vanilla-100);
}
}
&-body {
padding: 14px 16px !important;
padding-bottom: 0 !important;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
&-footer {
margin-top: 0;
background: var(--bg-ink-400);
border-top: 1px solid var(--bg-slate-500);
padding: 16px !important;
.add-span-to-funnel-modal {
&__save-button {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
color: var(--bg-vanilla-100);
font-size: 12px;
font-weight: 500;
line-height: 24px;
width: 135px;
.ant-btn-icon {
display: flex;
}
&:disabled {
color: var(--bg-vanilla-400);
.ant-btn-icon {
svg {
stroke: var(--bg-vanilla-400);
}
}
}
}
&__discard-button {
background: var(--bg-slate-500);
}
}
.ant-btn {
border-radius: 2px;
padding: 4px 8px;
margin: 0 !important;
border: none;
box-shadow: none;
}
}
}
}
}
// Main modal styles
.add-span-to-funnel-modal {
// Common button styles
%button-base {
display: flex;
align-items: center;
font-family: Inter;
}
// Details view styles
&--details {
.traces-funnel-details {
height: unset;
&__steps-config {
width: unset;
border: none;
}
.funnel-step-wrapper {
gap: 15px;
}
.steps-content {
max-height: 500px;
}
}
}
// Search section
&__search {
display: flex;
gap: 12px;
margin-bottom: 14px;
align-items: center;
&-input {
flex: 1;
padding: 6px 8px;
background: var(--bg-ink-300);
.ant-input-prefix {
height: 18px;
margin-inline-end: 6px;
svg {
opacity: 0.4;
}
}
&,
input {
font-family: Inter;
font-size: 14px;
line-height: 18px;
letter-spacing: -0.07px;
font-weight: 400;
background: var(--bg-ink-300);
}
input::placeholder {
color: var(--bg-vanilla-400);
opacity: 0.4;
}
}
}
// Create button
&__create-button {
@extend %button-base;
width: 153px;
padding: 4px 8px;
justify-content: center;
gap: 4px;
flex-shrink: 0;
border-radius: 2px;
background: var(--bg-slate-500);
border: none;
box-shadow: none;
color: var(--bg-vanilla-400);
font-size: 12px;
font-weight: 500;
line-height: 24px;
}
.funnel-item {
padding: 8px 8px 12px 16px;
&,
&:first-child {
border-radius: 6px;
}
&__header {
line-height: 20px;
}
&__details {
line-height: 18px;
}
}
// List section
&__list {
max-height: 400px;
overflow-y: scroll;
.funnels-empty {
&__content {
padding: 0;
}
}
.funnels-list {
gap: 8px;
.funnel-item {
padding: 8px 16px 12px;
&__details {
margin-top: 8px;
}
}
}
}
&__spinner {
height: 400px;
}
// Back button
&__back-button {
@extend %button-base;
gap: 6px;
color: var(--bg-vanilla-400);
font-size: 14px;
line-height: 20px;
margin-bottom: 14px;
}
// Details section
&__details {
display: flex;
flex-direction: column;
gap: 24px;
.funnel-configuration__steps {
padding: 0;
.funnel-step {
&__content .filters__service-and-span .ant-select {
width: 170px;
}
&__footer .error {
width: 25%;
}
}
.inter-step-config {
width: calc(100% - 104px);
}
}
.funnel-item__actions-popover {
display: none;
}
}
}
// Light mode styles
.lightMode {
.add-span-to-funnel-modal-container {
.ant-modal {
&-content,
&-header {
background: var(--bg-vanilla-100);
}
&-title {
color: var(--bg-ink-500);
}
&-footer {
border-top-color: var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
.add-span-to-funnel-modal__discard-button {
background: var(--bg-vanilla-200);
color: var(--bg-ink-500);
}
}
}
}
.add-span-to-funnel-modal {
&__search-input {
background: var(--bg-vanilla-100);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-500);
input {
color: var(--bg-ink-500);
background: var(--bg-vanilla-100);
&::placeholder {
color: var(--bg-ink-400);
}
}
}
&__create-button {
background: var(--bg-vanilla-300);
color: var(--bg-ink-400);
}
&__back-button {
color: var(--bg-ink-500);
&:hover {
color: var(--bg-ink-400);
}
}
&__details h3 {
color: var(--bg-ink-500);
}
}
}

View File

@@ -1,294 +0,0 @@
import { ChangeEvent, useEffect, useMemo, useState } from 'react';
import { LoadingOutlined } from '@ant-design/icons';
import { Button, Input, Spin } from 'antd';
import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import SignozModal from 'components/SignozModal/SignozModal';
import {
useFunnelDetails,
useFunnelsList,
} from 'hooks/TracesFunnels/useFunnels';
import { isEqual } from 'lodash-es';
import { ArrowLeft, Check, Plus, Search } from 'lucide-react';
import FunnelConfiguration from 'pages/TracesFunnelDetails/components/FunnelConfiguration/FunnelConfiguration';
import { TracesFunnelsContentRenderer } from 'pages/TracesFunnels';
import CreateFunnel from 'pages/TracesFunnels/components/CreateFunnel/CreateFunnel';
import { FunnelListItem } from 'pages/TracesFunnels/components/FunnelsList/FunnelsList';
import {
FunnelProvider,
useFunnelContext,
} from 'pages/TracesFunnels/FunnelContext';
import { filterFunnelsByQuery } from 'pages/TracesFunnels/utils';
import { Span } from 'types/api/trace/getTraceV2';
import { FunnelData } from 'types/api/traceFunnels';
import './AddSpanToFunnelModal.styles.scss';
enum ModalView {
LIST = 'list',
DETAILS = 'details',
}
function FunnelDetailsView({
funnel,
span,
triggerAutoSave,
showNotifications,
onChangesDetected,
triggerDiscard,
}: {
funnel: FunnelData;
span: Span;
triggerAutoSave: boolean;
showNotifications: boolean;
onChangesDetected: (hasChanges: boolean) => void;
triggerDiscard: boolean;
}): JSX.Element {
const { handleRestoreSteps, steps } = useFunnelContext();
// Track changes between current steps and original steps
useEffect(() => {
const hasChanges = !isEqual(steps, funnel.steps);
if (onChangesDetected) {
onChangesDetected(hasChanges);
}
}, [steps, funnel.steps, onChangesDetected]);
// Handle discard when triggered from parent
useEffect(() => {
if (triggerDiscard && funnel.steps) {
handleRestoreSteps(funnel.steps);
}
}, [triggerDiscard, funnel.steps, handleRestoreSteps]);
return (
<div className="add-span-to-funnel-modal__details">
<FunnelListItem
funnel={funnel}
shouldRedirectToTracesListOnDeleteSuccess={false}
isSpanDetailsPage
/>
<FunnelConfiguration
funnel={funnel}
isTraceDetailsPage
span={span}
triggerAutoSave={triggerAutoSave}
showNotifications={showNotifications}
/>
</div>
);
}
interface AddSpanToFunnelModalProps {
isOpen: boolean;
onClose: () => void;
span: Span;
}
function AddSpanToFunnelModal({
isOpen,
onClose,
span,
}: AddSpanToFunnelModalProps): JSX.Element {
const [activeView, setActiveView] = useState<ModalView>(ModalView.LIST);
const [searchQuery, setSearchQuery] = useState<string>('');
const [selectedFunnelId, setSelectedFunnelId] = useState<string | undefined>(
undefined,
);
const [isCreateModalOpen, setIsCreateModalOpen] = useState<boolean>(false);
const [triggerSave, setTriggerSave] = useState<boolean>(false);
const [isUnsavedChanges, setIsUnsavedChanges] = useState<boolean>(false);
const [triggerDiscard, setTriggerDiscard] = useState<boolean>(false);
const [isCreatedFromSpan, setIsCreatedFromSpan] = useState<boolean>(false);
const handleSearch = (e: ChangeEvent<HTMLInputElement>): void => {
setSearchQuery(e.target.value);
};
const { data, isLoading, isError, isFetching } = useFunnelsList();
const filteredData = useMemo(
() =>
filterFunnelsByQuery(data?.payload || [], searchQuery).sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
),
[data?.payload, searchQuery],
);
const {
data: funnelDetails,
isLoading: isFunnelDetailsLoading,
isFetching: isFunnelDetailsFetching,
} = useFunnelDetails({
funnelId: selectedFunnelId,
});
const handleFunnelClick = (funnel: FunnelData): void => {
setSelectedFunnelId(funnel.funnel_id);
setActiveView(ModalView.DETAILS);
setIsCreatedFromSpan(false);
};
const handleBack = (): void => {
setActiveView(ModalView.LIST);
setSelectedFunnelId(undefined);
setIsUnsavedChanges(false);
setTriggerSave(false);
setIsCreatedFromSpan(false);
};
const handleCreateNewClick = (): void => {
setIsCreateModalOpen(true);
};
const handleSaveFunnel = (): void => {
setTriggerSave(true);
// Reset trigger after a brief moment to allow the save to be processed
setTimeout(() => {
setTriggerSave(false);
onClose();
}, 100);
};
const handleDiscard = (): void => {
setTriggerDiscard(true);
// Reset trigger after a brief moment
setTimeout(() => {
setTriggerDiscard(false);
onClose();
}, 100);
};
const renderListView = (): JSX.Element => (
<div className="add-span-to-funnel-modal">
{!!filteredData?.length && (
<div className="add-span-to-funnel-modal__search">
<Input
className="add-span-to-funnel-modal__search-input"
placeholder="Search by name, description, or tags..."
prefix={<Search size={12} />}
value={searchQuery}
onChange={handleSearch}
/>
</div>
)}
<div className="add-span-to-funnel-modal__list">
<OverlayScrollbar>
<TracesFunnelsContentRenderer
isError={isError}
isLoading={isLoading || isFetching}
data={filteredData || []}
onCreateFunnel={handleCreateNewClick}
onFunnelClick={(funnel: FunnelData): void => handleFunnelClick(funnel)}
shouldRedirectToTracesListOnDeleteSuccess={false}
/>
</OverlayScrollbar>
</div>
<CreateFunnel
isOpen={isCreateModalOpen}
onClose={(funnelId): void => {
if (funnelId) {
setSelectedFunnelId(funnelId);
setActiveView(ModalView.DETAILS);
setIsCreatedFromSpan(true);
}
setIsCreateModalOpen(false);
}}
redirectToDetails={false}
/>
</div>
);
const renderDetailsView = ({ span }: { span: Span }): JSX.Element => (
<div className="add-span-to-funnel-modal add-span-to-funnel-modal--details">
<Button
type="text"
className="add-span-to-funnel-modal__back-button"
onClick={handleBack}
>
<ArrowLeft size={14} />
All funnels
</Button>
<div className="traces-funnel-details">
<div className="traces-funnel-details__steps-config">
<Spin
className="add-span-to-funnel-modal__loading-spinner"
spinning={isFunnelDetailsLoading || isFunnelDetailsFetching}
indicator={<LoadingOutlined spin />}
>
{selectedFunnelId && funnelDetails?.payload && (
<FunnelProvider
funnelId={selectedFunnelId}
hasSingleStep={isCreatedFromSpan}
>
<FunnelDetailsView
funnel={funnelDetails.payload}
span={span}
triggerAutoSave={triggerSave}
showNotifications
onChangesDetected={setIsUnsavedChanges}
triggerDiscard={triggerDiscard}
/>
</FunnelProvider>
)}
</Spin>
</div>
</div>
</div>
);
return (
<SignozModal
open={isOpen}
onCancel={onClose}
width={570}
title="Add span to funnel"
className={cx('add-span-to-funnel-modal-container', {
'add-span-to-funnel-modal-container--details':
activeView === ModalView.DETAILS,
})}
footer={
activeView === ModalView.DETAILS
? [
<Button
type="default"
key="discard"
onClick={handleDiscard}
className="add-span-to-funnel-modal__discard-button"
disabled={!isUnsavedChanges}
>
Discard
</Button>,
<Button
key="save"
type="primary"
className="add-span-to-funnel-modal__save-button"
onClick={handleSaveFunnel}
disabled={!isUnsavedChanges}
icon={<Check size={14} color="var(--bg-vanilla-100)" />}
>
Save Funnel
</Button>,
]
: [
<Button
key="create"
type="default"
className="add-span-to-funnel-modal__create-button"
onClick={handleCreateNewClick}
icon={<Plus size={14} />}
>
Create new funnel
</Button>,
]
}
>
{activeView === ModalView.LIST
? renderListView()
: renderDetailsView({ span })}
</SignozModal>
);
}
export default AddSpanToFunnelModal;

View File

@@ -1,39 +0,0 @@
.span-line-action-buttons {
display: flex;
position: absolute;
transform: translate(-50%, -50%);
top: 50%;
right: 0;
cursor: pointer;
border-radius: 2px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-400);
.ant-btn-default {
border: none;
box-shadow: none;
padding: 9px;
justify-content: center;
align-items: center;
display: flex;
&.active-tab {
background-color: var(--bg-slate-400);
}
}
.copy-span-btn {
border-color: var(--bg-slate-400) !important;
}
}
.lightMode {
.span-line-action-buttons {
border: 1px solid var(--bg-vanilla-400);
background: var(--bg-vanilla-400);
.copy-span-btn {
border-color: var(--bg-vanilla-400) !important;
}
}
}

View File

@@ -1,134 +0,0 @@
import { fireEvent, screen } from '@testing-library/react';
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
import { render } from 'tests/test-utils';
import { Span } from 'types/api/trace/getTraceV2';
import SpanLineActionButtons from '../index';
// Mock the useCopySpanLink hook
jest.mock('hooks/trace/useCopySpanLink');
const mockSpan: Span = {
spanId: 'test-span-id',
name: 'test-span',
serviceName: 'test-service',
durationNano: 1000,
timestamp: 1234567890,
rootSpanId: 'test-root-span-id',
parentSpanId: 'test-parent-span-id',
traceId: 'test-trace-id',
hasError: false,
kind: 0,
references: [],
tagMap: {},
event: [],
rootName: 'test-root-name',
statusMessage: 'test-status-message',
statusCodeString: 'test-status-code-string',
spanKind: 'test-span-kind',
hasChildren: false,
hasSibling: false,
subTreeNodeCount: 0,
level: 0,
};
describe('SpanLineActionButtons', () => {
beforeEach(() => {
// Clear mock before each test
jest.clearAllMocks();
});
it('renders copy link button with correct icon', () => {
(useCopySpanLink as jest.Mock).mockReturnValue({
onSpanCopy: jest.fn(),
});
render(<SpanLineActionButtons span={mockSpan} />);
// Check if the button is rendered
const copyButton = screen.getByRole('button');
expect(copyButton).toBeInTheDocument();
// Check if the link icon is rendered
const linkIcon = screen.getByRole('img', { hidden: true });
expect(linkIcon).toHaveClass('anticon anticon-link');
});
it('calls onSpanCopy when copy button is clicked', () => {
const mockOnSpanCopy = jest.fn();
(useCopySpanLink as jest.Mock).mockReturnValue({
onSpanCopy: mockOnSpanCopy,
});
render(<SpanLineActionButtons span={mockSpan} />);
// Click the copy button
const copyButton = screen.getByRole('button');
fireEvent.click(copyButton);
// Verify the copy function was called
expect(mockOnSpanCopy).toHaveBeenCalledTimes(1);
});
it('applies correct styling classes', () => {
(useCopySpanLink as jest.Mock).mockReturnValue({
onSpanCopy: jest.fn(),
});
render(<SpanLineActionButtons span={mockSpan} />);
// Check if the main container has the correct class
const container = screen
.getByRole('button')
.closest('.span-line-action-buttons');
expect(container).toHaveClass('span-line-action-buttons');
// Check if the button has the correct class
const copyButton = screen.getByRole('button');
expect(copyButton).toHaveClass('copy-span-btn');
});
it('copies span link to clipboard when copy button is clicked', () => {
const mockSetCopy = jest.fn();
const mockUrlQuery = {
delete: jest.fn(),
set: jest.fn(),
toString: jest.fn().mockReturnValue('spanId=test-span-id'),
};
const mockPathname = '/test-path';
const mockLocation = {
origin: 'http://localhost:3000',
};
// Mock window.location
Object.defineProperty(window, 'location', {
value: mockLocation,
writable: true,
});
// Mock useCopySpanLink hook
(useCopySpanLink as jest.Mock).mockReturnValue({
onSpanCopy: (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
mockUrlQuery.delete('spanId');
mockUrlQuery.set('spanId', mockSpan.spanId);
const link = `${
window.location.origin
}${mockPathname}?${mockUrlQuery.toString()}`;
mockSetCopy(link);
},
});
render(<SpanLineActionButtons span={mockSpan} />);
// Click the copy button
const copyButton = screen.getByRole('button');
fireEvent.click(copyButton);
// Verify the copy function was called with correct link
expect(mockSetCopy).toHaveBeenCalledWith(
'http://localhost:3000/test-path?spanId=test-span-id',
);
});
});

View File

@@ -1,28 +0,0 @@
import { LinkOutlined } from '@ant-design/icons';
import { Button, Tooltip } from 'antd';
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
import { Span } from 'types/api/trace/getTraceV2';
import './SpanLineActionButtons.styles.scss';
export interface SpanLineActionButtonsProps {
span: Span;
}
export default function SpanLineActionButtons({
span,
}: SpanLineActionButtonsProps): JSX.Element {
const { onSpanCopy } = useCopySpanLink(span);
return (
<div className="span-line-action-buttons">
<Tooltip title="Copy Span Link">
<Button
size="small"
icon={<LinkOutlined size={14} />}
onClick={onSpanCopy}
className="copy-span-btn"
/>
</Tooltip>
</div>
);
}

View File

@@ -1,11 +0,0 @@
.trace-waterfall {
height: 100%;
display: flex;
flex-direction: column;
.loading-skeleton {
justify-content: center;
align-items: center;
padding: 20px;
}
}

View File

@@ -1,136 +0,0 @@
import { Dispatch, SetStateAction, useMemo } from 'react';
import { Skeleton } from 'antd';
import { AxiosError } from 'axios';
import Spinner from 'components/Spinner';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { GetTraceV2SuccessResponse, Span } from 'types/api/trace/getTraceV2';
import { TraceWaterfallStates } from './constants';
import Error from './TraceWaterfallStates/Error/Error';
import NoData from './TraceWaterfallStates/NoData/NoData';
import Success from './TraceWaterfallStates/Success/Success';
import './TraceWaterfall.styles.scss';
export interface IInterestedSpan {
spanId: string;
isUncollapsed: boolean;
}
interface ITraceWaterfallProps {
traceId: string;
uncollapsedNodes: string[];
traceData:
| SuccessResponse<GetTraceV2SuccessResponse, unknown>
| ErrorResponse
| undefined;
isFetchingTraceData: boolean;
errorFetchingTraceData: unknown;
interestedSpanId: IInterestedSpan;
setInterestedSpanId: Dispatch<SetStateAction<IInterestedSpan>>;
selectedSpan: Span | undefined;
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
}
function TraceWaterfall(props: ITraceWaterfallProps): JSX.Element {
const {
traceData,
isFetchingTraceData,
errorFetchingTraceData,
interestedSpanId,
traceId,
uncollapsedNodes,
setInterestedSpanId,
setSelectedSpan,
selectedSpan,
} = props;
// get the current state of trace waterfall based on the API lifecycle
const traceWaterfallState = useMemo(() => {
if (isFetchingTraceData) {
if (
traceData &&
traceData.payload &&
traceData.payload.spans &&
traceData.payload.spans.length > 0
) {
return TraceWaterfallStates.FETCHING_WITH_OLD_DATA_PRESENT;
}
return TraceWaterfallStates.LOADING;
}
if (errorFetchingTraceData) {
return TraceWaterfallStates.ERROR;
}
if (
traceData &&
traceData.payload &&
traceData.payload.spans &&
traceData.payload.spans.length === 0
) {
return TraceWaterfallStates.NO_DATA;
}
return TraceWaterfallStates.SUCCESS;
}, [errorFetchingTraceData, isFetchingTraceData, traceData]);
// capture the spans from the response, since we do not need to do any manipulation on the same we will keep this as a simple constant [ memoized ]
const spans = useMemo(() => traceData?.payload?.spans || [], [
traceData?.payload?.spans,
]);
// get the content based on the current state of the trace waterfall
const getContent = useMemo(() => {
switch (traceWaterfallState) {
case TraceWaterfallStates.LOADING:
return (
<div className="loading-skeleton">
<Skeleton active paragraph={{ rows: 6 }} />
</div>
);
case TraceWaterfallStates.ERROR:
return <Error error={errorFetchingTraceData as AxiosError} />;
case TraceWaterfallStates.NO_DATA:
return <NoData id={traceId} />;
case TraceWaterfallStates.SUCCESS:
case TraceWaterfallStates.FETCHING_WITH_OLD_DATA_PRESENT:
return (
<Success
spans={spans}
traceMetadata={{
traceId,
startTime: traceData?.payload?.startTimestampMillis || 0,
endTime: traceData?.payload?.endTimestampMillis || 0,
hasMissingSpans: traceData?.payload?.hasMissingSpans || false,
}}
interestedSpanId={interestedSpanId || ''}
uncollapsedNodes={uncollapsedNodes}
setInterestedSpanId={setInterestedSpanId}
selectedSpan={selectedSpan}
setSelectedSpan={setSelectedSpan}
isFetching={
traceWaterfallState ===
TraceWaterfallStates.FETCHING_WITH_OLD_DATA_PRESENT
}
/>
);
default:
return <Spinner tip="Fetching the trace!" />;
}
}, [
errorFetchingTraceData,
interestedSpanId,
selectedSpan,
setInterestedSpanId,
setSelectedSpan,
spans,
traceData?.payload?.endTimestampMillis,
traceData?.payload?.hasMissingSpans,
traceData?.payload?.startTimestampMillis,
traceId,
traceWaterfallState,
uncollapsedNodes,
]);
return <div className="trace-waterfall">{getContent}</div>;
}
export default TraceWaterfall;

View File

@@ -1,30 +0,0 @@
.error-waterfall {
display: flex;
padding: 12px;
margin: 20px;
gap: 12px;
align-items: flex-start;
border-radius: 4px;
background: var(--bg-cherry-500);
.text {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
flex-shrink: 0;
}
.value {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}

View File

@@ -1,25 +0,0 @@
import { Tooltip, Typography } from 'antd';
import { AxiosError } from 'axios';
import './Error.styles.scss';
interface IErrorProps {
error: AxiosError;
}
function Error(props: IErrorProps): JSX.Element {
const { error } = props;
return (
<div className="error-waterfall">
<Typography.Text className="text">Something went wrong!</Typography.Text>
<Tooltip title={error?.message}>
<Typography.Text className="value" ellipsis>
{error?.message}
</Typography.Text>
</Tooltip>
</div>
);
}
export default Error;

View File

@@ -1,12 +0,0 @@
import { Typography } from 'antd';
interface INoDataProps {
id: string;
}
function NoData(props: INoDataProps): JSX.Element {
const { id } = props;
return <Typography.Text>No Trace found with the id: {id} </Typography.Text>;
}
export default NoData;

View File

@@ -1,60 +0,0 @@
.filter-row {
display: flex;
align-items: center;
padding: 16px 20px 0px 20px;
gap: 12px;
.query-builder-search-v2 {
width: 100%;
}
.pre-next-toggle {
display: flex;
flex-shrink: 0;
gap: 12px;
.ant-typography {
display: flex;
align-items: center;
color: var(--bg-vanilla-400);
font-family: 'Geist Mono';
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
}
.ant-btn {
display: flex;
align-items: center;
justify-content: center;
box-shadow: none;
}
}
.no-results {
display: flex;
align-items: center;
flex-shrink: 0;
color: var(--bg-vanilla-400);
font-family: 'Geist Mono';
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
}
}
.lightMode {
.filter-row {
.pre-next-toggle {
.ant-typography {
color: var(--bg-ink-400);
}
}
.no-results {
color: var(--bg-ink-400);
}
}
}

View File

@@ -1,207 +0,0 @@
import { useCallback, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { InfoCircleOutlined, LoadingOutlined } from '@ant-design/icons';
import { Button, Spin, Tooltip, Typography } from 'antd';
import { AxiosError } from 'axios';
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { uniqBy } from 'lodash-es';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { TracesAggregatorOperator } from 'types/common/queryBuilder';
import { BASE_FILTER_QUERY } from './constants';
import './Filters.styles.scss';
function prepareQuery(filters: TagFilter, traceID: string): Query {
return {
...initialQueriesMap.traces,
builder: {
...initialQueriesMap.traces.builder,
queryData: [
{
...initialQueriesMap.traces.builder.queryData[0],
aggregateOperator: TracesAggregatorOperator.NOOP,
orderBy: [{ columnName: 'timestamp', order: 'asc' }],
filters: {
...filters,
items: [
...filters.items,
{
id: '5ab8e1cf',
key: {
key: 'trace_id',
dataType: DataTypes.String,
type: '',
id: 'trace_id--string----true',
},
op: '=',
value: traceID,
},
],
},
},
],
},
};
}
function Filters({
startTime,
endTime,
traceID,
onFilteredSpansChange = (): void => {},
}: {
startTime: number;
endTime: number;
traceID: string;
onFilteredSpansChange?: (spanIds: string[], isFilterActive: boolean) => void;
}): JSX.Element {
const [filters, setFilters] = useState<TagFilter>(
BASE_FILTER_QUERY.filters || { items: [], op: 'AND' },
);
const [noData, setNoData] = useState<boolean>(false);
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
const [currentSearchedIndex, setCurrentSearchedIndex] = useState<number>(0);
const handleFilterChange = useCallback(
(value: TagFilter): void => {
if (value.items.length === 0) {
setFilteredSpanIds([]);
onFilteredSpansChange?.([], false);
setCurrentSearchedIndex(0);
setNoData(false);
}
setFilters(value);
},
[onFilteredSpansChange],
);
const { search } = useLocation();
const history = useHistory();
const handlePrevNext = useCallback(
(index: number, spanId?: string): void => {
const searchParams = new URLSearchParams(search);
if (spanId) {
searchParams.set('spanId', spanId);
} else {
searchParams.set('spanId', filteredSpanIds[index]);
}
history.replace({ search: searchParams.toString() });
},
[filteredSpanIds, history, search],
);
const { isFetching, error } = useGetQueryRange(
{
query: prepareQuery(filters, traceID),
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
start: startTime,
end: endTime,
params: {
dataSource: 'traces',
},
tableParams: {
pagination: {
offset: 0,
limit: 200,
},
selectColumns: [
{
key: 'name',
dataType: 'string',
type: 'tag',
id: 'name--string--tag--true',
isIndexed: false,
},
],
},
},
DEFAULT_ENTITY_VERSION,
{
queryKey: [filters],
enabled: filters.items.length > 0,
onSuccess: (data) => {
const isFilterActive = filters.items.length > 0;
if (data?.payload.data.newResult.data.result[0].list) {
const uniqueSpans = uniqBy(
data?.payload.data.newResult.data.result[0].list,
'data.spanID',
);
const spanIds = uniqueSpans.map((val) => val.data.spanID);
setFilteredSpanIds(spanIds);
onFilteredSpansChange?.(spanIds, isFilterActive);
handlePrevNext(0, spanIds[0]);
setNoData(false);
} else {
setNoData(true);
setFilteredSpanIds([]);
onFilteredSpansChange?.([], isFilterActive);
setCurrentSearchedIndex(0);
}
},
},
);
return (
<div className="filter-row">
<QueryBuilderSearchV2
query={{
...BASE_FILTER_QUERY,
filters,
}}
onChange={handleFilterChange}
hideSpanScopeSelector={false}
skipQueryBuilderRedirect
selectProps={{ listHeight: 125 }}
/>
{filteredSpanIds.length > 0 && (
<div className="pre-next-toggle">
<Typography.Text>
{currentSearchedIndex + 1} / {filteredSpanIds.length}
</Typography.Text>
<Button
icon={<ChevronUp size={14} />}
disabled={currentSearchedIndex === 0}
type="text"
onClick={(): void => {
handlePrevNext(currentSearchedIndex - 1);
setCurrentSearchedIndex((prev) => prev - 1);
}}
/>
<Button
icon={<ChevronDown size={14} />}
type="text"
disabled={currentSearchedIndex === filteredSpanIds.length - 1}
onClick={(): void => {
handlePrevNext(currentSearchedIndex + 1);
setCurrentSearchedIndex((prev) => prev + 1);
}}
/>
</div>
)}
{isFetching && <Spin indicator={<LoadingOutlined spin />} size="small" />}
{error && (
<Tooltip title={(error as AxiosError)?.message || 'Something went wrong'}>
<InfoCircleOutlined size={14} />
</Tooltip>
)}
{noData && (
<Typography.Text className="no-results">No results found</Typography.Text>
)}
</div>
);
}
Filters.defaultProps = {
onFilteredSpansChange: undefined,
};
export default Filters;

View File

@@ -1,38 +0,0 @@
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
export const BASE_FILTER_QUERY: IBuilderQuery = {
queryName: 'A',
dataSource: DataSource.TRACES,
aggregateOperator: 'noop',
aggregateAttribute: {
id: '------false',
dataType: DataTypes.EMPTY,
key: '',
type: '',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
functions: [],
filters: {
items: [],
op: 'AND',
},
expression: 'A',
disabled: false,
stepInterval: 60,
having: [],
limit: 200,
orderBy: [
{
columnName: 'timestamp',
order: 'desc',
},
],
groupBy: [],
legend: '',
reduceTo: ReduceOperators.AVG,
offset: 0,
selectColumns: [],
};

View File

@@ -1,553 +0,0 @@
.waterfall-loading-bar {
height: 2px;
background: var(--primary);
animation: waterfall-loading 1.5s ease-in-out infinite;
flex-shrink: 0;
}
@keyframes waterfall-loading {
0% {
opacity: 0.3;
transform: scaleX(0.3);
transform-origin: left;
}
50% {
opacity: 1;
transform: scaleX(1);
transform-origin: left;
}
100% {
opacity: 0.3;
transform: scaleX(0.3);
transform-origin: right;
}
}
.success-content {
overflow-y: hidden;
overflow-x: hidden;
max-width: 100%;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
.missing-spans {
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
margin: 16px;
padding: 12px;
border-radius: 4px;
background: rgba(69, 104, 220, 0.1);
.left-info {
display: flex;
align-items: center;
gap: 8px;
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
.text {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
.right-info {
display: flex;
align-items: center;
justify-content: center;
flex-direction: row-reverse;
gap: 8px;
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.right-info:hover {
background-color: unset;
color: var(--bg-robin-200);
}
}
.waterfall-split-panel {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 0px 20px 0px 20px;
&::-webkit-scrollbar {
width: 0.1rem;
}
}
.waterfall-split-header {
position: sticky;
top: 0;
z-index: 2;
display: flex;
height: 25px;
background-color: var(--bg-ink-500);
.sidebar-header {
flex-shrink: 0;
}
.resize-handle-header {
width: 4px;
flex-shrink: 0;
}
.timeline-header {
flex: 1;
overflow: hidden;
padding: 0 15px;
}
}
.waterfall-split-body {
display: flex;
position: relative;
}
.waterfall-sidebar {
overflow-x: auto;
overflow-y: hidden;
flex-shrink: 0;
border-right: 1px solid var(--bg-slate-400);
.resizable-box__content {
overflow-x: auto;
overflow-y: hidden;
&::-webkit-scrollbar {
height: 0;
}
scrollbar-width: none;
}
&::-webkit-scrollbar {
height: 0.3rem;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-400);
border-radius: 4px;
}
}
.span-tree-table {
position: relative;
border-collapse: collapse;
.span-tree-row {
display: flex;
align-items: center;
}
.span-tree-cell {
display: flex;
width: 100%;
height: 28px;
align-items: center;
overflow: visible;
padding: 0;
}
.span-tree-row:hover,
.span-tree-row.hovered-span {
border-radius: 4px;
background: rgba(171, 189, 255, 0.06) !important;
.span-overview {
background: unset !important;
}
}
}
.sidebar-resize-handle {
width: 4px;
flex-shrink: 0;
cursor: col-resize;
user-select: none;
touch-action: none;
background: transparent;
transition: background 0.15s ease;
z-index: 1;
&:hover,
&:active {
background: rgba(35, 196, 248, 0.2);
}
}
.waterfall-timeline {
flex: 1;
position: relative;
overflow: hidden;
.timeline-row {
display: flex;
align-items: center;
}
.timeline-row:hover,
.timeline-row.hovered-span {
border-radius: 4px;
background: rgba(171, 189, 255, 0.06) !important;
}
}
// Shared span component styles (used in both panels)
.span-overview {
display: flex;
align-items: center;
flex-shrink: 0;
height: 100%;
width: 100%;
cursor: pointer;
position: relative;
white-space: nowrap;
.tree-indent {
display: inline-block;
flex-shrink: 0;
}
.tree-line {
position: absolute;
background-color: var(--bg-slate-400);
pointer-events: none;
}
.tree-connector {
position: absolute;
width: 11px;
height: 50%;
border-left: 1px solid var(--bg-slate-400);
border-bottom: 1px solid var(--bg-slate-400);
border-bottom-left-radius: 6px;
pointer-events: none;
}
.tree-arrow {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
cursor: pointer;
color: var(--bg-vanilla-400);
&.no-children {
cursor: default;
width: 18px;
height: 18px;
}
}
.tree-icon {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
margin: 0 6px;
&.is-error {
box-shadow: 0 0 0 2px rgba(255, 70, 70, 0.3);
}
}
.tree-label {
color: #fff;
font-family: 'Inter';
font-size: 13px;
font-weight: 400;
line-height: 28px;
letter-spacing: 0.01em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.span-row-actions {
position: sticky;
right: 0;
display: flex;
align-items: center;
gap: 2px;
margin-left: auto;
padding-right: 4px;
padding-left: 8px;
flex-shrink: 0;
height: 100%;
background: linear-gradient(to left, var(--bg-ink-500) 60%, transparent);
z-index: 2;
opacity: 0;
pointer-events: none;
.span-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 2px;
cursor: pointer;
color: var(--bg-vanilla-400);
background: transparent;
border: none;
padding: 0;
&:hover {
background: var(--bg-slate-400);
}
}
}
&:hover .span-row-actions {
opacity: 1;
pointer-events: auto;
}
}
// Also show action buttons when hovering the tree row (parent of span-overview)
.span-tree-row:hover .span-row-actions,
.span-tree-row.hovered-span .span-row-actions {
opacity: 1;
pointer-events: auto;
}
.span-duration {
display: flex;
align-items: center;
height: 28px;
position: relative;
width: 100%;
padding: 0 15px;
cursor: pointer;
.span-bar {
position: absolute;
height: 18px;
top: 5px;
border-radius: 2px;
display: flex;
align-items: center;
padding: 0 8px;
overflow: hidden;
cursor: pointer;
white-space: nowrap;
color: rgba(0, 0, 0, 0.9);
background-color: var(--span-color);
border: 1px solid transparent;
.span-info {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
overflow: hidden;
z-index: 1;
.span-name {
font-weight: 500;
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-grow: 1;
min-width: 0;
}
.span-duration-text {
color: inherit;
opacity: 0.8;
font-size: 10px;
margin-left: 8px;
flex-shrink: 0;
}
}
}
.event-dot {
position: absolute;
top: 50%;
transform: translate(-50%, -50%) rotate(45deg);
width: 5px;
height: 5px;
background-color: var(--event-dot-bg, var(--bg-robin-500));
border: 1px solid var(--event-dot-border, var(--bg-robin-600));
cursor: pointer;
z-index: 1;
&.error {
background-color: var(--bg-cherry-500);
border-color: var(--bg-cherry-600);
}
&:hover {
transform: translate(-50%, -50%) rotate(45deg) scale(1.5);
}
}
}
// Hover state: static stripe pattern + border
.timeline-row:hover .span-bar,
.timeline-row.hovered-span .span-bar {
color: var(--span-color);
background-color: rgba(var(--span-color-rgb), 0.1);
border: 1px solid rgba(var(--span-color-rgb), 0.2);
background-image: repeating-linear-gradient(
-45deg,
transparent,
transparent 10px,
rgba(var(--span-color-rgb), 0.04) 10px,
rgba(var(--span-color-rgb), 0.04) 20px
);
}
// Selected state: stripe pattern + dashed border
.interested-span .span-bar,
.selected-non-matching-span .span-bar {
color: var(--span-color);
background-color: rgba(var(--span-color-rgb), 0.1);
border: 1px dashed var(--span-color);
background-image: repeating-linear-gradient(
-45deg,
transparent,
transparent 10px,
rgba(var(--span-color-rgb), 0.04) 10px,
rgba(var(--span-color-rgb), 0.04) 20px
);
}
// Shared state classes for both panels
.interested-span,
.selected-non-matching-span {
border-radius: 4px;
background: rgba(171, 189, 255, 0.06) !important;
}
.dimmed-span {
opacity: 0.4;
}
.highlighted-span {
opacity: 1;
}
.selected-non-matching-span {
.tree-label {
opacity: 0.5;
}
}
}
.span-dets {
.related-logs {
display: flex;
width: 160px;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 8px;
border-radius: 2px;
border: 1px solid var(--Slate-400, #1d212d);
background: var(--Slate-500, #161922);
box-shadow: none;
}
}
.lightMode {
.success-content {
.span-overview {
.tree-arrow {
color: var(--bg-ink-400);
}
.tree-label {
color: var(--bg-ink-400);
}
.tree-line {
background-color: var(--bg-vanilla-400);
}
.tree-connector {
border-color: var(--bg-vanilla-400);
}
.span-row-actions {
background: linear-gradient(
to left,
var(--bg-vanilla-100) 60%,
transparent
);
.span-action-btn {
color: var(--bg-ink-400);
&:hover {
background: var(--bg-vanilla-300);
}
}
}
}
.interested-span {
border-radius: 4px;
background: var(--bg-vanilla-300);
}
.span-duration .span-bar {
color: rgba(255, 255, 255, 0.9);
}
// Light mode hover/selected: span color must override the white default above
.timeline-row:hover .span-bar,
.timeline-row.hovered-span .span-bar {
color: var(--span-color);
}
.interested-span .span-bar,
.selected-non-matching-span .span-bar {
color: var(--span-color);
}
.waterfall-sidebar {
border-right: 1px solid var(--bg-vanilla-300);
}
.waterfall-split-header {
background-color: var(--bg-vanilla-200) !important;
}
}
.span-dets {
.related-logs {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-300);
}
}
}

View File

@@ -1,693 +0,0 @@
import {
Dispatch,
memo,
SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import { useVirtualizer, Virtualizer } from '@tanstack/react-virtual';
import { Button, Popover, Tooltip, Typography } from 'antd';
import cx from 'classnames';
import TimelineV3 from 'components/TimelineV3/TimelineV3';
import { themeColors } from 'constants/theme';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { colorToRgb, generateColor } from 'lib/uPlotLib/utils/generateColor';
import {
AlertCircle,
ArrowUpRight,
ChevronDown,
ChevronRight,
Link,
ListPlus,
} from 'lucide-react';
import { ResizableBox } from 'periscope/components/ResizableBox';
import { Span } from 'types/api/trace/getTraceV2';
import { toFixed } from 'utils/toFixed';
import { EventTooltipContent } from '../../../SpanHoverCard/EventTooltipContent';
import SpanHoverCard from '../../../SpanHoverCard/SpanHoverCard';
import AddSpanToFunnelModal from '../../AddSpanToFunnelModal/AddSpanToFunnelModal';
import { IInterestedSpan } from '../../TraceWaterfall';
import Filters from './Filters/Filters';
import './Success.styles.scss';
// css config
const CONNECTOR_WIDTH = 20;
const VERTICAL_CONNECTOR_WIDTH = 1;
interface ITraceMetadata {
traceId: string;
startTime: number;
endTime: number;
hasMissingSpans: boolean;
}
interface ISuccessProps {
spans: Span[];
traceMetadata: ITraceMetadata;
interestedSpanId: IInterestedSpan;
uncollapsedNodes: string[];
setInterestedSpanId: Dispatch<SetStateAction<IInterestedSpan>>;
selectedSpan: Span | undefined;
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
isFetching?: boolean;
}
const SpanOverview = memo(function SpanOverview({
span,
isSpanCollapsed,
handleCollapseUncollapse,
handleSpanClick,
selectedSpan,
filteredSpanIds,
isFilterActive,
traceMetadata,
onAddSpanToFunnel,
}: {
span: Span;
isSpanCollapsed: boolean;
handleCollapseUncollapse: (id: string, collapse: boolean) => void;
selectedSpan: Span | undefined;
handleSpanClick: (span: Span) => void;
filteredSpanIds: string[];
isFilterActive: boolean;
traceMetadata: ITraceMetadata;
onAddSpanToFunnel: (span: Span) => void;
}): JSX.Element {
const isRootSpan = span.level === 0;
const { onSpanCopy } = useCopySpanLink(span);
let color = generateColor(span.serviceName, themeColors.traceDetailColorsV3);
if (span.hasError) {
color = `var(--bg-cherry-500)`;
}
// Smart highlighting logic
const isMatching =
isFilterActive && (filteredSpanIds || []).includes(span.spanId);
const isSelected = selectedSpan?.spanId === span.spanId;
const isDimmed = isFilterActive && !isMatching && !isSelected;
const isHighlighted = isFilterActive && isMatching && !isSelected;
const isSelectedNonMatching = isSelected && isFilterActive && !isMatching;
const indentWidth = isRootSpan ? 0 : span.level * CONNECTOR_WIDTH;
const handleFunnelClick = (e: React.MouseEvent<HTMLButtonElement>): void => {
e.stopPropagation();
onAddSpanToFunnel(span);
};
return (
<SpanHoverCard span={span} traceMetadata={traceMetadata}>
<div
className={cx('span-overview', {
'interested-span': isSelected && (!isFilterActive || isMatching),
'highlighted-span': isHighlighted,
'selected-non-matching-span': isSelectedNonMatching,
'dimmed-span': isDimmed,
})}
onClick={(): void => handleSpanClick(span)}
>
{/* Tree connector lines — always draw vertical lines at all ancestor levels + L-connector */}
{!isRootSpan &&
Array.from({ length: span.level }, (_, i) => {
const lvl = i + 1;
const xPos = (lvl - 1) * CONNECTOR_WIDTH + 9;
if (lvl < span.level) {
// Stop the line at 50% for the last child's parent level
const isLastChildParentLine = !span.hasSibling && lvl === span.level - 1;
return (
<div
key={lvl}
className="tree-line"
style={{
left: xPos,
top: 0,
width: 1,
height: isLastChildParentLine ? '50%' : '100%',
}}
/>
);
}
return (
<div key={lvl}>
<div
className="tree-line"
style={{ left: xPos, top: 0, width: 1, height: '50%' }}
/>
<div className="tree-connector" style={{ left: xPos, top: 0 }} />
</div>
);
})}
{/* Indent spacer */}
<span className="tree-indent" style={{ width: `${indentWidth}px` }} />
{/* Expand/collapse arrow or leaf bullet */}
{span.hasChildren ? (
<span
className={cx('tree-arrow', { expanded: !isSpanCollapsed })}
onClick={(event): void => {
event.stopPropagation();
event.preventDefault();
handleCollapseUncollapse(span.spanId, !isSpanCollapsed);
}}
>
{isSpanCollapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
</span>
) : (
<span className="tree-arrow no-children" />
)}
{/* Colored service dot */}
<span
className={cx('tree-icon', { 'is-error': span.hasError })}
style={{ backgroundColor: color }}
/>
{/* Span name */}
<span className="tree-label">{span.name}</span>
{/* Action buttons — shown on hover via CSS, right-aligned */}
<span className="span-row-actions">
<Tooltip title="Copy Span Link">
<button type="button" className="span-action-btn" onClick={onSpanCopy}>
<Link size={12} />
</button>
</Tooltip>
<Tooltip title="Add to Trace Funnel">
<button
type="button"
className="span-action-btn"
onClick={handleFunnelClick}
>
<ListPlus size={12} />
</button>
</Tooltip>
</span>
</div>
</SpanHoverCard>
);
});
export const SpanDuration = memo(function SpanDuration({
span,
traceMetadata,
handleSpanClick,
selectedSpan,
filteredSpanIds,
isFilterActive,
}: {
span: Span;
traceMetadata: ITraceMetadata;
selectedSpan: Span | undefined;
handleSpanClick: (span: Span) => void;
filteredSpanIds: string[];
isFilterActive: boolean;
}): JSX.Element {
const { time, timeUnitName } = convertTimeToRelevantUnit(
span.durationNano / 1e6,
);
const spread = traceMetadata.endTime - traceMetadata.startTime;
const leftOffset = ((span.timestamp - traceMetadata.startTime) * 1e2) / spread;
const width = (span.durationNano * 1e2) / (spread * 1e6);
let color = generateColor(span.serviceName, themeColors.traceDetailColorsV3);
let rgbColor = colorToRgb(color);
if (span.hasError) {
color = `var(--bg-cherry-500)`;
rgbColor = '239, 68, 68';
}
const isMatching =
isFilterActive && (filteredSpanIds || []).includes(span.spanId);
const isSelected = selectedSpan?.spanId === span.spanId;
const isDimmed = isFilterActive && !isMatching && !isSelected;
const isHighlighted = isFilterActive && isMatching && !isSelected;
const isSelectedNonMatching = isSelected && isFilterActive && !isMatching;
return (
<div
className={cx('span-duration', {
'interested-span': isSelected && (!isFilterActive || isMatching),
'highlighted-span': isHighlighted,
'selected-non-matching-span': isSelectedNonMatching,
'dimmed-span': isDimmed,
})}
onClick={(): void => handleSpanClick(span)}
>
<SpanHoverCard span={span} traceMetadata={traceMetadata}>
<div
className="span-bar"
style={
{
left: `${leftOffset}%`,
width: `${width}%`,
'--span-color': color,
'--span-color-rgb': rgbColor,
} as React.CSSProperties
}
>
<span className="span-info">
<span className="span-name">{span.name}</span>
<span className="span-duration-text">{`${toFixed(
time,
2,
)} ${timeUnitName}`}</span>
</span>
</div>
</SpanHoverCard>
{span.event?.map((event) => {
const eventTimeMs = event.timeUnixNano / 1e6;
const spanDurationMs = span.durationNano / 1e6;
const eventOffsetPercent =
((eventTimeMs - span.timestamp) / spanDurationMs) * 100;
const clampedOffset = Math.max(1, Math.min(eventOffsetPercent, 99));
const { isError } = event;
// Position relative to the span bar: leftOffset% + clampedOffset% of width%
const dotLeft = leftOffset + (clampedOffset / 100) * width;
const parts = rgbColor.split(', ');
const dotBg = `rgb(${parts
.map((c) => Math.round(Number(c) * 0.7))
.join(', ')})`;
const dotBorder = `rgb(${parts
.map((c) => Math.round(Number(c) * 0.5))
.join(', ')})`;
return (
<Popover
key={`${span.spanId}-event-${event.name}-${event.timeUnixNano}`}
content={
<EventTooltipContent
eventName={event.name}
timeOffsetMs={eventTimeMs - span.timestamp}
isError={isError}
attributeMap={event.attributeMap || {}}
/>
}
trigger="hover"
rootClassName="span-hover-card-popover"
autoAdjustOverflow
arrow={false}
>
<div
className={`event-dot ${isError ? 'error' : ''}`}
style={
{
left: `${dotLeft}%`,
'--event-dot-bg': isError ? undefined : dotBg,
'--event-dot-border': isError ? undefined : dotBorder,
} as React.CSSProperties
}
/>
</Popover>
);
})}
</div>
);
});
// table config
const columnDefHelper = createColumnHelper<Span>();
const ROW_HEIGHT = 28;
const DEFAULT_SIDEBAR_WIDTH = 450;
const MIN_SIDEBAR_WIDTH = 240;
const MAX_SIDEBAR_WIDTH = 900;
const BASE_CONTENT_WIDTH = 300;
function Success(props: ISuccessProps): JSX.Element {
const {
spans,
traceMetadata,
interestedSpanId,
uncollapsedNodes,
setInterestedSpanId,
setSelectedSpan,
selectedSpan,
isFetching,
} = props;
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
const [isFilterActive, setIsFilterActive] = useState<boolean>(false);
const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_SIDEBAR_WIDTH);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const virtualizerRef = useRef<Virtualizer<HTMLDivElement, Element>>();
const prevHoveredSpanIdRef = useRef<string | null>(null);
// Imperative DOM class toggling for hover highlights (avoids React re-renders)
const applyHoverClass = useCallback((spanId: string | null): void => {
const prev = prevHoveredSpanIdRef.current;
if (prev === spanId) {
return;
}
if (prev) {
const prevElements = document.querySelectorAll(`[data-span-id="${prev}"]`);
prevElements.forEach((el) => el.classList.remove('hovered-span'));
}
if (spanId) {
const nextElements = document.querySelectorAll(`[data-span-id="${spanId}"]`);
nextElements.forEach((el) => el.classList.add('hovered-span'));
}
prevHoveredSpanIdRef.current = spanId;
}, []);
const handleRowMouseEnter = useCallback(
(spanId: string): void => {
applyHoverClass(spanId);
},
[applyHoverClass],
);
const handleRowMouseLeave = useCallback((): void => {
applyHoverClass(null);
}, [applyHoverClass]);
const handleFilteredSpansChange = useCallback(
(spanIds: string[], isActive: boolean) => {
setFilteredSpanIds(spanIds);
setIsFilterActive(isActive);
},
[],
);
const handleCollapseUncollapse = useCallback(
(spanId: string, collapse: boolean) => {
setInterestedSpanId({ spanId, isUncollapsed: !collapse });
},
[setInterestedSpanId],
);
const handleVirtualizerInstanceChanged = useCallback(
(instance: Virtualizer<HTMLDivElement, Element>): void => {
const { range } = instance;
// when there are less than 500 elements in the API call that means there is nothing to fetch on top and bottom so
// do not trigger the API call
if (spans.length < 500) {
return;
}
if (range?.startIndex === 0 && instance.isScrolling) {
// do not trigger for trace root as nothing to fetch above
if (spans[0].level !== 0) {
setInterestedSpanId({
spanId: spans[0].spanId,
isUncollapsed: false,
});
}
return;
}
if (range?.endIndex === spans.length - 1 && instance.isScrolling) {
setInterestedSpanId({
spanId: spans[spans.length - 1].spanId,
isUncollapsed: false,
});
}
},
[spans, setInterestedSpanId],
);
const [isAddSpanToFunnelModalOpen, setIsAddSpanToFunnelModalOpen] = useState(
false,
);
const [selectedSpanToAddToFunnel, setSelectedSpanToAddToFunnel] = useState<
Span | undefined
>(undefined);
const handleAddSpanToFunnel = useCallback((span: Span): void => {
setIsAddSpanToFunnelModalOpen(true);
setSelectedSpanToAddToFunnel(span);
}, []);
const urlQuery = useUrlQuery();
const { safeNavigate } = useSafeNavigate();
const handleSpanClick = useCallback(
(span: Span): void => {
setSelectedSpan(span);
if (span?.spanId) {
urlQuery.set('spanId', span?.spanId);
}
safeNavigate({ search: urlQuery.toString() });
},
[setSelectedSpan, urlQuery, safeNavigate],
);
// Left side columns using TanStack React Table (extensible for future columns)
const leftColumns = useMemo(
() => [
columnDefHelper.display({
id: 'span-name',
header: '',
cell: (cellProps): JSX.Element => (
<SpanOverview
span={cellProps.row.original}
handleCollapseUncollapse={handleCollapseUncollapse}
isSpanCollapsed={
!uncollapsedNodes.includes(cellProps.row.original.spanId)
}
selectedSpan={selectedSpan}
handleSpanClick={handleSpanClick}
traceMetadata={traceMetadata}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
onAddSpanToFunnel={handleAddSpanToFunnel}
/>
),
}),
],
[
handleCollapseUncollapse,
uncollapsedNodes,
traceMetadata,
selectedSpan,
handleSpanClick,
filteredSpanIds,
isFilterActive,
handleAddSpanToFunnel,
],
);
const leftTable = useReactTable({
data: spans,
columns: leftColumns,
getCoreRowModel: getCoreRowModel(),
});
// Shared virtualizer - one instance drives both panels
const virtualizer = useVirtualizer({
count: spans.length,
getScrollElement: (): HTMLDivElement | null => scrollContainerRef.current,
estimateSize: (): number => ROW_HEIGHT,
overscan: 20,
onChange: handleVirtualizerInstanceChanged,
});
useEffect(() => {
virtualizerRef.current = virtualizer;
}, [virtualizer]);
// Compute max content width for sidebar horizontal scroll
const maxContentWidth = useMemo(() => {
if (spans.length === 0) {
return sidebarWidth;
}
const maxLevel = spans.reduce((max, span) => Math.max(max, span.level), 0);
return Math.max(
sidebarWidth,
maxLevel * (CONNECTOR_WIDTH + VERTICAL_CONNECTOR_WIDTH) + BASE_CONTENT_WIDTH,
);
}, [spans, sidebarWidth]);
// Scroll to interested span
useEffect(() => {
if (interestedSpanId.spanId !== '' && virtualizerRef.current) {
const idx = spans.findIndex(
(span) => span.spanId === interestedSpanId.spanId,
);
if (idx !== -1) {
setTimeout(() => {
virtualizerRef.current?.scrollToIndex(idx, {
align: 'center',
behavior: 'auto',
});
}, 400);
setSelectedSpan(spans[idx]);
}
} else {
setSelectedSpan((prev) => {
if (!prev) {
return spans[0];
}
return prev;
});
}
}, [interestedSpanId, setSelectedSpan, spans]);
const virtualItems = virtualizer.getVirtualItems();
const leftRows = leftTable.getRowModel().rows;
return (
<div className="success-content">
{traceMetadata.hasMissingSpans && (
<div className="missing-spans">
<section className="left-info">
<AlertCircle size={14} />
<Typography.Text className="text">
This trace has missing spans
</Typography.Text>
</section>
<Button
icon={<ArrowUpRight size={14} />}
className="right-info"
type="text"
onClick={(): WindowProxy | null =>
window.open(
'https://signoz.io/docs/userguide/traces/#missing-spans',
'_blank',
)
}
>
Learn More
</Button>
</div>
)}
<Filters
startTime={traceMetadata.startTime / 1e3}
endTime={traceMetadata.endTime / 1e3}
traceID={traceMetadata.traceId}
onFilteredSpansChange={handleFilteredSpansChange}
/>
{isFetching && <div className="waterfall-loading-bar" />}
<div className="waterfall-split-panel" ref={scrollContainerRef}>
{/* Sticky header row */}
<div className="waterfall-split-header">
<div
className="sidebar-header"
style={{ width: sidebarWidth, flexShrink: 0 }}
/>
<div className="resize-handle-header" />
<div className="timeline-header">
<TimelineV3
startTimestamp={traceMetadata.startTime}
endTimestamp={traceMetadata.endTime}
timelineHeight={10}
offsetTimestamp={0}
/>
</div>
</div>
{/* Split body */}
<div
className="waterfall-split-body"
style={{
minHeight: virtualizer.getTotalSize(),
height: '100%',
}}
>
{/* Left panel - table with horizontal scroll */}
<ResizableBox
direction="horizontal"
defaultWidth={DEFAULT_SIDEBAR_WIDTH}
minWidth={MIN_SIDEBAR_WIDTH}
maxWidth={MAX_SIDEBAR_WIDTH}
onResize={setSidebarWidth}
className="waterfall-sidebar"
>
<table className="span-tree-table" style={{ width: maxContentWidth }}>
<tbody>
{virtualItems.map((virtualRow) => {
const row = leftRows[virtualRow.index];
const span = spans[virtualRow.index];
return (
<tr
key={String(virtualRow.key)}
data-testid={`cell-0-${span.spanId}`}
data-span-id={span.spanId}
className="span-tree-row"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: ROW_HEIGHT,
transform: `translateY(${virtualRow.start}px)`,
}}
onMouseEnter={(): void => handleRowMouseEnter(span.spanId)}
onMouseLeave={handleRowMouseLeave}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="span-tree-cell">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</ResizableBox>
{/* Right panel - timeline bars */}
<div className="waterfall-timeline">
{virtualItems.map((virtualRow) => {
const span = spans[virtualRow.index];
return (
<div
key={String(virtualRow.key)}
data-testid={`cell-1-${span.spanId}`}
data-span-id={span.spanId}
className="timeline-row"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: ROW_HEIGHT,
transform: `translateY(${virtualRow.start}px)`,
}}
onMouseEnter={(): void => handleRowMouseEnter(span.spanId)}
onMouseLeave={handleRowMouseLeave}
>
<SpanDuration
span={span}
traceMetadata={traceMetadata}
selectedSpan={selectedSpan}
handleSpanClick={handleSpanClick}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
/>
</div>
);
})}
</div>
</div>
</div>
{selectedSpanToAddToFunnel && (
<AddSpanToFunnelModal
span={selectedSpanToAddToFunnel}
isOpen={isAddSpanToFunnelModalOpen}
onClose={(): void => setIsAddSpanToFunnelModalOpen(false)}
/>
)}
</div>
);
}
export default Success;

View File

@@ -1,261 +0,0 @@
import useUrlQuery from 'hooks/useUrlQuery';
import { fireEvent, render, screen } from 'tests/test-utils';
import { Span } from 'types/api/trace/getTraceV2';
import { SpanDuration } from '../Success';
// Constants to avoid string duplication
const SPAN_DURATION_TEXT = '1.16 ms';
const SPAN_DURATION_CLASS = '.span-duration';
const INTERESTED_SPAN_CLASS = 'interested-span';
const HIGHLIGHTED_SPAN_CLASS = 'highlighted-span';
const DIMMED_SPAN_CLASS = 'dimmed-span';
const SELECTED_NON_MATCHING_SPAN_CLASS = 'selected-non-matching-span';
jest.mock('components/TimelineV3/TimelineV3', () => ({
__esModule: true,
default: (): null => null,
}));
// Mock the hooks
jest.mock('hooks/useUrlQuery');
jest.mock('@signozhq/badge', () => ({
Badge: jest.fn(),
}));
const mockSpan: Span = {
spanId: 'test-span-id',
name: 'test-span',
serviceName: 'test-service',
durationNano: 1160000, // 1ms in nano
timestamp: 1234567890,
rootSpanId: 'test-root-span-id',
parentSpanId: 'test-parent-span-id',
traceId: 'test-trace-id',
hasError: false,
kind: 0,
references: [],
tagMap: {},
event: [],
rootName: 'test-root-name',
statusMessage: 'test-status-message',
statusCodeString: 'test-status-code-string',
spanKind: 'test-span-kind',
hasChildren: false,
hasSibling: false,
subTreeNodeCount: 0,
level: 0,
};
const mockTraceMetadata = {
traceId: 'test-trace-id',
startTime: 1234567000,
endTime: 1234569000,
hasMissingSpans: false,
};
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: mockSafeNavigate,
}),
}));
describe('SpanDuration', () => {
const mockSetSelectedSpan = jest.fn();
const mockUrlQuerySet = jest.fn();
const mockUrlQueryGet = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
// Mock URL query hook
(useUrlQuery as jest.Mock).mockReturnValue({
set: mockUrlQuerySet,
get: mockUrlQueryGet,
toString: () => 'spanId=test-span-id',
});
});
it('calls handleSpanClick when clicked', () => {
const mockHandleSpanClick = jest.fn();
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={undefined}
handleSpanClick={mockHandleSpanClick}
filteredSpanIds={[]}
isFilterActive={false}
/>,
);
// Find and click the span duration element
const spanElement = screen.getByText(SPAN_DURATION_TEXT);
fireEvent.click(spanElement);
// Verify handleSpanClick was called with the correct span
expect(mockHandleSpanClick).toHaveBeenCalledWith(mockSpan);
});
it('shows action buttons on hover', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={undefined}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={[]}
isFilterActive={false}
/>,
);
const spanElement = screen.getByText(SPAN_DURATION_TEXT);
// Hover over the span should add hovered-span class
fireEvent.mouseEnter(spanElement);
// Mouse leave should remove hovered-span class
fireEvent.mouseLeave(spanElement);
});
it('applies interested-span class when span is selected', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={mockSpan}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={[]}
isFilterActive={false}
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(INTERESTED_SPAN_CLASS);
});
it('applies highlighted-span class when span matches filter', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={undefined}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={[mockSpan.spanId]}
isFilterActive
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(HIGHLIGHTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
});
it('applies dimmed-span class when span does not match filter', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={undefined}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={['other-span-id']}
isFilterActive
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(DIMMED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
});
it('prioritizes interested-span over highlighted-span when span is selected and matches filter', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={mockSpan}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={[mockSpan.spanId]}
isFilterActive
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(INTERESTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(DIMMED_SPAN_CLASS);
});
it('applies selected-non-matching-span class when span is selected but does not match filter', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={mockSpan}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={['different-span-id']}
isFilterActive
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(SELECTED_NON_MATCHING_SPAN_CLASS);
expect(spanElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(DIMMED_SPAN_CLASS);
});
it('applies interested-span class when span is selected and no filter is active', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={mockSpan}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={[]}
isFilterActive={false}
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(INTERESTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(SELECTED_NON_MATCHING_SPAN_CLASS);
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(DIMMED_SPAN_CLASS);
});
it('dims span when filter is active but no matches found', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={undefined}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={[]} // Empty array but filter is active
isFilterActive // This is the key difference
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(DIMMED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
});
});

View File

@@ -1,420 +0,0 @@
import React from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { Span } from 'types/api/trace/getTraceV2';
import Success from '../Success';
// Mock the required hooks with proper typing
const mockSafeNavigate = jest.fn() as jest.MockedFunction<
(params: { search: string }) => void
>;
const mockUrlQuery = new URLSearchParams();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: typeof mockSafeNavigate } => ({
safeNavigate: mockSafeNavigate,
}),
}));
jest.mock('hooks/useUrlQuery', () => (): URLSearchParams => mockUrlQuery);
// App provider is already handled by test-utils
// React Router is already globally mocked
// Mock complex external dependencies that cause provider issues
jest.mock('components/SpanHoverCard/SpanHoverCard', () => {
function SpanHoverCard({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
return <div>{children}</div>;
}
SpanHoverCard.displayName = 'SpanHoverCard';
return SpanHoverCard;
});
// Mock the Filters component that's causing React Query issues
jest.mock('../Filters/Filters', () => {
function Filters(): null {
return null;
}
Filters.displayName = 'Filters';
return Filters;
});
// Mock other potential dependencies
jest.mock(
'pages/TraceDetailsV3/TraceWaterfall/AddSpanToFunnelModal/AddSpanToFunnelModal',
() => {
function AddSpanToFunnelModal(): null {
return null;
}
AddSpanToFunnelModal.displayName = 'AddSpanToFunnelModal';
return AddSpanToFunnelModal;
},
);
jest.mock('pages/TraceDetailsV3/TraceWaterfall/SpanLineActionButtons', () => {
function SpanLineActionButtons(): null {
return null;
}
SpanLineActionButtons.displayName = 'SpanLineActionButtons';
return SpanLineActionButtons;
});
jest.mock('components/HttpStatusBadge/HttpStatusBadge', () => {
function HttpStatusBadge(): null {
return null;
}
HttpStatusBadge.displayName = 'HttpStatusBadge';
return HttpStatusBadge;
});
jest.mock('components/TimelineV3/TimelineV3', () => {
function TimelineV3(): null {
return null;
}
TimelineV3.displayName = 'TimelineV3';
return { __esModule: true, default: TimelineV3 };
});
// Mock other utilities that might cause issues
jest.mock('lib/uPlotLib/utils/generateColor', () => ({
generateColor: (): string => '#1890ff',
colorToRgb: (): string => '24, 144, 255',
}));
jest.mock('container/TraceDetail/utils', () => ({
convertTimeToRelevantUnit: (
value: number,
): { time: number; timeUnitName: string } => ({
time: value < 1000 ? value : value / 1000,
timeUnitName: value < 1000 ? 'ms' : 's',
}),
}));
jest.mock('utils/toFixed', () => ({
toFixed: (value: number, decimals: number): string => value.toFixed(decimals),
}));
// Mock useVirtualizer to render all items without actual virtualization
jest.mock('@tanstack/react-virtual', () => ({
useVirtualizer: ({
count,
}: {
count: number;
}): {
getVirtualItems: () => Array<{
index: number;
key: number;
start: number;
size: number;
}>;
getTotalSize: () => number;
scrollToIndex: jest.Mock;
} => ({
getVirtualItems: (): Array<{
index: number;
key: number;
start: number;
size: number;
}> =>
Array.from({ length: count }, (_, i) => ({
index: i,
key: i,
start: i * 54,
size: 54,
})),
getTotalSize: (): number => count * 54,
scrollToIndex: jest.fn(),
}),
Virtualizer: jest.fn(),
}));
const mockTraceMetadata = {
traceId: 'test-trace-id',
startTime: 1679748225000000,
endTime: 1679748226000000,
hasMissingSpans: false,
};
const createMockSpan = (spanId: string, level = 1): Span => ({
spanId,
traceId: 'test-trace-id',
rootSpanId: 'span-1',
parentSpanId: level === 0 ? '' : 'span-1',
name: `Test Span ${spanId}`,
serviceName: 'test-service',
timestamp: mockTraceMetadata.startTime + level * 100000,
durationNano: 50000000,
level,
hasError: false,
kind: 1,
references: [],
tagMap: {},
event: [],
rootName: 'Test Root Span',
statusMessage: '',
statusCodeString: 'OK',
spanKind: 'server',
hasChildren: false,
hasSibling: false,
subTreeNodeCount: 1,
});
const mockSpans = [
createMockSpan('span-1', 0),
createMockSpan('span-2', 1),
createMockSpan('span-3', 1),
];
// Shared TestComponent for all tests
function TestComponent(): JSX.Element {
const [selectedSpan, setSelectedSpan] = React.useState<Span | undefined>(
undefined,
);
return (
<Success
spans={mockSpans}
traceMetadata={mockTraceMetadata}
interestedSpanId={{ spanId: '', isUncollapsed: false }}
uncollapsedNodes={mockSpans.map((s) => s.spanId)}
setInterestedSpanId={jest.fn()}
selectedSpan={selectedSpan}
setSelectedSpan={setSelectedSpan}
/>
);
}
describe('Span Click User Flows', () => {
const FIRST_SPAN_TEST_ID = 'cell-0-span-1';
const FIRST_SPAN_DURATION_TEST_ID = 'cell-1-span-1';
const SECOND_SPAN_TEST_ID = 'cell-0-span-2';
const SPAN_OVERVIEW_CLASS = '.span-overview';
const SPAN_DURATION_CLASS = '.span-duration';
const INTERESTED_SPAN_CLASS = 'interested-span';
const SECOND_SPAN_DURATION_TEST_ID = 'cell-1-span-2';
beforeEach(() => {
jest.clearAllMocks();
// Clear all URL parameters
Array.from(mockUrlQuery.keys()).forEach((key) => mockUrlQuery.delete(key));
});
it('clicking span updates URL with spanId parameter', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<Success
spans={mockSpans}
traceMetadata={mockTraceMetadata}
interestedSpanId={{ spanId: '', isUncollapsed: false }}
uncollapsedNodes={mockSpans.map((s) => s.spanId)}
setInterestedSpanId={jest.fn()}
selectedSpan={undefined}
setSelectedSpan={jest.fn()}
/>,
undefined,
{ initialRoute: '/trace' },
);
// Initially URL should not have spanId
expect(mockUrlQuery.get('spanId')).toBeNull();
// Click on the actual span element (not the wrapper)
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
const spanElement = spanOverview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
await user.click(spanElement);
// Verify URL was updated with spanId
expect(mockUrlQuery.get('spanId')).toBe('span-1');
expect(mockSafeNavigate).toHaveBeenCalledWith({
search: expect.stringContaining('spanId=span-1'),
});
});
it('clicking span duration visually selects the span', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestComponent />, undefined, {
initialRoute: '/trace',
});
// Wait for initial render and selection
await waitFor(() => {
const spanDuration = screen.getByTestId(FIRST_SPAN_DURATION_TEST_ID);
const spanDurationElement = spanDuration.querySelector(
SPAN_DURATION_CLASS,
) as HTMLElement;
expect(spanDurationElement).toHaveClass(INTERESTED_SPAN_CLASS);
});
// Click on span-2 to test selection change
const span2Duration = screen.getByTestId(SECOND_SPAN_DURATION_TEST_ID);
const span2DurationElement = span2Duration.querySelector(
SPAN_DURATION_CLASS,
) as HTMLElement;
await user.click(span2DurationElement);
// Wait for the state update and re-render
await waitFor(() => {
const spanDuration = screen.getByTestId(FIRST_SPAN_DURATION_TEST_ID);
const spanDurationElement = spanDuration.querySelector(
SPAN_DURATION_CLASS,
) as HTMLElement;
const span2Duration = screen.getByTestId(SECOND_SPAN_DURATION_TEST_ID);
const span2DurationElement = span2Duration.querySelector(
SPAN_DURATION_CLASS,
) as HTMLElement;
expect(spanDurationElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
expect(span2DurationElement).toHaveClass(INTERESTED_SPAN_CLASS);
});
});
it('both click areas produce the same visual result', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestComponent />, undefined, {
initialRoute: '/trace',
});
// Wait for initial render and selection
await waitFor(() => {
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
const spanDuration = screen.getByTestId(FIRST_SPAN_DURATION_TEST_ID);
const spanOverviewElement = spanOverview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
const spanDurationElement = spanDuration.querySelector(
SPAN_DURATION_CLASS,
) as HTMLElement;
// Initially both areas should show the same visual selection (first span is auto-selected)
expect(spanOverviewElement).toHaveClass(INTERESTED_SPAN_CLASS);
expect(spanDurationElement).toHaveClass(INTERESTED_SPAN_CLASS);
});
// Click span-2 to test selection change
const span2Overview = screen.getByTestId(SECOND_SPAN_TEST_ID);
const span2Element = span2Overview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
await user.click(span2Element);
// Wait for the state update and re-render
await waitFor(() => {
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
const spanDuration = screen.getByTestId(FIRST_SPAN_DURATION_TEST_ID);
const spanOverviewElement = spanOverview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
const spanDurationElement = spanDuration.querySelector(
SPAN_DURATION_CLASS,
) as HTMLElement;
// Now span-2 should be selected, span-1 should not
expect(spanOverviewElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
expect(spanDurationElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
// Check that span-2 is selected
const span2Overview = screen.getByTestId(SECOND_SPAN_TEST_ID);
const span2Duration = screen.getByTestId(SECOND_SPAN_DURATION_TEST_ID);
const span2OverviewElement = span2Overview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
const span2DurationElement = span2Duration.querySelector(
SPAN_DURATION_CLASS,
) as HTMLElement;
expect(span2OverviewElement).toHaveClass(INTERESTED_SPAN_CLASS);
expect(span2DurationElement).toHaveClass(INTERESTED_SPAN_CLASS);
});
});
it('clicking different spans updates selection correctly', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestComponent />, undefined, {
initialRoute: '/trace',
});
// Wait for initial render and selection
await waitFor(() => {
const span1Overview = screen.getByTestId(FIRST_SPAN_TEST_ID);
const span1Element = span1Overview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
expect(span1Element).toHaveClass(INTERESTED_SPAN_CLASS);
});
// Click second span
const span2Overview = screen.getByTestId(SECOND_SPAN_TEST_ID);
const span2Element = span2Overview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
await user.click(span2Element);
// Wait for the state update and re-render
await waitFor(() => {
const span1Overview = screen.getByTestId(FIRST_SPAN_TEST_ID);
const span1Element = span1Overview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
const span2Overview = screen.getByTestId(SECOND_SPAN_TEST_ID);
const span2Element = span2Overview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
// Second span should be selected, first should not
expect(span1Element).not.toHaveClass(INTERESTED_SPAN_CLASS);
expect(span2Element).toHaveClass(INTERESTED_SPAN_CLASS);
});
});
it('preserves existing URL parameters when selecting spans', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
// Pre-populate URL with existing parameters
mockUrlQuery.set('existingParam', 'existingValue');
mockUrlQuery.set('anotherParam', 'anotherValue');
render(
<Success
spans={mockSpans}
traceMetadata={mockTraceMetadata}
interestedSpanId={{ spanId: '', isUncollapsed: false }}
uncollapsedNodes={mockSpans.map((s) => s.spanId)}
setInterestedSpanId={jest.fn()}
selectedSpan={undefined}
setSelectedSpan={jest.fn()}
/>,
undefined,
{ initialRoute: '/trace' },
);
// Click on the actual span element (not the wrapper)
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
const spanElement = spanOverview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
await user.click(spanElement);
// Verify existing parameters are preserved and spanId is added
expect(mockUrlQuery.get('existingParam')).toBe('existingValue');
expect(mockUrlQuery.get('anotherParam')).toBe('anotherValue');
expect(mockUrlQuery.get('spanId')).toBe('span-1');
// Verify navigation was called with all parameters
expect(mockSafeNavigate).toHaveBeenCalledWith({
search: expect.stringMatching(
/existingParam=existingValue.*anotherParam=anotherValue.*spanId=span-1/,
),
});
});
});

View File

@@ -1,7 +0,0 @@
export enum TraceWaterfallStates {
LOADING = 'LOADING',
SUCCESS = 'SUCCESS',
NO_DATA = 'NO_DATA',
ERROR = 'ERROR',
FETCHING_WITH_OLD_DATA_PRESENT = 'FETCHING_WTIH_OLD_DATA_PRESENT',
}

View File

@@ -1,195 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Collapse } from 'antd';
import { useDetailsPanel } from 'components/DetailsPanel';
import { LOCALSTORAGE } from 'constants/localStorage';
import useGetTraceV2 from 'hooks/trace/useGetTraceV2';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { ResizableBox } from 'periscope/components/ResizableBox';
import { Span, TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
import { SpanDetailVariant } from './SpanDetailsPanel/constants';
import SpanDetailsPanel from './SpanDetailsPanel/SpanDetailsPanel';
import TraceDetailsHeader from './TraceDetailsHeader/TraceDetailsHeader';
import TraceFlamegraph from './TraceFlamegraph/TraceFlamegraph';
import TraceWaterfall, {
IInterestedSpan,
} from './TraceWaterfall/TraceWaterfall';
import './TraceDetailsV3.styles.scss';
function TraceDetailsV3(): JSX.Element {
const { id: traceId } = useParams<TraceDetailV2URLProps>();
const urlQuery = useUrlQuery();
const [interestedSpanId, setInterestedSpanId] = useState<IInterestedSpan>(
() => ({
spanId: urlQuery.get('spanId') || '',
isUncollapsed: urlQuery.get('spanId') !== '',
}),
);
const [uncollapsedNodes, setUncollapsedNodes] = useState<string[]>([]);
const [selectedSpan, setSelectedSpan] = useState<Span>();
const selectedSpanId = urlQuery.get('spanId') || undefined;
const { safeNavigate } = useSafeNavigate();
const handleSpanDetailsClose = useCallback((): void => {
urlQuery.delete('spanId');
safeNavigate({ search: urlQuery.toString() });
}, [urlQuery, safeNavigate]);
const panelState = useDetailsPanel({
entityId: selectedSpanId,
onClose: handleSpanDetailsClose,
});
useEffect(() => {
setInterestedSpanId({
spanId: urlQuery.get('spanId') || '',
isUncollapsed: urlQuery.get('spanId') !== '',
});
}, [urlQuery]);
const {
data: traceData,
isFetching: isFetchingTraceData,
error: errorFetchingTraceData,
} = useGetTraceV2({
traceId,
uncollapsedSpans: uncollapsedNodes,
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
});
useEffect(() => {
if (traceData && traceData.payload && traceData.payload.uncollapsedSpans) {
setUncollapsedNodes(traceData.payload.uncollapsedSpans);
}
}, [traceData]);
// Collapse state — at least one must remain open
const [activeKeys, setActiveKeys] = useState<string[]>(['flame', 'waterfall']);
const handleCollapseChange = (key: string): void => {
setActiveKeys((prev) => {
const next = prev.includes(key)
? prev.filter((k) => k !== key)
: [...prev, key];
// Don't allow collapsing the last open section
if (next.length === 0) {
return prev;
}
return next;
});
};
const [spanDetailVariant, setSpanDetailVariant] = useState<SpanDetailVariant>(
() =>
(localStorage.getItem(
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
) as SpanDetailVariant) || SpanDetailVariant.DOCKED,
);
const handleVariantChange = useCallback(
(newVariant: SpanDetailVariant): void => {
localStorage.setItem(
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
newVariant,
);
setSpanDetailVariant(newVariant);
},
[],
);
const isDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
const isWaterfallDocked = panelState.isOpen && isDocked;
const waterfallChildren = (
<ResizableBox
defaultHeight={300}
minHeight={150}
disabled={!isWaterfallDocked}
>
<TraceWaterfall
traceData={traceData}
isFetchingTraceData={isFetchingTraceData}
errorFetchingTraceData={errorFetchingTraceData}
traceId={traceId || ''}
interestedSpanId={interestedSpanId}
setInterestedSpanId={setInterestedSpanId}
uncollapsedNodes={uncollapsedNodes}
selectedSpan={selectedSpan}
setSelectedSpan={setSelectedSpan}
/>
</ResizableBox>
);
return (
<div className="trace-details-v3">
<TraceDetailsHeader />
<div className="trace-details-v3__content">
<Collapse
activeKey={activeKeys.filter((k) => k === 'flame')}
onChange={(): void => handleCollapseChange('flame')}
size="small"
className="trace-details-v3__flame-collapse"
items={[
{
key: 'flame',
label: 'Flame Graph',
children: (
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
<TraceFlamegraph />
</ResizableBox>
),
},
]}
/>
<Collapse
activeKey={activeKeys.filter((k) => k === 'waterfall')}
onChange={(): void => handleCollapseChange('waterfall')}
size="small"
className={`trace-details-v3__waterfall-collapse${
isWaterfallDocked ? ' trace-details-v3__waterfall-collapse--docked' : ''
}`}
items={[
{
key: 'waterfall',
label: 'Waterfall',
children: waterfallChildren,
},
]}
/>
{panelState.isOpen && isDocked && (
<div className="trace-details-v3__docked-span-details">
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DOCKED}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
</div>
)}
</div>
{panelState.isOpen && !isDocked && (
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DIALOG}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
)}
</div>
);
}
export default TraceDetailsV3;

View File

@@ -1,28 +0,0 @@
import { Span } from 'types/api/trace/getTraceV2';
/**
* Look up an attribute from both `resources` and `attributes` on a span.
* Resources are checked first (service.name, k8s.* etc. live there).
* Falls back to V2 fields (tagMap) if V3 fields are not present.
*/
export function getSpanAttribute(span: Span, key: string): string | undefined {
return span.resources?.[key] || span.attributes?.[key] || span.tagMap?.[key];
}
const INFRA_METADATA_KEYS = [
'k8s.cluster.name',
'k8s.pod.name',
'k8s.node.name',
'host.name',
] as const;
/**
* Check if span has infrastructure metadata (k8s/host).
* Works with both V2 (tagMap) and V3 (resources/attributes) spans.
*/
export function hasInfraMetadata(span: Span | undefined): boolean {
if (!span) {
return false;
}
return INFRA_METADATA_KEYS.some((key) => getSpanAttribute(span, key));
}

View File

@@ -1,54 +0,0 @@
.data-viewer {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
// Toolbar — view mode switcher + copy button
&__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 8px;
}
&__mode-select {
min-width: 90px;
height: 28px !important;
.ant-select-selector {
border-radius: 4px !important;
border-color: var(--l2-border) !important;
font-size: 12px !important;
}
}
&__copy-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 4px 8px;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: transparent;
color: var(--l2-foreground);
cursor: pointer;
&:hover {
color: var(--l1-foreground);
background: var(--l3-background);
}
}
// Shared content container — no scroll, each view handles its own
&__content {
flex: 1;
display: flex;
flex-direction: column;
border: 1px solid var(--l2-border);
border-radius: 4px;
padding: 8px;
min-height: 0;
overflow: hidden;
}
}

View File

@@ -1,77 +0,0 @@
import { useMemo, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Copy } from '@signozhq/icons';
import { toast } from '@signozhq/sonner';
// TODO: Replace antd Select with @signozhq/ui component when moving to design library
import { Select } from 'antd';
import { JsonView } from 'periscope/components/JsonView';
import { PrettyView } from 'periscope/components/PrettyView';
import { PrettyViewProps } from 'periscope/components/PrettyView';
import './DataViewer.styles.scss';
type ViewMode = 'pretty' | 'json';
export interface DataViewerProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: Record<string, any>;
drawerKey?: string;
prettyViewProps?: Omit<PrettyViewProps, 'data' | 'drawerKey'>;
}
function DataViewer({
data,
drawerKey = 'default',
prettyViewProps,
}: DataViewerProps): JSX.Element {
const [viewMode, setViewMode] = useState<ViewMode>('pretty');
const [, setCopy] = useCopyToClipboard();
const jsonString = useMemo(() => JSON.stringify(data, null, 2), [data]);
const handleCopy = (): void => {
const text = JSON.stringify(data, null, 2);
setCopy(text);
toast.success('Copied to clipboard', {
richColors: true,
position: 'top-right',
});
};
return (
<div className="data-viewer">
<div className="data-viewer__toolbar">
<Select
className="data-viewer__mode-select"
size="small"
value={viewMode}
onChange={(value: ViewMode): void => setViewMode(value)}
options={[
{ label: 'Pretty', value: 'pretty' },
{ label: 'JSON', value: 'json' },
]}
getPopupContainer={(trigger): HTMLElement =>
trigger.parentElement || document.body
}
/>
<button
type="button"
className="data-viewer__copy-btn"
onClick={handleCopy}
aria-label="Copy JSON"
>
<Copy size={14} />
</button>
</div>
<div className="data-viewer__content">
{viewMode === 'pretty' && (
<PrettyView data={data} drawerKey={drawerKey} {...prettyViewProps} />
)}
{viewMode === 'json' && <JsonView data={jsonString} />}
</div>
</div>
);
}
export default DataViewer;

View File

@@ -1,2 +0,0 @@
export type { DataViewerProps } from './DataViewer';
export { default as DataViewer } from './DataViewer';

View File

@@ -1,22 +0,0 @@
.floating-panel {
z-index: 999;
background: var(--l1-background);
border: 1px solid var(--l2-border);
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
overflow: hidden;
// Inner div fills the Rnd-controlled size
&__inner {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
// Drag handle — any child with this class becomes the drag target
.floating-panel__drag-handle {
cursor: move;
}
}

View File

@@ -1,80 +0,0 @@
import { ComponentProps } from 'react';
import { createPortal } from 'react-dom';
import { Rnd } from 'react-rnd';
import './FloatingPanel.styles.scss';
type EnableResizing = ComponentProps<typeof Rnd>['enableResizing'];
export interface FloatingPanelProps {
isOpen: boolean;
children: React.ReactNode;
defaultPosition?: { x: number; y: number };
width?: number;
height?: number;
minWidth?: number;
minHeight?: number;
enableResizing?: EnableResizing;
className?: string;
}
function FloatingPanel({
isOpen,
children,
defaultPosition,
width = 560,
height = 600,
minWidth = 400,
minHeight = 300,
enableResizing,
className,
}: FloatingPanelProps): JSX.Element | null {
if (!isOpen) {
return null;
}
const initialPosition = defaultPosition || {
x: window.innerWidth - width - 24,
y: 80,
};
return createPortal(
<Rnd
default={{
x: initialPosition.x,
y: initialPosition.y,
width,
height,
}}
dragHandleClassName="floating-panel__drag-handle"
minWidth={minWidth}
minHeight={minHeight}
onDrag={(_e, d): void | false => {
const HEADER_HEIGHT = 40;
// Top: don't allow header to go above viewport
if (d.y < 0) {
return false;
}
// Left: don't allow panel to go off-screen left
if (d.x < 0) {
return false;
}
// Bottom: only header needs to be visible
if (d.y > window.innerHeight - HEADER_HEIGHT) {
return false;
}
// Right: at least the close button (~40px) stays visible
if (d.x > window.innerWidth - 40) {
return false;
}
}}
className={`floating-panel ${className || ''}`}
enableResizing={enableResizing}
>
<div className="floating-panel__inner">{children}</div>
</Rnd>,
document.body,
);
}
export default FloatingPanel;

View File

@@ -1,2 +0,0 @@
export type { FloatingPanelProps } from './FloatingPanel';
export { default as FloatingPanel } from './FloatingPanel';

View File

@@ -1,22 +0,0 @@
.json-view {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
&__footer {
flex-shrink: 0;
height: 36px;
display: flex;
align-items: center;
border-top: 1px solid var(--l2-border);
}
&__wrap-toggle {
display: flex;
gap: 8px;
margin-left: 12px;
align-items: center;
}
}

View File

@@ -1,78 +0,0 @@
import { useState } from 'react';
import MEditor, { EditorProps, Monaco } from '@monaco-editor/react';
import { Color } from '@signozhq/design-tokens';
import { Switch, Typography } from 'antd';
import { useIsDarkMode } from 'hooks/useDarkMode';
import './JsonView.styles.scss';
export interface JsonViewProps {
data: string;
height?: string;
}
const editorOptions: EditorProps['options'] = {
automaticLayout: true,
readOnly: true,
wordWrap: 'on',
minimap: { enabled: false },
fontWeight: 400,
fontFamily: 'SF Mono, Geist Mono, Fira Code, monospace',
fontSize: 12,
lineHeight: '18px',
colorDecorators: true,
scrollBeyondLastLine: false,
decorationsOverviewRuler: false,
scrollbar: { vertical: 'hidden', horizontal: 'hidden' },
folding: false,
};
function setEditorTheme(monaco: Monaco): void {
monaco.editor.defineTheme('signoz-dark', {
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'string.key.json', foreground: Color.BG_VANILLA_400 },
{ token: 'string.value.json', foreground: Color.BG_ROBIN_400 },
],
colors: {
'editor.background': '#00000000', // transparent
},
fontFamily: 'SF Mono, Geist Mono, Fira Code, monospace',
fontSize: 12,
fontWeight: 'normal',
lineHeight: 18,
letterSpacing: -0.06,
});
}
function JsonView({ data, height = '575px' }: JsonViewProps): JSX.Element {
const [isWrapWord, setIsWrapWord] = useState(true);
const isDarkMode = useIsDarkMode();
return (
<div className="json-view">
<MEditor
value={data}
language="json"
options={{ ...editorOptions, wordWrap: isWrapWord ? 'on' : 'off' }}
onChange={(): void => {}}
height={height}
theme={isDarkMode ? 'signoz-dark' : 'light'}
beforeMount={setEditorTheme}
/>
<div className="json-view__footer">
<div className="json-view__wrap-toggle">
<Typography.Text>Wrap text</Typography.Text>
<Switch
checked={isWrapWord}
onChange={(checked): void => setIsWrapWord(checked)}
size="small"
/>
</div>
</div>
</div>
);
}
export default JsonView;

View File

@@ -1,2 +0,0 @@
export type { JsonViewProps } from './JsonView';
export { default as JsonView } from './JsonView';

View File

@@ -1,67 +1,43 @@
.key-value-label {
display: flex;
border: 1px solid var(--l2-border);
align-items: center;
border: 1px solid var(--bg-slate-400);
border-radius: 2px;
&--row {
align-items: center;
flex-direction: row;
flex-wrap: wrap;
}
&--column {
flex-direction: column;
border: none;
border-radius: 0;
gap: 4px;
padding: 8px;
.key-value-label__key {
background: transparent;
border-radius: 0;
padding: 0;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.48px;
line-height: 20px;
}
.key-value-label__value {
background: transparent;
padding: 0;
font-size: 14px;
line-height: 20px;
}
}
flex-wrap: wrap;
&__key,
&__value {
padding: 6px;
padding: 1px 6px;
font-size: 14px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.005em;
&,
&:hover {
color: var(--l1-foreground);
color: var(--text-vanilla-400);
}
}
&__key {
background: var(--l2-background);
background: var(--bg-ink-400);
border-radius: 2px 0 0 2px;
}
&__value {
background: var(--l3-background);
background: var(--bg-slate-400);
}
}
&--clickable {
cursor: pointer;
&:hover {
opacity: 0.8;
}
.lightMode {
.key-value-label {
border-color: var(--bg-vanilla-400);
&__key,
&__value {
color: var(--text-ink-400);
}
&__key {
background: var(--bg-vanilla-300);
}
&__value {
background: var(--bg-vanilla-200);
}
}
}

View File

@@ -1,61 +1,29 @@
import { useMemo } from 'react';
import { Tooltip } from 'antd';
import TrimmedText from '../TrimmedText/TrimmedText';
import './KeyValueLabel.styles.scss';
// Rethink this component later
type KeyValueLabelProps = {
badgeKey: string | React.ReactNode;
badgeValue: string | React.ReactNode;
direction?: 'row' | 'column';
badgeValue: string;
maxCharacters?: number;
onClick?: () => void;
};
export default function KeyValueLabel({
badgeKey,
badgeValue,
direction = 'row',
maxCharacters = 20,
onClick,
}: KeyValueLabelProps): JSX.Element | null {
const isUrl = useMemo(() => /^https?:\/\//.test(badgeValue), [badgeValue]);
if (!badgeKey || !badgeValue) {
return null;
}
const renderValue = (): JSX.Element => {
if (typeof badgeValue !== 'string') {
return <div className="key-value-label__value">{badgeValue}</div>;
}
return (
<Tooltip title={badgeValue}>
<div
className={`key-value-label__value ${
onClick ? 'key-value-label__value--clickable' : ''
}`}
onClick={onClick}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
onKeyDown={
onClick
? (e): void => {
if (e.key === 'Enter' || e.key === ' ') {
onClick();
}
}
: undefined
}
>
<TrimmedText text={badgeValue} maxCharacters={maxCharacters} />
</div>
</Tooltip>
);
};
return (
<div className={`key-value-label key-value-label--${direction}`}>
<div className="key-value-label">
<div className="key-value-label__key">
{typeof badgeKey === 'string' ? (
<TrimmedText text={badgeKey} maxCharacters={maxCharacters} />
@@ -63,13 +31,26 @@ export default function KeyValueLabel({
badgeKey
)}
</div>
{renderValue()}
{isUrl ? (
<a
href={badgeValue}
target="_blank"
rel="noopener noreferrer"
className="key-value-label__value"
>
<TrimmedText text={badgeValue} maxCharacters={maxCharacters} />
</a>
) : (
<Tooltip title={badgeValue}>
<div className="key-value-label__value">
<TrimmedText text={badgeValue} maxCharacters={maxCharacters} />
</div>
</Tooltip>
)}
</div>
);
}
KeyValueLabel.defaultProps = {
maxCharacters: 20,
direction: 'row',
onClick: undefined,
};

View File

@@ -1,191 +0,0 @@
.pretty-view {
padding: 0;
flex: 1;
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
&__search-input {
position: sticky;
top: 0;
z-index: 1;
margin-bottom: 8px;
width: 100%;
border: 1px solid var(--l2-border) !important;
border-radius: 4px;
font-family: 'SF Mono', 'Geist Mono', 'Fira Code', monospace !important;
font-size: 12px !important;
line-height: 18px !important;
background: var(--l1-background) !important;
outline: none !important;
box-shadow: none !important;
&:focus {
border-color: var(--primary) !important;
outline: none !important;
box-shadow: none !important;
}
}
// Font spec: SF Mono, 12px, 400, 18px line-height, -0.5% letter-spacing
font-family: 'SF Mono', 'Geist Mono', 'Fira Code', monospace;
font-size: 12px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.06px;
// Override react-json-tree inline styles
ul {
margin: 0 !important;
padding-left: 16px !important;
list-style: none !important;
background-color: transparent !important;
flex: 1;
}
> ul,
&__pinned > ul {
padding-left: 0 !important;
}
// Force font on all tree elements
li,
span,
label {
font-family: inherit !important;
font-size: inherit !important;
line-height: inherit !important;
letter-spacing: inherit !important;
}
// Fix react-json-tree text-indent wrapping
li {
text-indent: 0 !important;
margin-left: 0 !important;
padding-top: 2px !important;
padding-bottom: 2px !important;
}
.pretty-view__actions {
margin-right: 16px;
}
// Leaf node row — hover highlights only this row
&__row {
border-radius: 2px;
display: flex !important;
align-items: baseline;
&:hover {
background-color: var(--l3-background);
.pretty-view__actions {
opacity: 1;
}
}
// Push actions to the right edge
> span {
flex: 1;
}
}
// Nested node (object/array) — hover only on the label line, not children
&__nested-row {
> label,
> span:not(ul span) {
border-radius: 2px;
padding: 1px 2px;
display: inline !important; // keep item string inline with label
}
// Highlight label + item string on hover, show actions
&:hover > label,
&:hover > label + span {
background-color: var(--l3-background);
}
&:hover > label + span .pretty-view__actions {
opacity: 1;
}
// In nested rows, value-row should not take full width
.pretty-view__value-row {
width: auto;
}
}
// Value + actions wrapper (from valueRenderer / getItemString)
&__value-row {
display: inline-flex;
align-items: center;
gap: 6px;
}
// In leaf rows, value-row takes full width to push ... to the right
&__row &__value-row {
justify-content: space-between;
width: 100%;
}
// ... actions button — hidden by default, shown on row hover
&__actions {
opacity: 0;
display: inline-flex;
align-items: center;
cursor: pointer;
color: var(--l2-foreground);
transition: opacity 0.1s ease;
padding: 0 2px;
&:hover {
color: var(--l1-foreground);
}
}
// Pinned items section
&__pinned {
border-bottom: 1px solid var(--l2-border);
padding-bottom: 8px;
margin-bottom: 8px;
padding-left: 0px;
li {
margin-left: 0 !important;
padding-left: 0 !important;
display: flex !important;
align-items: baseline;
}
}
&__pinned-header {
font-size: 11px;
font-weight: 500;
color: var(--l3-foreground);
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 4px;
}
&__pinned-label {
display: inline-flex;
align-items: baseline;
gap: 6px;
}
&__pinned-icon {
flex-shrink: 0;
color: var(--text-robin-400);
cursor: pointer;
fill: var(--text-robin-400);
transition: fill 0.1s ease;
&:hover {
color: var(--text-robin-300);
fill: transparent;
}
}
}

View File

@@ -1,310 +0,0 @@
import { useCallback, useMemo } from 'react';
import { JSONTree, KeyPath } from 'react-json-tree';
import { useCopyToClipboard } from 'react-use';
import { Copy, Ellipsis, Pin, PinOff } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { toast } from '@signozhq/sonner';
import type { MenuProps } from 'antd';
// TODO: Replace antd Dropdown with @signozhq/ui component when moving to design library
import { Dropdown } from 'antd';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { darkTheme, lightTheme, themeExtension } from './constants';
import usePinnedFields from './hooks/usePinnedFields';
import useSearchFilter, { filterTree } from './hooks/useSearchFilter';
import {
keyPathToDisplayString,
keyPathToForward,
serializeKeyPath,
} from './utils';
import './PrettyView.styles.scss';
export interface FieldContext {
fieldKey: string;
fieldKeyPath: (string | number)[];
fieldValue: unknown;
isNested: boolean;
}
export interface PrettyViewAction {
key: string;
label: React.ReactNode;
icon?: React.ReactNode;
onClick: (context: FieldContext) => void;
}
export interface PrettyViewProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: Record<string, any>;
actions?: PrettyViewAction[];
searchable?: boolean;
showPinned?: boolean;
drawerKey?: string;
}
function PrettyView({
data,
actions,
searchable = true,
showPinned = false,
drawerKey = 'default',
}: PrettyViewProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const [, setCopy] = useCopyToClipboard();
const { searchQuery, setSearchQuery, filteredData } = useSearchFilter(data);
const {
isPinned,
togglePin,
pinnedEntries,
pinnedData,
displayKeyToForwardPath,
} = usePinnedFields(data, drawerKey);
const filteredPinnedData = useMemo(() => {
const trimmed = searchQuery.trim();
if (!trimmed) {
return pinnedData;
}
return filterTree(pinnedData, trimmed) || {};
}, [pinnedData, searchQuery]);
const theme = useMemo(
() => ({
extend: isDarkMode ? darkTheme : lightTheme,
...themeExtension,
}),
[isDarkMode],
);
const shouldExpandNodeInitially = useCallback(
(
_keyPath: readonly (string | number)[],
_data: unknown,
level: number,
): boolean => level < 5,
[],
);
const buildMenuItems = useCallback(
(context: FieldContext): MenuProps['items'] => {
// todo: drive dropdown through config.
const copyItem = {
key: 'copy',
label: 'Copy',
icon: <Copy size={12} />,
onClick: (): void => {
const text =
typeof context.fieldValue === 'object'
? JSON.stringify(context.fieldValue, null, 2)
: String(context.fieldValue);
setCopy(text);
toast.success('Copied to clipboard', {
richColors: true,
position: 'top-right',
});
},
};
const items: NonNullable<MenuProps['items']> = [copyItem];
// Pin action only for leaf nodes
if (!context.isNested) {
// Resolve the correct forward path — pinned tree uses display keys
// which don't match the original serialized path
const resolvedPath =
displayKeyToForwardPath[context.fieldKey] || context.fieldKeyPath;
const serialized = serializeKeyPath(resolvedPath);
const pinned = isPinned(serialized);
items.push({
key: 'pin',
label: pinned ? 'Unpin field' : 'Pin field',
icon: pinned ? <PinOff size={12} /> : <Pin size={12} />,
onClick: (): void => {
togglePin(resolvedPath);
},
});
}
if (actions && actions.length > 0) {
//todo: why this divider?
items.push({ type: 'divider' as const, key: 'divider' });
actions.forEach((action) => {
items.push({
key: action.key,
label: action.label,
icon: action.icon,
onClick: (): void => {
action.onClick(context);
},
});
});
}
return items;
},
[actions, isPinned, togglePin, displayKeyToForwardPath],
);
const renderWithActions = useCallback(
({
content,
fieldKey,
fieldKeyPath,
value,
isNested,
}: {
content: React.ReactNode;
fieldKey: string;
fieldKeyPath: (string | number)[];
value: unknown;
isNested: boolean;
}): React.ReactNode => {
const context: FieldContext = {
fieldKey,
fieldKeyPath,
fieldValue: value,
isNested,
};
const menuItems = buildMenuItems(context);
return (
<span className="pretty-view__value-row">
<span>{content}</span>
<Dropdown
menu={{
items: menuItems,
onClick: (e): void => {
e.domEvent.stopPropagation();
},
}}
trigger={['click']}
placement="bottomLeft"
getPopupContainer={(trigger): HTMLElement =>
trigger.parentElement || document.body
}
>
<span
className="pretty-view__actions"
onClick={(e): void => e.stopPropagation()}
role="button"
tabIndex={0}
>
<Ellipsis size={12} />
</span>
</Dropdown>
</span>
);
},
[buildMenuItems],
);
// eslint-disable-next-line max-params
const getItemString = useCallback(
(
_nodeType: string,
nodeData: unknown,
itemType: React.ReactNode,
itemString: string,
keyPath: KeyPath,
): React.ReactNode => {
const forwardPath = keyPathToForward(keyPath);
return renderWithActions({
content: (
<>
{itemType} {itemString}
</>
),
fieldKey: keyPathToDisplayString(keyPath),
fieldKeyPath: forwardPath,
value: nodeData,
isNested: true,
});
},
[renderWithActions],
);
const valueRenderer = useCallback(
(
valueAsString: unknown,
value: unknown,
...keyPath: KeyPath
): React.ReactNode => {
const forwardPath = keyPathToForward(keyPath);
return renderWithActions({
content: String(valueAsString),
fieldKey: keyPathToDisplayString(keyPath),
fieldKeyPath: forwardPath,
value,
isNested: typeof value === 'object' && value !== null,
});
},
[renderWithActions],
);
const pinnedLabelRenderer = useCallback(
(keyPath: KeyPath): React.ReactNode => {
const displayKey = String(keyPath[0]);
const entry = pinnedEntries.find((e) => e.displayKey === displayKey);
return (
<span className="pretty-view__pinned-label">
<Pin
size={12}
className="pretty-view__pinned-icon"
onClick={(): void => {
if (entry) {
togglePin(entry.forwardPath);
}
}}
/>
<span>{displayKey}</span>
</span>
);
},
[togglePin, pinnedEntries],
);
return (
<div className="pretty-view">
{searchable && (
<Input
className="pretty-view__search-input"
type="text"
placeholder="Search for a field..."
value={searchQuery}
onChange={(e): void => setSearchQuery(e.target.value)}
/>
)}
{showPinned && Object.keys(filteredPinnedData).length > 0 && (
<div className="pretty-view__pinned">
<div className="pretty-view__pinned-header">PINNED ITEMS</div>
<JSONTree
key={`pinned-${searchQuery}`}
data={filteredPinnedData}
theme={theme}
invertTheme={false}
hideRoot
shouldExpandNodeInitially={shouldExpandNodeInitially}
valueRenderer={valueRenderer}
getItemString={getItemString}
labelRenderer={pinnedLabelRenderer}
/>
</div>
)}
<JSONTree
key={searchQuery}
data={filteredData}
theme={theme}
invertTheme={false}
hideRoot
shouldExpandNodeInitially={shouldExpandNodeInitially}
valueRenderer={valueRenderer}
getItemString={getItemString}
/>
</div>
);
}
export default PrettyView;

Some files were not shown because too many files have changed in this diff Show More