mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
chore: separate timeline components to own modules, fix lint issues (#1813)
Closes HDX-3518 ## Changes - Separates the different Timeline related components into their own modules inside `packages/app/src/components/TimelineChart`. - Fixes lint and TS errors / warnings. ## Testing - Trace waterfall view should work just like before.
This commit is contained in:
parent
53a4b67262
commit
f889c34997
14 changed files with 830 additions and 754 deletions
5
.changeset/little-walls-behave.md
Normal file
5
.changeset/little-walls-behave.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
chore: separate timeline components to own modules, fix lint issues
|
||||
|
|
@ -1,745 +0,0 @@
|
|||
import { memo, RefObject, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { Text, Tooltip } from '@mantine/core';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { color } from '@uiw/react-codemirror';
|
||||
|
||||
import { useFormatTime } from '@/useFormatTime';
|
||||
|
||||
import useResizable from './hooks/useResizable';
|
||||
import { useDrag, usePrevious } from './utils';
|
||||
|
||||
import resizeStyles from '../styles/ResizablePanel.module.scss';
|
||||
import styles from '../styles/TimelineChart.module.scss';
|
||||
|
||||
type SpanEventMarker = {
|
||||
timestamp: number; // ms offset from minOffset
|
||||
name: string;
|
||||
attributes: Record<string, any>;
|
||||
};
|
||||
|
||||
type TimelineEventT = {
|
||||
id: string;
|
||||
start: number;
|
||||
end: number;
|
||||
tooltip: string;
|
||||
color: string;
|
||||
body: React.ReactNode;
|
||||
minWidthPerc?: number;
|
||||
isError?: boolean;
|
||||
markers?: SpanEventMarker[];
|
||||
};
|
||||
|
||||
const SpanEventMarkerComponent = memo(function SpanEventMarkerComponent({
|
||||
marker,
|
||||
eventStart,
|
||||
eventEnd,
|
||||
height,
|
||||
}: {
|
||||
marker: SpanEventMarker;
|
||||
eventStart: number;
|
||||
eventEnd: number;
|
||||
height: number;
|
||||
}) {
|
||||
const formatTime = useFormatTime();
|
||||
// Calculate marker position as percentage within the span bar (0-100%)
|
||||
const spanDuration = eventEnd - eventStart;
|
||||
const markerOffsetFromStart = marker.timestamp - eventStart;
|
||||
const markerPosition =
|
||||
spanDuration > 0 ? (markerOffsetFromStart / spanDuration) * 100 : 0;
|
||||
|
||||
// Format attributes for tooltip
|
||||
const attributeEntries = Object.entries(marker.attributes);
|
||||
const tooltipContent = (
|
||||
<div>
|
||||
<Text size="xxs" c="dimmed" mb="xxs">
|
||||
{formatTime(new Date(marker.timestamp), { format: 'withMs' })}
|
||||
</Text>
|
||||
<Text size="xs">{marker.name}</Text>
|
||||
{attributeEntries.length > 0 && (
|
||||
<div style={{ fontSize: 10, marginTop: 4 }}>
|
||||
{attributeEntries.slice(0, 5).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<span style={{ color: 'var(--color-text-primary)' }}>{key}:</span>{' '}
|
||||
{String(value).length > 50
|
||||
? String(value).substring(0, 50) + '...'
|
||||
: String(value)}
|
||||
</div>
|
||||
))}
|
||||
{attributeEntries.length > 5 && (
|
||||
<div style={{ fontStyle: 'italic' }}>
|
||||
...and {attributeEntries.length - 5} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={tooltipContent}
|
||||
color="var(--color-bg-surface)"
|
||||
withArrow
|
||||
multiline
|
||||
transitionProps={{ transition: 'fade' }}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
maxWidth: 350,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${markerPosition.toFixed(6)}%`,
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 8,
|
||||
height: 8,
|
||||
cursor: 'pointer',
|
||||
zIndex: 10,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
onMouseEnter={e => e.stopPropagation()}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Diamond shape marker */}
|
||||
<div
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
backgroundColor: 'var(--color-bg-success)',
|
||||
transform: 'rotate(45deg)',
|
||||
border: '1px solid #333',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
/>
|
||||
{/* Vertical line extending above and below */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 1,
|
||||
height: height,
|
||||
backgroundColor: 'var(--color-bg-success)',
|
||||
opacity: 0.4,
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
const NewTimelineRow = memo(
|
||||
function NewTimelineRow({
|
||||
events,
|
||||
maxVal,
|
||||
height,
|
||||
eventStyles,
|
||||
onEventHover,
|
||||
scale,
|
||||
offset,
|
||||
}: {
|
||||
events: TimelineEventT[] | undefined;
|
||||
maxVal: number;
|
||||
height: number;
|
||||
scale: number;
|
||||
offset: number;
|
||||
eventStyles?:
|
||||
| React.CSSProperties
|
||||
| ((event: TimelineEventT) => React.CSSProperties);
|
||||
onEventHover?: Function;
|
||||
onEventClick?: (event: any) => any;
|
||||
}) {
|
||||
const onHover = onEventHover ?? (() => {});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="d-flex overflow-hidden"
|
||||
style={{ width: 0, flexGrow: 1, height, position: 'relative' }}
|
||||
>
|
||||
<div
|
||||
style={{ marginRight: `${(-1 * offset * scale).toFixed(6)}%` }}
|
||||
></div>
|
||||
{(events ?? []).map((e: TimelineEventT, i, arr) => {
|
||||
const minWidth = (e.minWidthPerc ?? 0) / 100;
|
||||
const lastEvent = arr[i - 1];
|
||||
const lastEventMinEnd =
|
||||
lastEvent?.start != null ? lastEvent?.start + maxVal * minWidth : 0;
|
||||
const lastEventEnd = Math.max(lastEvent?.end ?? 0, lastEventMinEnd);
|
||||
|
||||
const percWidth =
|
||||
scale * Math.max((e.end - e.start) / maxVal, minWidth) * 100;
|
||||
const percMarginLeft =
|
||||
scale * (((e.start - lastEventEnd) / maxVal) * 100);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={e.id}
|
||||
label={e.tooltip}
|
||||
color="gray"
|
||||
withArrow
|
||||
multiline
|
||||
transitionProps={{ transition: 'fade-right' }}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
maxWidth: 300,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onMouseEnter={() => onHover(e.id)}
|
||||
className="d-flex align-items-center h-100 cursor-pointer text-truncate hover-opacity"
|
||||
style={{
|
||||
userSelect: 'none',
|
||||
backgroundColor: e.color,
|
||||
minWidth: `${percWidth.toFixed(6)}%`,
|
||||
width: `${percWidth.toFixed(6)}%`,
|
||||
marginLeft: `${percMarginLeft.toFixed(6)}%`,
|
||||
position: 'relative',
|
||||
...(typeof eventStyles === 'function'
|
||||
? eventStyles(e)
|
||||
: eventStyles),
|
||||
}}
|
||||
>
|
||||
<div style={{ margin: 'auto' }} className="px-2">
|
||||
{e.body}
|
||||
</div>
|
||||
{/* Render span event markers */}
|
||||
{e.markers?.map((marker, idx) => (
|
||||
<SpanEventMarkerComponent
|
||||
key={`${e.id}-marker-${idx}`}
|
||||
marker={marker}
|
||||
eventStart={e.start}
|
||||
eventEnd={e.end}
|
||||
height={height}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
// TODO: Revisit this?
|
||||
// (prev, next) => {
|
||||
// // TODO: This is a hack for cheap comparisons
|
||||
// return (
|
||||
// prev.maxVal === next.maxVal &&
|
||||
// prev.events?.length === next.events?.length &&
|
||||
// prev.scale === next.scale &&
|
||||
// prev.offset === next.offset
|
||||
// );
|
||||
// },
|
||||
);
|
||||
|
||||
function renderMs(ms: number) {
|
||||
return ms < 1000
|
||||
? `${Math.round(ms)}ms`
|
||||
: ms % 1000 === 0
|
||||
? `${Math.floor(ms / 1000)}s`
|
||||
: `${(ms / 1000).toFixed(3)}s`;
|
||||
}
|
||||
|
||||
function calculateInterval(value: number) {
|
||||
// Calculate the approximate interval by dividing the value by 10
|
||||
const interval = value / 10;
|
||||
|
||||
// Round the interval to the nearest power of 10 to make it a human-friendly number
|
||||
const magnitude = Math.pow(10, Math.floor(Math.log10(interval)));
|
||||
|
||||
// Adjust the interval to the nearest standard bucket size
|
||||
let bucketSize = magnitude;
|
||||
if (interval >= 2 * magnitude) {
|
||||
bucketSize = 2 * magnitude;
|
||||
}
|
||||
if (interval >= 5 * magnitude) {
|
||||
bucketSize = 5 * magnitude;
|
||||
}
|
||||
|
||||
return bucketSize;
|
||||
}
|
||||
|
||||
function TimelineXAxis({
|
||||
maxVal,
|
||||
labelWidth,
|
||||
height,
|
||||
scale,
|
||||
offset,
|
||||
}: {
|
||||
maxVal: number;
|
||||
labelWidth: number;
|
||||
height: number;
|
||||
scale: number;
|
||||
offset: number;
|
||||
}) {
|
||||
const scaledMaxVal = maxVal / scale;
|
||||
// TODO: Turn this into a function
|
||||
const interval = calculateInterval(scaledMaxVal);
|
||||
|
||||
const numTicks = Math.floor(maxVal / interval);
|
||||
const percSpacing = (interval / maxVal) * 100 * scale;
|
||||
|
||||
const ticks = [];
|
||||
for (let i = 0; i < numTicks; i++) {
|
||||
ticks.push(
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
height,
|
||||
width: 1,
|
||||
marginRight: -1,
|
||||
marginLeft: i === 0 ? 0 : `${percSpacing.toFixed(6)}%`,
|
||||
background: 'var(--color-border-muted)',
|
||||
}}
|
||||
>
|
||||
<div className="ms-2 fs-8.5">{renderMs(i * interval)}</div>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
height: 24,
|
||||
paddingTop: 4,
|
||||
zIndex: 200,
|
||||
pointerEvents: 'none',
|
||||
background: 'var(--color-bg-body)',
|
||||
}}
|
||||
>
|
||||
<div className={`${cx('d-flex align-items-center')}`}>
|
||||
<div style={{ width: labelWidth, minWidth: labelWidth }}></div>
|
||||
<div className="d-flex w-100 overflow-hidden">
|
||||
<div
|
||||
style={{ marginRight: `${(-1 * offset * scale).toFixed(6)}%` }}
|
||||
></div>
|
||||
{ticks}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineCursor({
|
||||
xPerc,
|
||||
overlay,
|
||||
labelWidth,
|
||||
color,
|
||||
height,
|
||||
}: {
|
||||
xPerc: number;
|
||||
overlay?: React.ReactNode;
|
||||
labelWidth: number;
|
||||
color: string;
|
||||
height: number;
|
||||
}) {
|
||||
// Bound [-1,100] to 6 digits as a percent, -1 so it can slide off the right side of the screen
|
||||
const cursorMarginLeft = `${Math.min(Math.max(xPerc * 100, -1), 100).toFixed(
|
||||
6,
|
||||
)}%`;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
height: 0,
|
||||
zIndex: 250,
|
||||
pointerEvents: 'none',
|
||||
display: xPerc <= 0 ? 'none' : 'block',
|
||||
}}
|
||||
>
|
||||
<div className="d-flex">
|
||||
<div style={{ width: labelWidth, minWidth: labelWidth }} />
|
||||
<div className="w-100 overflow-hidden">
|
||||
<div style={{ marginLeft: cursorMarginLeft }}>
|
||||
{overlay != null && (
|
||||
<div
|
||||
style={{
|
||||
height: 0,
|
||||
marginLeft: xPerc < 0.5 ? 12 : -150,
|
||||
top: 12,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
className="p-2 rounded border"
|
||||
style={{ background: 'var(--color-bg-surface)' }}
|
||||
>
|
||||
{overlay}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
height,
|
||||
width: 1,
|
||||
background: color,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineMouseCursor({
|
||||
containerRef,
|
||||
maxVal,
|
||||
labelWidth,
|
||||
height,
|
||||
scale,
|
||||
offset,
|
||||
xPerc,
|
||||
setXPerc,
|
||||
}: {
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
maxVal: number;
|
||||
labelWidth: number;
|
||||
height: number;
|
||||
scale: number;
|
||||
offset: number;
|
||||
xPerc: number;
|
||||
setXPerc: (p: number) => any;
|
||||
}) {
|
||||
const [showCursor, setShowCursor] = useState(false);
|
||||
useEffect(() => {
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (containerRef.current != null) {
|
||||
const timelineContainer = containerRef.current;
|
||||
const rect = timelineContainer.getBoundingClientRect();
|
||||
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
// Remove label width from calculations
|
||||
// Use clientWidth as that removes scroll bars
|
||||
const xPerc =
|
||||
(x - labelWidth) / (timelineContainer.clientWidth - labelWidth);
|
||||
if (onMouseMove != null) {
|
||||
setXPerc(xPerc);
|
||||
}
|
||||
}
|
||||
};
|
||||
const onMouseEnter = () => setShowCursor(true);
|
||||
const onMouseLeave = () => setShowCursor(false);
|
||||
|
||||
const element = containerRef.current;
|
||||
element?.addEventListener('mousemove', onMouseMove);
|
||||
element?.addEventListener('mouseleave', onMouseLeave);
|
||||
element?.addEventListener('mouseenter', onMouseEnter);
|
||||
|
||||
return () => {
|
||||
element?.removeEventListener('mousemove', onMouseMove);
|
||||
element?.removeEventListener('mouseleave', onMouseLeave);
|
||||
element?.removeEventListener('mouseenter', onMouseEnter);
|
||||
};
|
||||
}, [containerRef, labelWidth, setXPerc]);
|
||||
|
||||
const cursorTime = (offset / 100 + Math.max(xPerc, 0) / scale) * maxVal;
|
||||
|
||||
return showCursor ? (
|
||||
<TimelineCursor
|
||||
xPerc={Math.max(xPerc, 0)}
|
||||
overlay={renderMs(Math.max(cursorTime, 0))}
|
||||
height={height}
|
||||
labelWidth={labelWidth}
|
||||
color="var(--color-bg-neutral)"
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
label: React.ReactNode;
|
||||
events: TimelineEventT[];
|
||||
style?: any;
|
||||
type?: string;
|
||||
className?: string;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
export default function TimelineChart({
|
||||
rows,
|
||||
cursors,
|
||||
rowHeight,
|
||||
onMouseMove,
|
||||
onEventClick,
|
||||
labelWidth: initialLabelWidth,
|
||||
className,
|
||||
style,
|
||||
onClick,
|
||||
scale = 1,
|
||||
setScale = () => {},
|
||||
initialScrollRowIndex,
|
||||
scaleWithScroll: scaleWithScroll = false,
|
||||
}: {
|
||||
rows: Row[] | undefined;
|
||||
cursors?: {
|
||||
id: string;
|
||||
start: number;
|
||||
color: string;
|
||||
}[];
|
||||
scale?: number;
|
||||
rowHeight: number;
|
||||
onMouseMove?: (ts: number) => any;
|
||||
onClick?: (ts: number) => any;
|
||||
onEventClick?: (e: Row) => any;
|
||||
labelWidth: number;
|
||||
className?: string;
|
||||
style?: any;
|
||||
setScale?: (cb: (scale: number) => number) => any;
|
||||
scaleWithScroll?: boolean;
|
||||
initialScrollRowIndex?: number;
|
||||
}) {
|
||||
const [offset, setOffset] = useState(0);
|
||||
const prevScale = usePrevious(scale);
|
||||
|
||||
const initialWidthPercent = (initialLabelWidth / window.innerWidth) * 100;
|
||||
const { size: labelWidthPercent, startResize } = useResizable(
|
||||
initialWidthPercent,
|
||||
'left',
|
||||
);
|
||||
|
||||
const labelWidth = (labelWidthPercent / 100) * window.innerWidth;
|
||||
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const onMouseEvent = (
|
||||
e: { clientX: number; clientY: number },
|
||||
cb: Function | undefined,
|
||||
) => {
|
||||
if (timelineRef.current != null && cb != null) {
|
||||
const timelineContainer = timelineRef.current;
|
||||
const rect = timelineContainer.getBoundingClientRect();
|
||||
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
// Remove label width from calculations
|
||||
// Use clientWidth as that removes scroll bars
|
||||
const xPerc =
|
||||
(x - labelWidth) / (timelineContainer.clientWidth - labelWidth);
|
||||
cb(Math.max((offset / 100 + xPerc / scale) * maxVal));
|
||||
}
|
||||
};
|
||||
|
||||
const useDragOptions: Parameters<typeof useDrag>[1] = useMemo(
|
||||
() => ({
|
||||
onDrag: e => {
|
||||
setOffset(v =>
|
||||
Math.min(
|
||||
Math.max(v - e.movementX * (0.125 / scale), 0),
|
||||
100 - 100 / scale,
|
||||
),
|
||||
);
|
||||
},
|
||||
}),
|
||||
[scale, setOffset],
|
||||
);
|
||||
useDrag(timelineRef, useDragOptions);
|
||||
|
||||
const [cursorXPerc, setCursorXPerc] = useState(0);
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (scaleWithScroll) {
|
||||
e.preventDefault();
|
||||
setScale(v => Math.max(v - e.deltaY * 0.001, 1));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (prevScale != null && prevScale != scale) {
|
||||
setOffset(offset => {
|
||||
const newScale = scale;
|
||||
|
||||
// we try to calculate the new offset we need to keep the cursor's
|
||||
// abs % the same between current scale and new scale
|
||||
// cursor abs % = cursorTime/maxVal = offset / 100 + xPerc / scale
|
||||
const boundedCursorXPerc = Math.max(Math.min(cursorXPerc, 1), 0);
|
||||
const newOffset =
|
||||
offset +
|
||||
(100 * boundedCursorXPerc) / prevScale -
|
||||
(100 * boundedCursorXPerc) / newScale;
|
||||
|
||||
return Math.min(Math.max(newOffset, 0), 100 - 100 / scale);
|
||||
});
|
||||
}
|
||||
}, [scale, prevScale, cursorXPerc]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = timelineRef.current;
|
||||
if (element != null) {
|
||||
element.addEventListener('wheel', onWheel, {
|
||||
passive: false,
|
||||
});
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('wheel', onWheel);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const maxVal = useMemo(() => {
|
||||
let max = 0;
|
||||
for (const row of rows ?? []) {
|
||||
for (const event of row.events) {
|
||||
max = Math.max(max, event.end);
|
||||
}
|
||||
}
|
||||
return max * 1.1; // add 10% padding
|
||||
}, [rows]);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rows?.length ?? 0,
|
||||
getScrollElement: () => timelineRef.current,
|
||||
estimateSize: () => rowHeight,
|
||||
overscan: 5,
|
||||
});
|
||||
const items = rowVirtualizer.getVirtualItems();
|
||||
|
||||
const TIMELINE_AXIS_HEIGHT = 32;
|
||||
|
||||
const [initialScrolled, setInitialScrolled] = useState(false);
|
||||
useEffect(() => {
|
||||
if (
|
||||
initialScrollRowIndex != null &&
|
||||
!initialScrolled &&
|
||||
initialScrollRowIndex >= 0
|
||||
) {
|
||||
setInitialScrolled(true);
|
||||
rowVirtualizer.scrollToIndex(initialScrollRowIndex, {
|
||||
align: 'center',
|
||||
});
|
||||
}
|
||||
}, [initialScrollRowIndex, initialScrolled, rowVirtualizer]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ position: 'relative', ...style }}
|
||||
className={className}
|
||||
ref={timelineRef}
|
||||
onClick={e => {
|
||||
onMouseEvent(e, onClick);
|
||||
}}
|
||||
onMouseMove={e => {
|
||||
onMouseEvent(e, onMouseMove);
|
||||
}}
|
||||
>
|
||||
{(cursors ?? ([] as const)).map(cursor => {
|
||||
const xPerc = (cursor.start / maxVal - offset / 100) * scale;
|
||||
return (
|
||||
<TimelineCursor
|
||||
key={cursor.id}
|
||||
xPerc={xPerc}
|
||||
height={timelineRef.current?.getBoundingClientRect().height ?? 300}
|
||||
labelWidth={labelWidth}
|
||||
color={cursor.color}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<TimelineMouseCursor
|
||||
containerRef={timelineRef}
|
||||
maxVal={maxVal}
|
||||
height={timelineRef.current?.getBoundingClientRect().height ?? 300}
|
||||
labelWidth={labelWidth}
|
||||
scale={scale}
|
||||
offset={offset}
|
||||
xPerc={cursorXPerc}
|
||||
setXPerc={setCursorXPerc}
|
||||
/>
|
||||
<TimelineXAxis
|
||||
maxVal={maxVal}
|
||||
height={timelineRef.current?.getBoundingClientRect().height ?? 300}
|
||||
labelWidth={labelWidth}
|
||||
scale={scale}
|
||||
offset={offset}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize() + TIMELINE_AXIS_HEIGHT}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${items?.[0]?.start ?? 0}px)`,
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map(virtualRow => {
|
||||
const row = rows?.[virtualRow.index] as Row;
|
||||
return (
|
||||
<div
|
||||
onClick={() => onEventClick?.(row)}
|
||||
key={virtualRow.index}
|
||||
data-index={virtualRow.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
className={`${cx(
|
||||
'd-flex align-items-center overflow-hidden',
|
||||
row.className,
|
||||
styles.timelineRow,
|
||||
row.isActive && styles.timelineRowActive,
|
||||
)}`}
|
||||
style={{
|
||||
// position: 'absolute',
|
||||
// top: 0,
|
||||
// left: 0,
|
||||
// width: '100%',
|
||||
// height: `${virtualRow.size}px`,
|
||||
// transform: `translateY(${virtualRow.start}px)`,
|
||||
...row.style,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={styles.labelContainer}
|
||||
style={{
|
||||
width: labelWidth,
|
||||
minWidth: labelWidth,
|
||||
}}
|
||||
>
|
||||
{row.label}
|
||||
<div
|
||||
className={resizeStyles.resizeHandle}
|
||||
onMouseDown={startResize}
|
||||
style={{ backgroundColor: 'var(--color-bg-neutral)' }}
|
||||
/>
|
||||
</div>
|
||||
<NewTimelineRow
|
||||
events={row.events}
|
||||
height={rowHeight}
|
||||
maxVal={maxVal}
|
||||
eventStyles={(event: TimelineEventT) => ({
|
||||
borderRadius: 2,
|
||||
fontSize: rowHeight * 0.5,
|
||||
backgroundColor: event.isError
|
||||
? 'var(--color-bg-danger)'
|
||||
: 'var(--color-bg-inverted)',
|
||||
color: 'var(--color-text-inverted)',
|
||||
})}
|
||||
scale={scale}
|
||||
offset={offset}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -28,9 +28,10 @@ import {
|
|||
|
||||
import { ContactSupportText } from '@/components/ContactSupportText';
|
||||
import SearchInputV2 from '@/components/SearchInput/SearchInputV2';
|
||||
import { TimelineChart } from '@/components/TimelineChart';
|
||||
import useOffsetPaginatedQuery from '@/hooks/useOffsetPaginatedQuery';
|
||||
import useResizable from '@/hooks/useResizable';
|
||||
import useRowWhere, { RowWhereResult, WithClause } from '@/hooks/useRowWhere';
|
||||
import useRowWhere, { WithClause } from '@/hooks/useRowWhere';
|
||||
import useWaterfallSearchState from '@/hooks/useWaterfallSearchState';
|
||||
import {
|
||||
getDisplayedTimestampValueExpression,
|
||||
|
|
@ -38,7 +39,6 @@ import {
|
|||
getEventBody,
|
||||
getSpanEventBody,
|
||||
} from '@/source';
|
||||
import TimelineChart from '@/TimelineChart';
|
||||
import { useFormatTime } from '@/useFormatTime';
|
||||
import {
|
||||
getChartColorError,
|
||||
|
|
@ -940,12 +940,8 @@ export function DBTraceWaterfallChartContainer({
|
|||
maxHeight: `${heightPx}px`,
|
||||
}}
|
||||
scale={1}
|
||||
setScale={() => {}}
|
||||
rowHeight={22}
|
||||
labelWidth={300}
|
||||
onClick={ts => {
|
||||
// onTimeClick(ts + startedAt);
|
||||
}}
|
||||
onEventClick={(event: {
|
||||
id: string;
|
||||
type?: string;
|
||||
|
|
|
|||
298
packages/app/src/components/TimelineChart/TimelineChart.tsx
Normal file
298
packages/app/src/components/TimelineChart/TimelineChart.tsx
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
|
||||
import useResizable from '../../hooks/useResizable';
|
||||
import { useDrag, usePrevious } from '../../utils';
|
||||
|
||||
import {
|
||||
TimelineChartRowEvents,
|
||||
type TTimelineEvent,
|
||||
} from './TimelineChartRowEvents';
|
||||
import { TimelineCursor } from './TimelineCursor';
|
||||
import { TimelineMouseCursor } from './TimelineMouseCursor';
|
||||
import { TimelineXAxis } from './TimelineXAxis';
|
||||
|
||||
import resizeStyles from '../../../styles/ResizablePanel.module.scss';
|
||||
import styles from './TimelineChart.module.scss';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
label: React.ReactNode;
|
||||
events: TTimelineEvent[];
|
||||
style?: any;
|
||||
type?: string;
|
||||
className?: string;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
type Cursor = {
|
||||
id: string;
|
||||
start: number;
|
||||
color: string;
|
||||
};
|
||||
|
||||
type TimelineChartProps = {
|
||||
rows: Row[];
|
||||
cursors?: Cursor[];
|
||||
scale?: number;
|
||||
rowHeight: number;
|
||||
onMouseMove?: (ts: number) => void;
|
||||
onClick?: (ts: number) => void;
|
||||
onEventClick?: (e: Row) => void;
|
||||
labelWidth: number;
|
||||
className?: string;
|
||||
style?: any;
|
||||
setScale?: (cb: (scale: number) => number) => void;
|
||||
scaleWithScroll?: boolean;
|
||||
initialScrollRowIndex?: number;
|
||||
};
|
||||
|
||||
export const TimelineChart = memo(function ({
|
||||
rows,
|
||||
cursors,
|
||||
rowHeight,
|
||||
onMouseMove,
|
||||
onEventClick,
|
||||
labelWidth: initialLabelWidth,
|
||||
className,
|
||||
style,
|
||||
onClick,
|
||||
scale = 1,
|
||||
setScale,
|
||||
initialScrollRowIndex,
|
||||
scaleWithScroll = false,
|
||||
}: TimelineChartProps) {
|
||||
const [offset, setOffset] = useState(0);
|
||||
const prevScale = usePrevious(scale);
|
||||
const initialWidthPercent = (initialLabelWidth / window.innerWidth) * 100;
|
||||
const { size: labelWidthPercent, startResize } = useResizable(
|
||||
initialWidthPercent,
|
||||
'left',
|
||||
);
|
||||
|
||||
const labelWidth = (labelWidthPercent / 100) * window.innerWidth;
|
||||
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const onMouseEvent = (
|
||||
e: { clientX: number; clientY: number },
|
||||
cb: typeof onClick | typeof onMouseMove,
|
||||
) => {
|
||||
if (timelineRef.current != null && cb != null) {
|
||||
const timelineContainer = timelineRef.current;
|
||||
const rect = timelineContainer.getBoundingClientRect();
|
||||
|
||||
const x = e.clientX - rect.left;
|
||||
|
||||
// Remove label width from calculations
|
||||
// Use clientWidth as that removes scroll bars
|
||||
const xPerc =
|
||||
(x - labelWidth) / (timelineContainer.clientWidth - labelWidth);
|
||||
cb(Math.max((offset / 100 + xPerc / scale) * maxVal));
|
||||
}
|
||||
};
|
||||
|
||||
const useDragOptions: Parameters<typeof useDrag>[1] = useMemo(
|
||||
() => ({
|
||||
onDrag: e => {
|
||||
setOffset(v =>
|
||||
Math.min(
|
||||
Math.max(v - e.movementX * (0.125 / scale), 0),
|
||||
100 - 100 / scale,
|
||||
),
|
||||
);
|
||||
},
|
||||
}),
|
||||
[scale, setOffset],
|
||||
);
|
||||
useDrag(timelineRef, useDragOptions);
|
||||
|
||||
const [cursorXPerc, setCursorXPerc] = useState(0);
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (scaleWithScroll) {
|
||||
e.preventDefault();
|
||||
setScale?.(v => Math.max(v - e.deltaY * 0.001, 1));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (prevScale != null && prevScale != scale) {
|
||||
setOffset(offset => {
|
||||
const newScale = scale;
|
||||
|
||||
// we try to calculate the new offset we need to keep the cursor's
|
||||
// abs % the same between current scale and new scale
|
||||
// cursor abs % = cursorTime/maxVal = offset / 100 + xPerc / scale
|
||||
const boundedCursorXPerc = Math.max(Math.min(cursorXPerc, 1), 0);
|
||||
const newOffset =
|
||||
offset +
|
||||
(100 * boundedCursorXPerc) / prevScale -
|
||||
(100 * boundedCursorXPerc) / newScale;
|
||||
|
||||
return Math.min(Math.max(newOffset, 0), 100 - 100 / scale);
|
||||
});
|
||||
}
|
||||
}, [scale, prevScale, cursorXPerc]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = timelineRef.current;
|
||||
if (element != null) {
|
||||
element.addEventListener('wheel', onWheel, {
|
||||
passive: false,
|
||||
});
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('wheel', onWheel);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const maxVal = useMemo(() => {
|
||||
let max = 0;
|
||||
for (const row of rows) {
|
||||
for (const event of row.events) {
|
||||
max = Math.max(max, event.end);
|
||||
}
|
||||
}
|
||||
return max * 1.1; // add 10% padding
|
||||
}, [rows]);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => timelineRef.current,
|
||||
estimateSize: () => rowHeight,
|
||||
overscan: 5,
|
||||
});
|
||||
const items = rowVirtualizer.getVirtualItems();
|
||||
|
||||
const TIMELINE_AXIS_HEIGHT = 32;
|
||||
|
||||
const [initialScrolled, setInitialScrolled] = useState(false);
|
||||
useEffect(() => {
|
||||
if (
|
||||
initialScrollRowIndex != null &&
|
||||
!initialScrolled &&
|
||||
initialScrollRowIndex >= 0
|
||||
) {
|
||||
setInitialScrolled(true);
|
||||
rowVirtualizer.scrollToIndex(initialScrollRowIndex, {
|
||||
align: 'center',
|
||||
});
|
||||
}
|
||||
}, [initialScrollRowIndex, initialScrolled, rowVirtualizer]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ position: 'relative', ...style }}
|
||||
className={className}
|
||||
ref={timelineRef}
|
||||
onClick={e => {
|
||||
onMouseEvent(e, onClick);
|
||||
}}
|
||||
onMouseMove={e => {
|
||||
onMouseEvent(e, onMouseMove);
|
||||
}}
|
||||
>
|
||||
{(cursors ?? ([] as const)).map(cursor => {
|
||||
const xPerc = (cursor.start / maxVal - offset / 100) * scale;
|
||||
return (
|
||||
<TimelineCursor
|
||||
key={cursor.id}
|
||||
xPerc={xPerc}
|
||||
height={timelineRef.current?.getBoundingClientRect().height ?? 300}
|
||||
labelWidth={labelWidth}
|
||||
color={cursor.color}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<TimelineMouseCursor
|
||||
containerRef={timelineRef}
|
||||
maxVal={maxVal}
|
||||
height={timelineRef.current?.getBoundingClientRect().height ?? 300}
|
||||
labelWidth={labelWidth}
|
||||
scale={scale}
|
||||
offset={offset}
|
||||
xPerc={cursorXPerc}
|
||||
setXPerc={setCursorXPerc}
|
||||
/>
|
||||
<TimelineXAxis
|
||||
maxVal={maxVal}
|
||||
height={timelineRef.current?.getBoundingClientRect().height ?? 300}
|
||||
labelWidth={labelWidth}
|
||||
scale={scale}
|
||||
offset={offset}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize() + TIMELINE_AXIS_HEIGHT}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${items?.[0]?.start ?? 0}px)`,
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map(virtualRow => {
|
||||
const row = rows[virtualRow.index];
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onEventClick?.(row)}
|
||||
key={virtualRow.index}
|
||||
data-index={virtualRow.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
className={`${cx(
|
||||
'd-flex align-items-center overflow-hidden',
|
||||
row.className,
|
||||
styles.timelineRow,
|
||||
row.isActive && styles.timelineRowActive,
|
||||
)}`}
|
||||
style={row.style}
|
||||
>
|
||||
<div
|
||||
className={styles.labelContainer}
|
||||
style={{
|
||||
width: labelWidth,
|
||||
minWidth: labelWidth,
|
||||
}}
|
||||
>
|
||||
{row.label}
|
||||
<div
|
||||
className={resizeStyles.resizeHandle}
|
||||
onMouseDown={startResize}
|
||||
style={{ backgroundColor: 'var(--color-bg-neutral)' }}
|
||||
/>
|
||||
</div>
|
||||
<TimelineChartRowEvents
|
||||
events={row.events}
|
||||
height={rowHeight}
|
||||
maxVal={maxVal}
|
||||
eventStyles={(event: TTimelineEvent) => ({
|
||||
borderRadius: 2,
|
||||
fontSize: rowHeight * 0.5,
|
||||
backgroundColor: event.isError
|
||||
? 'var(--color-bg-danger)'
|
||||
: 'var(--color-bg-inverted)',
|
||||
color: 'var(--color-text-inverted)',
|
||||
})}
|
||||
scale={scale}
|
||||
offset={offset}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
TimelineChart.displayName = 'TimelineChart';
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
import { memo } from 'react';
|
||||
import { Tooltip } from '@mantine/core';
|
||||
|
||||
import {
|
||||
TimelineSpanEventMarker,
|
||||
type TTimelineSpanEventMarker,
|
||||
} from './TimelineSpanEventMarker';
|
||||
|
||||
export type TTimelineEvent = {
|
||||
id: string;
|
||||
start: number;
|
||||
end: number;
|
||||
tooltip: string;
|
||||
color: string;
|
||||
body: React.ReactNode;
|
||||
minWidthPerc?: number;
|
||||
isError?: boolean;
|
||||
markers?: TTimelineSpanEventMarker[];
|
||||
};
|
||||
|
||||
type TimelineChartRowProps = {
|
||||
events: TTimelineEvent[] | undefined;
|
||||
maxVal: number;
|
||||
height: number;
|
||||
scale: number;
|
||||
offset: number;
|
||||
eventStyles?:
|
||||
| React.CSSProperties
|
||||
| ((event: TTimelineEvent) => React.CSSProperties);
|
||||
onEventHover?: (eventId: string) => void;
|
||||
onEventClick?: (event: TTimelineEvent) => void;
|
||||
};
|
||||
|
||||
export const TimelineChartRowEvents = memo(function ({
|
||||
events,
|
||||
maxVal,
|
||||
height,
|
||||
eventStyles,
|
||||
onEventHover,
|
||||
scale,
|
||||
offset,
|
||||
}: TimelineChartRowProps) {
|
||||
return (
|
||||
<div
|
||||
className="d-flex overflow-hidden"
|
||||
style={{ width: 0, flexGrow: 1, height, position: 'relative' }}
|
||||
>
|
||||
<div
|
||||
style={{ marginRight: `${(-1 * offset * scale).toFixed(6)}%` }}
|
||||
></div>
|
||||
{(events ?? []).map((e: TTimelineEvent, i, arr) => {
|
||||
const minWidth = (e.minWidthPerc ?? 0) / 100;
|
||||
const lastEvent = arr[i - 1];
|
||||
const lastEventMinEnd =
|
||||
lastEvent?.start != null ? lastEvent?.start + maxVal * minWidth : 0;
|
||||
const lastEventEnd = Math.max(lastEvent?.end ?? 0, lastEventMinEnd);
|
||||
|
||||
const percWidth =
|
||||
scale * Math.max((e.end - e.start) / maxVal, minWidth) * 100;
|
||||
const percMarginLeft =
|
||||
scale * (((e.start - lastEventEnd) / maxVal) * 100);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={e.id}
|
||||
label={e.tooltip}
|
||||
color="gray"
|
||||
withArrow
|
||||
multiline
|
||||
transitionProps={{ transition: 'fade-right' }}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
maxWidth: 300,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onMouseEnter={() => onEventHover?.(e.id)}
|
||||
className="d-flex align-items-center h-100 cursor-pointer text-truncate hover-opacity"
|
||||
style={{
|
||||
userSelect: 'none',
|
||||
backgroundColor: e.color,
|
||||
minWidth: `${percWidth.toFixed(6)}%`,
|
||||
width: `${percWidth.toFixed(6)}%`,
|
||||
marginLeft: `${percMarginLeft.toFixed(6)}%`,
|
||||
position: 'relative',
|
||||
...(typeof eventStyles === 'function'
|
||||
? eventStyles(e)
|
||||
: eventStyles),
|
||||
}}
|
||||
>
|
||||
<div style={{ margin: 'auto' }} className="px-2">
|
||||
{e.body}
|
||||
</div>
|
||||
{e.markers?.map((marker, idx) => (
|
||||
<TimelineSpanEventMarker
|
||||
key={`${e.id}-marker-${idx}`}
|
||||
marker={marker}
|
||||
eventStart={e.start}
|
||||
eventEnd={e.end}
|
||||
height={height}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
TimelineChartRowEvents.displayName = 'TimelineChartRowEvents';
|
||||
67
packages/app/src/components/TimelineChart/TimelineCursor.tsx
Normal file
67
packages/app/src/components/TimelineChart/TimelineCursor.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
type TimelineCursorProps = {
|
||||
xPerc: number;
|
||||
overlay?: React.ReactNode;
|
||||
labelWidth: number;
|
||||
color: string;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export function TimelineCursor({
|
||||
xPerc,
|
||||
overlay,
|
||||
labelWidth,
|
||||
color,
|
||||
height,
|
||||
}: TimelineCursorProps) {
|
||||
// Bound [-1,100] to 6 digits as a percent, -1 so it can slide off the right side of the screen
|
||||
const cursorMarginLeft = `${Math.min(Math.max(xPerc * 100, -1), 100).toFixed(
|
||||
6,
|
||||
)}%`;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
height: 0,
|
||||
zIndex: 250,
|
||||
pointerEvents: 'none',
|
||||
display: xPerc <= 0 ? 'none' : 'block',
|
||||
}}
|
||||
>
|
||||
<div className="d-flex">
|
||||
<div style={{ width: labelWidth, minWidth: labelWidth }} />
|
||||
<div className="w-100 overflow-hidden">
|
||||
<div style={{ marginLeft: cursorMarginLeft }}>
|
||||
{overlay != null && (
|
||||
<div
|
||||
style={{
|
||||
height: 0,
|
||||
marginLeft: xPerc < 0.5 ? 12 : -150,
|
||||
top: 12,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
className="p-2 rounded border"
|
||||
style={{ background: 'var(--color-bg-surface)' }}
|
||||
>
|
||||
{overlay}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
height,
|
||||
width: 1,
|
||||
background: color,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import { RefObject, useEffect, useState } from 'react';
|
||||
|
||||
import { TimelineCursor } from './TimelineCursor';
|
||||
import { renderMs } from './utils';
|
||||
|
||||
export function TimelineMouseCursor({
|
||||
containerRef,
|
||||
maxVal,
|
||||
labelWidth,
|
||||
height,
|
||||
scale,
|
||||
offset,
|
||||
xPerc,
|
||||
setXPerc,
|
||||
}: {
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
maxVal: number;
|
||||
labelWidth: number;
|
||||
height: number;
|
||||
scale: number;
|
||||
offset: number;
|
||||
xPerc: number;
|
||||
setXPerc: (p: number) => void;
|
||||
}) {
|
||||
const [showCursor, setShowCursor] = useState(false);
|
||||
useEffect(() => {
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (containerRef.current != null) {
|
||||
const timelineContainer = containerRef.current;
|
||||
const rect = timelineContainer.getBoundingClientRect();
|
||||
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
// Remove label width from calculations
|
||||
// Use clientWidth as that removes scroll bars
|
||||
const xPerc =
|
||||
(x - labelWidth) / (timelineContainer.clientWidth - labelWidth);
|
||||
if (onMouseMove != null) {
|
||||
setXPerc(xPerc);
|
||||
}
|
||||
}
|
||||
};
|
||||
const onMouseEnter = () => setShowCursor(true);
|
||||
const onMouseLeave = () => setShowCursor(false);
|
||||
|
||||
const element = containerRef.current;
|
||||
element?.addEventListener('mousemove', onMouseMove);
|
||||
element?.addEventListener('mouseleave', onMouseLeave);
|
||||
element?.addEventListener('mouseenter', onMouseEnter);
|
||||
|
||||
return () => {
|
||||
element?.removeEventListener('mousemove', onMouseMove);
|
||||
element?.removeEventListener('mouseleave', onMouseLeave);
|
||||
element?.removeEventListener('mouseenter', onMouseEnter);
|
||||
};
|
||||
}, [containerRef, labelWidth, setXPerc]);
|
||||
|
||||
const cursorTime = (offset / 100 + Math.max(xPerc, 0) / scale) * maxVal;
|
||||
|
||||
return showCursor ? (
|
||||
<TimelineCursor
|
||||
xPerc={Math.max(xPerc, 0)}
|
||||
overlay={renderMs(Math.max(cursorTime, 0))}
|
||||
height={height}
|
||||
labelWidth={labelWidth}
|
||||
color="var(--color-bg-neutral)"
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
import { memo } from 'react';
|
||||
import { Text, Tooltip } from '@mantine/core';
|
||||
|
||||
import { useFormatTime } from '@/useFormatTime';
|
||||
|
||||
export type TTimelineSpanEventMarker = {
|
||||
timestamp: number; // ms offset from minOffset
|
||||
name: string;
|
||||
attributes: Record<string, any>;
|
||||
};
|
||||
|
||||
export const TimelineSpanEventMarker = memo(function ({
|
||||
marker,
|
||||
eventStart,
|
||||
eventEnd,
|
||||
height,
|
||||
}: {
|
||||
marker: TTimelineSpanEventMarker;
|
||||
eventStart: number;
|
||||
eventEnd: number;
|
||||
height: number;
|
||||
}) {
|
||||
const formatTime = useFormatTime();
|
||||
// Calculate marker position as percentage within the span bar (0-100%)
|
||||
const spanDuration = eventEnd - eventStart;
|
||||
const markerOffsetFromStart = marker.timestamp - eventStart;
|
||||
const markerPosition =
|
||||
spanDuration > 0 ? (markerOffsetFromStart / spanDuration) * 100 : 0;
|
||||
|
||||
// Format attributes for tooltip
|
||||
const attributeEntries = Object.entries(marker.attributes);
|
||||
const tooltipContent = (
|
||||
<div>
|
||||
<Text size="xxs" c="dimmed" mb="xxs">
|
||||
{formatTime(new Date(marker.timestamp), { format: 'withMs' })}
|
||||
</Text>
|
||||
<Text size="xs">{marker.name}</Text>
|
||||
{attributeEntries.length > 0 && (
|
||||
<div style={{ fontSize: 10, marginTop: 4 }}>
|
||||
{attributeEntries.slice(0, 5).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<span style={{ color: 'var(--color-text-primary)' }}>{key}:</span>{' '}
|
||||
{String(value).length > 50
|
||||
? String(value).substring(0, 50) + '...'
|
||||
: String(value)}
|
||||
</div>
|
||||
))}
|
||||
{attributeEntries.length > 5 && (
|
||||
<div style={{ fontStyle: 'italic' }}>
|
||||
...and {attributeEntries.length - 5} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={tooltipContent}
|
||||
color="var(--color-bg-surface)"
|
||||
withArrow
|
||||
multiline
|
||||
transitionProps={{ transition: 'fade' }}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
maxWidth: 350,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${markerPosition.toFixed(6)}%`,
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 8,
|
||||
height: 8,
|
||||
cursor: 'pointer',
|
||||
zIndex: 10,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
onMouseEnter={e => e.stopPropagation()}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Diamond shape marker */}
|
||||
<div
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
backgroundColor: 'var(--color-bg-success)',
|
||||
transform: 'rotate(45deg)',
|
||||
border: '1px solid #333',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
/>
|
||||
{/* Vertical line extending above and below */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 1,
|
||||
height: height,
|
||||
backgroundColor: 'var(--color-bg-success)',
|
||||
opacity: 0.4,
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
TimelineSpanEventMarker.displayName = 'TimelineSpanEventMarker';
|
||||
64
packages/app/src/components/TimelineChart/TimelineXAxis.tsx
Normal file
64
packages/app/src/components/TimelineChart/TimelineXAxis.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { calculateInterval, renderMs } from './utils';
|
||||
|
||||
export function TimelineXAxis({
|
||||
maxVal,
|
||||
labelWidth,
|
||||
height,
|
||||
scale,
|
||||
offset,
|
||||
}: {
|
||||
maxVal: number;
|
||||
labelWidth: number;
|
||||
height: number;
|
||||
scale: number;
|
||||
offset: number;
|
||||
}) {
|
||||
const scaledMaxVal = maxVal / scale;
|
||||
// TODO: Turn this into a function
|
||||
const interval = calculateInterval(scaledMaxVal);
|
||||
|
||||
const numTicks = Math.floor(maxVal / interval);
|
||||
const percSpacing = (interval / maxVal) * 100 * scale;
|
||||
|
||||
const ticks = [];
|
||||
for (let i = 0; i < numTicks; i++) {
|
||||
ticks.push(
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
height,
|
||||
width: 1,
|
||||
marginRight: -1,
|
||||
marginLeft: i === 0 ? 0 : `${percSpacing.toFixed(6)}%`,
|
||||
background: 'var(--color-border-muted)',
|
||||
}}
|
||||
>
|
||||
<div className="ms-2 fs-8.5">{renderMs(i * interval)}</div>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
height: 24,
|
||||
paddingTop: 4,
|
||||
zIndex: 200,
|
||||
pointerEvents: 'none',
|
||||
background: 'var(--color-bg-body)',
|
||||
}}
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
<div style={{ width: labelWidth, minWidth: labelWidth }}></div>
|
||||
<div className="d-flex w-100 overflow-hidden">
|
||||
<div
|
||||
style={{ marginRight: `${(-1 * offset * scale).toFixed(6)}%` }}
|
||||
></div>
|
||||
{ticks}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import { calculateInterval, renderMs } from '../utils';
|
||||
|
||||
describe('renderMs', () => {
|
||||
it('returns "0ms" for 0', () => {
|
||||
expect(renderMs(0)).toBe('0ms');
|
||||
});
|
||||
|
||||
it('formats sub-second values as ms', () => {
|
||||
expect(renderMs(500)).toBe('500ms');
|
||||
expect(renderMs(999)).toBe('999ms');
|
||||
});
|
||||
|
||||
it('rounds sub-second values', () => {
|
||||
expect(renderMs(999.4)).toBe('999ms');
|
||||
expect(renderMs(999.6)).toBe('1000ms');
|
||||
});
|
||||
|
||||
it('formats whole seconds without decimals', () => {
|
||||
expect(renderMs(1000)).toBe('1s');
|
||||
expect(renderMs(2000)).toBe('2s');
|
||||
expect(renderMs(5000)).toBe('5s');
|
||||
});
|
||||
|
||||
it('formats fractional seconds with three decimals', () => {
|
||||
expect(renderMs(1500)).toBe('1.500s');
|
||||
expect(renderMs(1234.567)).toBe('1.235s');
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateInterval', () => {
|
||||
it('returns 0.5 for value 5 (small values)', () => {
|
||||
expect(calculateInterval(5)).toBe(0.5);
|
||||
});
|
||||
|
||||
it('returns magnitude 1 bucket for value 10', () => {
|
||||
expect(calculateInterval(10)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 2x magnitude for value 25', () => {
|
||||
expect(calculateInterval(25)).toBe(2);
|
||||
});
|
||||
|
||||
it('returns 5x magnitude for value 50', () => {
|
||||
expect(calculateInterval(50)).toBe(5);
|
||||
});
|
||||
|
||||
it('returns magnitude 10 for value 100', () => {
|
||||
expect(calculateInterval(100)).toBe(10);
|
||||
});
|
||||
|
||||
it('returns 2x10 for value 200', () => {
|
||||
expect(calculateInterval(200)).toBe(20);
|
||||
});
|
||||
|
||||
it('returns 5x10 for value 500', () => {
|
||||
expect(calculateInterval(500)).toBe(50);
|
||||
});
|
||||
|
||||
it('returns magnitude 100 for value 1000', () => {
|
||||
expect(calculateInterval(1000)).toBe(100);
|
||||
});
|
||||
});
|
||||
1
packages/app/src/components/TimelineChart/index.ts
Normal file
1
packages/app/src/components/TimelineChart/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { TimelineChart } from './TimelineChart';
|
||||
30
packages/app/src/components/TimelineChart/utils.ts
Normal file
30
packages/app/src/components/TimelineChart/utils.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
export function renderMs(ms: number) {
|
||||
if (ms < 1000) {
|
||||
return `${Math.round(ms)}ms`;
|
||||
}
|
||||
|
||||
if (ms % 1000 === 0) {
|
||||
return `${Math.floor(ms / 1000)}s`;
|
||||
}
|
||||
|
||||
return `${(ms / 1000).toFixed(3)}s`;
|
||||
}
|
||||
|
||||
export function calculateInterval(value: number) {
|
||||
// Calculate the approximate interval by dividing the value by 10
|
||||
const interval = value / 10;
|
||||
|
||||
// Round the interval to the nearest power of 10 to make it a human-friendly number
|
||||
const magnitude = Math.pow(10, Math.floor(Math.log10(interval)));
|
||||
|
||||
// Adjust the interval to the nearest standard bucket size
|
||||
let bucketSize = magnitude;
|
||||
if (interval >= 2 * magnitude) {
|
||||
bucketSize = 2 * magnitude;
|
||||
}
|
||||
if (interval >= 5 * magnitude) {
|
||||
bucketSize = 5 * magnitude;
|
||||
}
|
||||
|
||||
return bucketSize;
|
||||
}
|
||||
|
|
@ -3,9 +3,9 @@ import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types';
|
|||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { TimelineChart } from '@/components/TimelineChart';
|
||||
import useOffsetPaginatedQuery from '@/hooks/useOffsetPaginatedQuery';
|
||||
import useRowWhere from '@/hooks/useRowWhere';
|
||||
import TimelineChart from '@/TimelineChart';
|
||||
|
||||
import { RowSidePanelContext } from '../DBRowSidePanel';
|
||||
import {
|
||||
|
|
@ -15,13 +15,13 @@ import {
|
|||
} from '../DBTraceWaterfallChart';
|
||||
|
||||
// Mock setup
|
||||
jest.mock('@/TimelineChart', () => {
|
||||
jest.mock('@/components/TimelineChart', () => {
|
||||
const mockComponent = function MockTimelineChart(props: any) {
|
||||
mockComponent.latestProps = props;
|
||||
return <div data-testid="timeline-chart">TimelineChart</div>;
|
||||
};
|
||||
mockComponent.latestProps = {};
|
||||
return mockComponent;
|
||||
return { TimelineChart: mockComponent };
|
||||
});
|
||||
|
||||
jest.mock('@/hooks/useOffsetPaginatedQuery');
|
||||
|
|
|
|||
Loading…
Reference in a new issue