mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-14 09:02:15 +00:00
Compare commits
22 Commits
debug_time
...
feat/trace
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e2cf57819 | ||
|
|
dc9ebc5b26 | ||
|
|
398ab6e9d9 | ||
|
|
fec60671d8 | ||
|
|
99259cc4e8 | ||
|
|
ca311717c2 | ||
|
|
a614da2c65 | ||
|
|
ce18709002 | ||
|
|
2b6977e891 | ||
|
|
3e6eedbcab | ||
|
|
fd9e3f0411 | ||
|
|
e99465e030 | ||
|
|
9ad2db4b99 | ||
|
|
07fd5f70ef | ||
|
|
ba79121795 | ||
|
|
6e4e419b5e | ||
|
|
2f06afaf27 | ||
|
|
f77c3cb23c | ||
|
|
9e3a8efcfc | ||
|
|
8e325ba8b3 | ||
|
|
884f516766 | ||
|
|
4bcbb4ffc3 |
@@ -0,0 +1,4 @@
|
|||||||
|
.timeline-v3-container {
|
||||||
|
// flex: 1;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
87
frontend/src/components/TimelineV3/TimelineV3.tsx
Normal file
87
frontend/src/components/TimelineV3/TimelineV3.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
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;
|
||||||
93
frontend/src/components/TimelineV3/utils.ts
Normal file
93
frontend/src/components/TimelineV3/utils.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -33,6 +33,125 @@ const themeColors = {
|
|||||||
purple: '#800080',
|
purple: '#800080',
|
||||||
cyan: '#00FFFF',
|
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: {
|
chartcolors: {
|
||||||
// Blues (3)
|
// Blues (3)
|
||||||
dodgerBlue: '#2F80ED',
|
dodgerBlue: '#2F80ED',
|
||||||
|
|||||||
@@ -4,19 +4,11 @@ import ROUTES from 'constants/routes';
|
|||||||
import history from 'lib/history';
|
import history from 'lib/history';
|
||||||
import { Compass, Cone, TowerControl } from 'lucide-react';
|
import { Compass, Cone, TowerControl } from 'lucide-react';
|
||||||
|
|
||||||
import TraceDetailsV2 from './TraceDetailV2';
|
import TraceDetailsV3 from '../TraceDetailsV3';
|
||||||
|
|
||||||
import './TraceDetailV2.styles.scss';
|
import './TraceDetailV2.styles.scss';
|
||||||
|
|
||||||
interface INewTraceDetailProps {
|
function NewTraceDetail(props: any): JSX.Element {
|
||||||
items: {
|
|
||||||
label: JSX.Element;
|
|
||||||
key: string;
|
|
||||||
children: JSX.Element;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function NewTraceDetail(props: INewTraceDetailProps): JSX.Element {
|
|
||||||
const { items } = props;
|
const { items } = props;
|
||||||
return (
|
return (
|
||||||
<div className="traces-module-container">
|
<div className="traces-module-container">
|
||||||
@@ -50,7 +42,7 @@ export default function TraceDetailsPage(): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
key: 'trace-details',
|
key: 'trace-details',
|
||||||
children: <TraceDetailsV2 />,
|
children: <TraceDetailsV3 />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: (
|
label: (
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
.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
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
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 { 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 { FlamegraphCanvasProps, SpanRect } from './types';
|
||||||
|
import { formatDuration } from './utils';
|
||||||
|
|
||||||
|
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 [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 totalHeight = spans.length * rowHeight;
|
||||||
|
|
||||||
|
const { isOverFlamegraphRef } = useFlamegraphZoom({
|
||||||
|
canvasRef,
|
||||||
|
traceMetadata,
|
||||||
|
viewStartRef,
|
||||||
|
viewEndRef,
|
||||||
|
rowHeightRef,
|
||||||
|
setViewStartTs,
|
||||||
|
setViewEndTs,
|
||||||
|
setRowHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleMouseDown,
|
||||||
|
handleMouseMove: handleDragMouseMove,
|
||||||
|
handleMouseUp,
|
||||||
|
handleDragMouseLeave,
|
||||||
|
suppressClickRef,
|
||||||
|
isDraggingRef,
|
||||||
|
} = useFlamegraphDrag({
|
||||||
|
canvasRef,
|
||||||
|
containerRef,
|
||||||
|
traceMetadata,
|
||||||
|
viewStartRef,
|
||||||
|
viewEndRef,
|
||||||
|
setViewStartTs,
|
||||||
|
setViewEndTs,
|
||||||
|
scrollTopRef,
|
||||||
|
setScrollTop,
|
||||||
|
totalHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
hoveredSpanId,
|
||||||
|
handleHoverMouseMove,
|
||||||
|
handleHoverMouseLeave,
|
||||||
|
handleClick,
|
||||||
|
tooltipContent,
|
||||||
|
} = useFlamegraphHover({
|
||||||
|
canvasRef,
|
||||||
|
spanRectsRef,
|
||||||
|
traceMetadata,
|
||||||
|
viewStartTs,
|
||||||
|
viewEndTs,
|
||||||
|
isDraggingRef,
|
||||||
|
suppressClickRef,
|
||||||
|
onSpanClick,
|
||||||
|
isDarkMode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { drawFlamegraph } = useFlamegraphDraw({
|
||||||
|
canvasRef,
|
||||||
|
containerRef,
|
||||||
|
spans,
|
||||||
|
viewStartTs,
|
||||||
|
viewEndTs,
|
||||||
|
scrollTop,
|
||||||
|
rowHeight,
|
||||||
|
selectedSpanId: firstSpanAtFetchLevel || undefined,
|
||||||
|
hoveredSpanId: hoveredSpanId ?? '',
|
||||||
|
isDarkMode,
|
||||||
|
spanRectsRef,
|
||||||
|
});
|
||||||
|
|
||||||
|
useScrollToSpan({
|
||||||
|
firstSpanAtFetchLevel,
|
||||||
|
spans,
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// todo: move to a separate component/utils file
|
||||||
|
const tooltipElement = tooltipContent
|
||||||
|
? createPortal(
|
||||||
|
<div
|
||||||
|
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)',
|
||||||
|
color: '#fff',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'Inter, sans-serif',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
marginBottom: 4,
|
||||||
|
color: tooltipContent.spanColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tooltipContent.spanName}
|
||||||
|
</div>
|
||||||
|
<div>Status: {tooltipContent.status}</div>
|
||||||
|
<div>Start: {tooltipContent.startMs.toFixed(2)} ms</div>
|
||||||
|
<div>Duration: {formatDuration(tooltipContent.durationMs * 1e6)}</div>
|
||||||
|
</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={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onClick={handleClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FlamegraphCanvas;
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -0,0 +1,525 @@
|
|||||||
|
import { DASHED_BORDER_LINE_DASH, MIN_WIDTH_FOR_NAME } from '../constants';
|
||||||
|
import type { FlamegraphRowMetrics } from '../utils';
|
||||||
|
import { getFlamegraphRowMetrics } from '../utils';
|
||||||
|
import { drawEventDot, drawSpanBar } 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,
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
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: [],
|
||||||
|
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: [],
|
||||||
|
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: [],
|
||||||
|
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: [],
|
||||||
|
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();
|
||||||
|
|
||||||
|
drawEventDot({
|
||||||
|
ctx,
|
||||||
|
x: 50,
|
||||||
|
y: 11,
|
||||||
|
isError: true,
|
||||||
|
isDarkMode: false,
|
||||||
|
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('uses normal styling when isError is false', () => {
|
||||||
|
const ctx = createMockCtx();
|
||||||
|
|
||||||
|
drawEventDot({
|
||||||
|
ctx,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
isError: false,
|
||||||
|
isDarkMode: false,
|
||||||
|
eventDotSize: 6,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ctx.fillStyle).toBe('rgb(6, 182, 212)');
|
||||||
|
expect(ctx.strokeStyle).toBe('rgb(8, 145, 178)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses dark mode colors for error', () => {
|
||||||
|
const ctx = createMockCtx();
|
||||||
|
|
||||||
|
drawEventDot({
|
||||||
|
ctx,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
isError: true,
|
||||||
|
isDarkMode: true,
|
||||||
|
eventDotSize: 6,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ctx.fillStyle).toBe('rgb(239, 68, 68)');
|
||||||
|
expect(ctx.strokeStyle).toBe('rgb(185, 28, 28)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses dark mode colors for non-error', () => {
|
||||||
|
const ctx = createMockCtx();
|
||||||
|
|
||||||
|
drawEventDot({
|
||||||
|
ctx,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
isError: false,
|
||||||
|
isDarkMode: true,
|
||||||
|
eventDotSize: 6,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ctx.fillStyle).toBe('rgb(14, 165, 233)');
|
||||||
|
expect(ctx.strokeStyle).toBe('rgb(2, 132, 199)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls save, translate, rotate, restore', () => {
|
||||||
|
const ctx = createMockCtx();
|
||||||
|
|
||||||
|
drawEventDot({
|
||||||
|
ctx,
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
isError: false,
|
||||||
|
isDarkMode: false,
|
||||||
|
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: [],
|
||||||
|
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: [],
|
||||||
|
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: [],
|
||||||
|
color: '#000',
|
||||||
|
isDarkMode: false,
|
||||||
|
metrics: METRICS,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ctx.save).toHaveBeenCalledTimes(3);
|
||||||
|
expect(ctx.translate).toHaveBeenCalledTimes(2);
|
||||||
|
expect(ctx.fillRect).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
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('does not set suppressClickRef when movement is below threshold', () => {
|
||||||
|
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: 102,
|
||||||
|
clientY: 51,
|
||||||
|
} as unknown) as React.MouseEvent);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleMouseUp();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.suppressClickRef.current).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets suppressClickRef when drag exceeds threshold', () => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleMouseUp();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.suppressClickRef.current).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
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] },
|
||||||
|
traceMetadata: MOCK_TRACE_METADATA,
|
||||||
|
viewStartTs: MOCK_TRACE_METADATA.startTime,
|
||||||
|
viewEndTs: MOCK_TRACE_METADATA.endTime,
|
||||||
|
isDraggingRef: { current: false },
|
||||||
|
suppressClickRef: { 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;
|
||||||
|
defaultArgs.suppressClickRef.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 suppressClickRef is set', () => {
|
||||||
|
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||||
|
defaultArgs.suppressClickRef.current = true;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleClick({
|
||||||
|
clientX: 150,
|
||||||
|
clientY: 61,
|
||||||
|
} 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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];
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
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;
|
||||||
|
suppressClickRef: MutableRefObject<boolean>;
|
||||||
|
isDraggingRef: MutableRefObject<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DRAG_THRESHOLD = 5;
|
||||||
|
|
||||||
|
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 suppressClickRef = useRef(false);
|
||||||
|
|
||||||
|
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 => {
|
||||||
|
const wasDrag = dragDistanceRef.current > DRAG_THRESHOLD;
|
||||||
|
suppressClickRef.current = wasDrag;
|
||||||
|
|
||||||
|
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,
|
||||||
|
suppressClickRef,
|
||||||
|
isDraggingRef,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import React, { RefObject, useCallback, useRef } from 'react';
|
||||||
|
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||||
|
|
||||||
|
import { SpanRect } from '../types';
|
||||||
|
import {
|
||||||
|
clamp,
|
||||||
|
drawSpanBar,
|
||||||
|
FlamegraphRowMetrics,
|
||||||
|
getFlamegraphRowMetrics,
|
||||||
|
getSpanColor,
|
||||||
|
} from '../utils';
|
||||||
|
|
||||||
|
interface UseFlamegraphDrawArgs {
|
||||||
|
canvasRef: RefObject<HTMLCanvasElement>;
|
||||||
|
containerRef: RefObject<HTMLDivElement>;
|
||||||
|
spans: FlamegraphSpan[][];
|
||||||
|
viewStartTs: number;
|
||||||
|
viewEndTs: number;
|
||||||
|
scrollTop: number;
|
||||||
|
rowHeight: number;
|
||||||
|
selectedSpanId: string | undefined;
|
||||||
|
hoveredSpanId: string;
|
||||||
|
isDarkMode: boolean;
|
||||||
|
spanRectsRef?: React.MutableRefObject<SpanRect[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseFlamegraphDrawResult {
|
||||||
|
drawFlamegraph: () => void;
|
||||||
|
spanRectsRef: RefObject<SpanRect[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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[];
|
||||||
|
metrics: FlamegraphRowMetrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawLevel(args: DrawLevelArgs): void {
|
||||||
|
const {
|
||||||
|
ctx,
|
||||||
|
levelSpans,
|
||||||
|
levelIndex,
|
||||||
|
y,
|
||||||
|
viewStartTs,
|
||||||
|
timeSpan,
|
||||||
|
cssWidth,
|
||||||
|
selectedSpanId,
|
||||||
|
hoveredSpanId,
|
||||||
|
isDarkMode,
|
||||||
|
spanRectsArray,
|
||||||
|
metrics,
|
||||||
|
} = 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,
|
||||||
|
color,
|
||||||
|
isDarkMode,
|
||||||
|
metrics,
|
||||||
|
selectedSpanId,
|
||||||
|
hoveredSpanId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFlamegraphDraw(
|
||||||
|
args: UseFlamegraphDrawArgs,
|
||||||
|
): UseFlamegraphDrawResult {
|
||||||
|
const {
|
||||||
|
canvasRef,
|
||||||
|
containerRef,
|
||||||
|
spans,
|
||||||
|
viewStartTs,
|
||||||
|
viewEndTs,
|
||||||
|
scrollTop,
|
||||||
|
rowHeight,
|
||||||
|
selectedSpanId,
|
||||||
|
hoveredSpanId,
|
||||||
|
isDarkMode,
|
||||||
|
spanRectsRef: spanRectsRefProp,
|
||||||
|
} = args;
|
||||||
|
|
||||||
|
const spanRectsRefInternal = useRef<SpanRect[]>([]);
|
||||||
|
const spanRectsRef = spanRectsRefProp ?? spanRectsRefInternal;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
const spanRectsArray: SpanRect[] = [];
|
||||||
|
|
||||||
|
// ---- 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,
|
||||||
|
metrics,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
spanRectsRef.current = spanRectsArray;
|
||||||
|
}, [
|
||||||
|
canvasRef,
|
||||||
|
containerRef,
|
||||||
|
spanRectsRef,
|
||||||
|
spans,
|
||||||
|
viewStartTs,
|
||||||
|
viewEndTs,
|
||||||
|
scrollTop,
|
||||||
|
rowHeight,
|
||||||
|
selectedSpanId,
|
||||||
|
hoveredSpanId,
|
||||||
|
isDarkMode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// TODO: spanRectsRef is a flat array — hover scans all visible rects O(N).
|
||||||
|
// Upgrade to per-level buckets: spanRects[levelIndex] = [...] so hover can
|
||||||
|
// compute level from mouseY / ROW_HEIGHT and scan only that row.
|
||||||
|
// Further: binary search within a level by x (spans are sorted by start time)
|
||||||
|
// to reduce hover cost from O(N) to O(log N).
|
||||||
|
return { drawFlamegraph, spanRectsRef };
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import {
|
||||||
|
Dispatch,
|
||||||
|
MouseEvent as ReactMouseEvent,
|
||||||
|
MutableRefObject,
|
||||||
|
RefObject,
|
||||||
|
SetStateAction,
|
||||||
|
useCallback,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||||
|
|
||||||
|
import { 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TooltipContent {
|
||||||
|
spanName: string;
|
||||||
|
status: 'ok' | 'warning' | 'error';
|
||||||
|
startMs: number;
|
||||||
|
durationMs: number;
|
||||||
|
clientX: number;
|
||||||
|
clientY: number;
|
||||||
|
spanColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseFlamegraphHoverArgs {
|
||||||
|
canvasRef: RefObject<HTMLCanvasElement>;
|
||||||
|
spanRectsRef: MutableRefObject<SpanRect[]>;
|
||||||
|
traceMetadata: ITraceMetadata;
|
||||||
|
viewStartTs: number;
|
||||||
|
viewEndTs: number;
|
||||||
|
isDraggingRef: MutableRefObject<boolean>;
|
||||||
|
suppressClickRef: MutableRefObject<boolean>;
|
||||||
|
onSpanClick: (spanId: string) => void;
|
||||||
|
isDarkMode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseFlamegraphHoverResult {
|
||||||
|
hoveredSpanId: string | null;
|
||||||
|
setHoveredSpanId: Dispatch<SetStateAction<string | null>>;
|
||||||
|
handleHoverMouseMove: (e: ReactMouseEvent) => void;
|
||||||
|
handleHoverMouseLeave: () => void;
|
||||||
|
handleClick: (e: ReactMouseEvent) => void;
|
||||||
|
tooltipContent: TooltipContent | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFlamegraphHover(
|
||||||
|
args: UseFlamegraphHoverArgs,
|
||||||
|
): UseFlamegraphHoverResult {
|
||||||
|
const {
|
||||||
|
canvasRef,
|
||||||
|
spanRectsRef,
|
||||||
|
traceMetadata,
|
||||||
|
viewStartTs,
|
||||||
|
viewEndTs,
|
||||||
|
isDraggingRef,
|
||||||
|
suppressClickRef,
|
||||||
|
onSpanClick,
|
||||||
|
isDarkMode,
|
||||||
|
} = args;
|
||||||
|
|
||||||
|
const [hoveredSpanId, setHoveredSpanId] = 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const span = findSpanAtPosition(
|
||||||
|
pointer.cssX,
|
||||||
|
pointer.cssY,
|
||||||
|
spanRectsRef.current,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (span) {
|
||||||
|
setHoveredSpanId(span.spanId);
|
||||||
|
setTooltipContent({
|
||||||
|
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 {
|
||||||
|
setHoveredSpanId(null);
|
||||||
|
setTooltipContent(null);
|
||||||
|
updateCursor(canvas, null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
canvasRef,
|
||||||
|
spanRectsRef,
|
||||||
|
traceMetadata.startTime,
|
||||||
|
isDraggingRef,
|
||||||
|
updateCursor,
|
||||||
|
isDarkMode,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleHoverMouseLeave = useCallback((): void => {
|
||||||
|
setHoveredSpanId(null);
|
||||||
|
setTooltipContent(null);
|
||||||
|
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (canvas) {
|
||||||
|
updateCursor(canvas, null);
|
||||||
|
}
|
||||||
|
}, [canvasRef, updateCursor]);
|
||||||
|
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(e: ReactMouseEvent): void => {
|
||||||
|
if (suppressClickRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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, suppressClickRef, onSpanClick],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hoveredSpanId,
|
||||||
|
setHoveredSpanId,
|
||||||
|
handleHoverMouseMove,
|
||||||
|
handleHoverMouseLeave,
|
||||||
|
handleClick,
|
||||||
|
tooltipContent,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
24
frontend/src/pages/TraceDetailsV3/TraceFlamegraph/types.ts
Normal file
24
frontend/src/pages/TraceDetailsV3/TraceFlamegraph/types.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Dispatch, SetStateAction } from 'react';
|
||||||
|
import { 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;
|
||||||
|
}
|
||||||
355
frontend/src/pages/TraceDetailsV3/TraceFlamegraph/utils.ts
Normal file
355
frontend/src/pages/TraceDetailsV3/TraceFlamegraph/utils.ts
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
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 { 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DrawEventDotArgs {
|
||||||
|
ctx: CanvasRenderingContext2D;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
isError: boolean;
|
||||||
|
isDarkMode: boolean;
|
||||||
|
eventDotSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawEventDot(args: DrawEventDotArgs): void {
|
||||||
|
const { ctx, x, y, isError, isDarkMode, eventDotSize } = args;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(x, y);
|
||||||
|
ctx.rotate(Math.PI / 4);
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
ctx.fillStyle = isDarkMode ? 'rgb(239, 68, 68)' : 'rgb(220, 38, 38)';
|
||||||
|
ctx.strokeStyle = isDarkMode ? 'rgb(185, 28, 28)' : 'rgb(153, 27, 27)';
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = isDarkMode ? 'rgb(14, 165, 233)' : 'rgb(6, 182, 212)';
|
||||||
|
ctx.strokeStyle = isDarkMode ? 'rgb(2, 132, 199)' : 'rgb(8, 145, 178)';
|
||||||
|
}
|
||||||
|
|
||||||
|
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[];
|
||||||
|
color: string;
|
||||||
|
isDarkMode: boolean;
|
||||||
|
metrics: FlamegraphRowMetrics;
|
||||||
|
selectedSpanId?: string | null;
|
||||||
|
hoveredSpanId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawSpanBar(args: DrawSpanBarArgs): void {
|
||||||
|
const {
|
||||||
|
ctx,
|
||||||
|
span,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
levelIndex,
|
||||||
|
spanRectsArray,
|
||||||
|
color,
|
||||||
|
isDarkMode,
|
||||||
|
metrics,
|
||||||
|
selectedSpanId,
|
||||||
|
hoveredSpanId,
|
||||||
|
} = 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;
|
||||||
|
|
||||||
|
drawEventDot({
|
||||||
|
ctx,
|
||||||
|
x: eventX,
|
||||||
|
y: eventY,
|
||||||
|
isError: event.isError,
|
||||||
|
isDarkMode,
|
||||||
|
eventDotSize: metrics.EVENT_DOT_SIZE,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
37
frontend/src/pages/TraceDetailsV3/index.tsx
Normal file
37
frontend/src/pages/TraceDetailsV3/index.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
ResizableHandle,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from '@signozhq/resizable';
|
||||||
|
|
||||||
|
import TraceDetailsHeader from './TraceDetailsHeader/TraceDetailsHeader';
|
||||||
|
import TraceFlamegraph from './TraceFlamegraph/TraceFlamegraph';
|
||||||
|
|
||||||
|
function TraceDetailsV3(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 'calc(100vh - 90px)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TraceDetailsHeader />
|
||||||
|
<ResizablePanelGroup
|
||||||
|
direction="vertical"
|
||||||
|
autoSaveId="trace-details-v3-layout"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
<ResizablePanel defaultSize={40} minSize={20} maxSize={80}>
|
||||||
|
<TraceFlamegraph />
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
<ResizablePanel defaultSize={60} minSize={20}>
|
||||||
|
<div />
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TraceDetailsV3;
|
||||||
Reference in New Issue
Block a user