mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-06 22:50:38 +01:00
Compare commits
13 Commits
issue-5535
...
feat/water
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e30183e76b | ||
|
|
c9570f134f | ||
|
|
652ff10d7e | ||
|
|
07f6312c45 | ||
|
|
273a95ce55 | ||
|
|
9836976eed | ||
|
|
c4431ba03d | ||
|
|
f5efb25f17 | ||
|
|
8d5e9cd804 | ||
|
|
5f31ea068a | ||
|
|
25e578f288 | ||
|
|
5240236ad3 | ||
|
|
7b0b1798db |
@@ -57,10 +57,13 @@ function TimelineV3(props: ITimelineV3Props): JSX.Element {
|
||||
}
|
||||
|
||||
const timeAtCursor = offsetTimestamp + cursorXPercent * spread;
|
||||
const unit = getIntervalUnit(spread, offsetTimestamp);
|
||||
// Use the same width-derived interval spread the ticks use, so the badge
|
||||
// unit always matches the tick unit (narrow rulers pick fewer intervals).
|
||||
const intervalSpread = spread / getMinimumIntervalsBasedOnWidth(width);
|
||||
const unit = getIntervalUnit(intervalSpread, offsetTimestamp);
|
||||
const formatted = toFixed(resolveTimeFromInterval(timeAtCursor, unit), 2);
|
||||
return `${formatted}${unit.name}`;
|
||||
}, [cursorXPercent, spread, offsetTimestamp]);
|
||||
}, [cursorXPercent, spread, offsetTimestamp, width]);
|
||||
|
||||
if (endTimestamp < startTimestamp) {
|
||||
console.error(
|
||||
@@ -94,12 +97,17 @@ function TimelineV3(props: ITimelineV3Props): JSX.Element {
|
||||
>
|
||||
<text
|
||||
x={index === intervals.length - 1 ? -10 : 0}
|
||||
y={timelineHeight * 2}
|
||||
y={timelineHeight * 2 - 3}
|
||||
fill={strokeColor}
|
||||
>
|
||||
{interval.label}
|
||||
</text>
|
||||
<line y1={0} y2={timelineHeight} stroke={strokeColor} strokeWidth="1" />
|
||||
<line
|
||||
y1={0}
|
||||
y2={timelineHeight - 3}
|
||||
stroke={strokeColor}
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
63
frontend/src/components/TimelineV3/__tests__/utils.test.ts
Normal file
63
frontend/src/components/TimelineV3/__tests__/utils.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
getIntervals,
|
||||
getIntervalUnit,
|
||||
getMinimumIntervalsBasedOnWidth,
|
||||
} from '../utils';
|
||||
|
||||
// A tick label looks like "200.00ms" / "1.10s" — grab the trailing unit name.
|
||||
function unitOfLabel(label: string): string {
|
||||
const match = label.match(/[a-z]+$/i);
|
||||
return match ? match[0] : '';
|
||||
}
|
||||
|
||||
describe('getMinimumIntervalsBasedOnWidth', () => {
|
||||
it('returns fewer intervals for narrower rulers', () => {
|
||||
expect(getMinimumIntervalsBasedOnWidth(500)).toBe(3);
|
||||
expect(getMinimumIntervalsBasedOnWidth(700)).toBe(4);
|
||||
expect(getMinimumIntervalsBasedOnWidth(900)).toBe(5);
|
||||
expect(getMinimumIntervalsBasedOnWidth(1200)).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIntervalUnit', () => {
|
||||
it('selects the unit from the interval spread', () => {
|
||||
expect(getIntervalUnit(130, 0).name).toBe('ms');
|
||||
expect(getIntervalUnit(1100, 0).name).toBe('s');
|
||||
expect(getIntervalUnit(70_000, 0).name).toBe('m');
|
||||
});
|
||||
|
||||
it('accounts for a large offset (deep-zoom labels stay readable)', () => {
|
||||
// Cursor spread is tiny but the window starts 5,000,000ms into the trace.
|
||||
expect(getIntervalUnit(100, 5_000_000).name).toBe('hr');
|
||||
});
|
||||
|
||||
// Regression: the interval COUNT changes the chosen unit, so the crosshair
|
||||
// badge must use the same width-derived count as the ticks. On a 5.5s trace a
|
||||
// narrow ruler (5 intervals → 1100ms → "s") and a wide one (6 intervals →
|
||||
// 916ms → "ms") pick different units.
|
||||
it('can resolve to different units for the same spread at different counts', () => {
|
||||
const spread = 5500;
|
||||
expect(getIntervalUnit(spread / 5, 0).name).toBe('s');
|
||||
expect(getIntervalUnit(spread / 6, 0).name).toBe('ms');
|
||||
});
|
||||
});
|
||||
|
||||
describe('badge/tick unit consistency', () => {
|
||||
// The invariant the fix guarantees: when the badge and the ticks are fed the
|
||||
// same width-derived intervalSpread, every tick label uses the badge's unit.
|
||||
it.each([
|
||||
{ spread: 1287, width: 900 },
|
||||
{ spread: 5500, width: 900 }, // narrow → 5 intervals, the mismatch case
|
||||
{ spread: 5500, width: 1200 }, // wide → 6 intervals
|
||||
{ spread: 120_000, width: 700 },
|
||||
])('spread=$spread width=$width', ({ spread, width }) => {
|
||||
const minIntervals = getMinimumIntervalsBasedOnWidth(width);
|
||||
const intervalSpread = spread / minIntervals;
|
||||
const badgeUnit = getIntervalUnit(intervalSpread, 0).name;
|
||||
const intervals = getIntervals(intervalSpread, spread, 0);
|
||||
|
||||
intervals.forEach((interval) => {
|
||||
expect(unitOfLabel(interval.label)).toBe(badgeUnit);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,14 +10,18 @@ export type { Interval };
|
||||
|
||||
/**
|
||||
* Select the interval unit matching the timeline's logic.
|
||||
* Exported so crosshair labels use the same unit as timeline ticks.
|
||||
* Exported so crosshair labels use the same unit as the timeline ticks.
|
||||
*
|
||||
* Takes the already-computed `intervalSpread` (spread / minIntervals) rather
|
||||
* than deriving it from a hardcoded interval count — the tick count is
|
||||
* width-dependent (`getMinimumIntervalsBasedOnWidth`), so callers must pass the
|
||||
* same `intervalSpread` the ticks use or the badge unit can diverge from the
|
||||
* ticks (e.g. ms vs s) on narrower rulers like the waterfall.
|
||||
*/
|
||||
export function getIntervalUnit(
|
||||
spread: number,
|
||||
intervalSpread: number,
|
||||
offsetTimestamp: number,
|
||||
): IIntervalUnit {
|
||||
const minIntervals = 6;
|
||||
const intervalSpread = spread / minIntervals;
|
||||
const valueForUnitSelection = Math.max(offsetTimestamp, intervalSpread);
|
||||
let unit: IIntervalUnit = INTERVAL_UNITS[0];
|
||||
for (let idx = INTERVAL_UNITS.length - 1; idx >= 0; idx -= 1) {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
.container {
|
||||
// Gutter matches the header/subHeader 16px; bottom gap before the panels.
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: inherit;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState } from 'react';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { ArrowUpRight } from '@signozhq/icons';
|
||||
|
||||
import styles from './MissingSpansBanner.module.scss';
|
||||
|
||||
const MISSING_SPANS_DOCS_URL =
|
||||
'https://signoz.io/docs/userguide/traces/#missing-spans';
|
||||
|
||||
function MissingSpansBanner(): JSX.Element | null {
|
||||
// Session-only dismissal — not persisted, so the banner returns on reload.
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
|
||||
if (isDismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Wrapper owns the gutter: Callout is width:100%, so putting the gutter as a
|
||||
// margin on it would overflow the parent by the margin width. Pad instead.
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Callout
|
||||
type="info"
|
||||
size="small"
|
||||
showIcon
|
||||
action="dismissible"
|
||||
onClick={(): void => setIsDismissed(true)}
|
||||
testId="missing-spans-banner"
|
||||
title={
|
||||
<span className={styles.title}>
|
||||
This trace has missing spans
|
||||
<a
|
||||
className={styles.link}
|
||||
href={MISSING_SPANS_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn More <ArrowUpRight size={14} />
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MissingSpansBanner;
|
||||
@@ -31,6 +31,7 @@ import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
||||
import { useTraceStore } from '../stores/traceStore';
|
||||
import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
|
||||
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
|
||||
import MissingSpansBanner from './MissingSpansBanner';
|
||||
import TraceOptionsMenu from './TraceOptionsMenu';
|
||||
|
||||
import styles from './TraceDetailsHeader.module.scss';
|
||||
@@ -48,6 +49,7 @@ export interface TraceMetadataForHeader {
|
||||
rootServiceName: string;
|
||||
rootServiceEntryPoint: string;
|
||||
rootSpanStatusCode: string;
|
||||
hasMissingSpans: boolean;
|
||||
}
|
||||
|
||||
interface TraceDetailsHeaderProps {
|
||||
@@ -229,6 +231,8 @@ function TraceDetailsHeader({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{traceMetadata?.hasMissingSpans && <MissingSpansBanner />}
|
||||
|
||||
<FieldsSelector
|
||||
isOpen={isPreviewFieldsOpen}
|
||||
title="Preview fields"
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
:global(.ant-collapse-header) {
|
||||
border-top: 1px solid var(--l2-border);
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-content) {
|
||||
@@ -98,6 +99,13 @@
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
// The flamegraph's ResizableBox above renders a 1px resize handle at its
|
||||
// bottom edge; drop the header's own top border so the two don't stack
|
||||
// into a double border at the flamegraph/waterfall juncture.
|
||||
:global(.ant-collapse-header) {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-item) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -49,59 +49,6 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.missingSpans {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 44px;
|
||||
margin: 16px;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
background: rgba(69, 104, 220, 0.1);
|
||||
}
|
||||
|
||||
.leftInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.rightInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
&:hover {
|
||||
background-color: unset;
|
||||
color: var(--bg-robin-200);
|
||||
}
|
||||
}
|
||||
|
||||
.splitPanel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -125,6 +72,9 @@
|
||||
|
||||
.sidebarHeader {
|
||||
flex-shrink: 0;
|
||||
// Matches the body `.sidebar` border-right so the span-name / timeline
|
||||
// divider is continuous from the top of the container through the ruler.
|
||||
border-right: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.resizeHandleHeader {
|
||||
@@ -170,7 +120,7 @@
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
border-right: 1px solid var(--l2-border);
|
||||
|
||||
// ResizableBox child renders with a global `.resizable-box__content` class
|
||||
// — give it independent horizontal scrolling.
|
||||
@@ -185,6 +135,18 @@
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
// The drag handle is painted --l2-border by default, which would stack with
|
||||
// our border-right into a 2px divider. Keep it as a hover-only affordance so
|
||||
// the border-right stays the sole 1px divider (matching the header segment).
|
||||
:global(.resizable-box__handle--right) {
|
||||
background: transparent;
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
background: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
height: 0.3rem;
|
||||
}
|
||||
@@ -216,10 +178,12 @@
|
||||
|
||||
.treeRow:hover,
|
||||
.treeRow.hoveredSpan {
|
||||
border-radius: 4px;
|
||||
// Left end of the row band — round only the outer (left) corners so the
|
||||
// highlight joins the status + timeline segments into one continuous band.
|
||||
border-radius: 4px 0 0 4px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--l3-background) 20%,
|
||||
var(--l3-background) 60%,
|
||||
transparent
|
||||
) !important;
|
||||
|
||||
@@ -262,20 +226,22 @@
|
||||
--badge-border-width: 0px;
|
||||
|
||||
&.hoveredSpan {
|
||||
border-radius: 4px;
|
||||
// Middle segment of the row band — square so it butts up against the
|
||||
// name and timeline segments (no rounded corner at the badge column).
|
||||
border-radius: 0;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--l3-background) 20%,
|
||||
var(--l3-background) 60%,
|
||||
transparent
|
||||
) !important;
|
||||
}
|
||||
|
||||
&.isInterested,
|
||||
&.isSelectedNonMatching {
|
||||
border-radius: 4px;
|
||||
border-radius: 0;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--l3-background) 40%,
|
||||
var(--l3-background) 80%,
|
||||
transparent
|
||||
) !important;
|
||||
}
|
||||
@@ -309,20 +275,21 @@
|
||||
|
||||
&:hover,
|
||||
&.hoveredSpan {
|
||||
border-radius: 4px;
|
||||
// Right end of the row band — round only the outer (right) corners.
|
||||
border-radius: 0 4px 4px 0;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--l3-background) 20%,
|
||||
var(--l3-background) 60%,
|
||||
transparent
|
||||
) !important;
|
||||
}
|
||||
|
||||
&:has(.isInterested),
|
||||
&:has(.isSelectedNonMatching) {
|
||||
border-radius: 4px;
|
||||
border-radius: 0 4px 4px 0;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--l3-background) 40%,
|
||||
var(--l3-background) 80%,
|
||||
transparent
|
||||
) !important;
|
||||
}
|
||||
@@ -345,10 +312,11 @@
|
||||
|
||||
&.isInterested,
|
||||
&.isSelectedNonMatching {
|
||||
border-radius: 4px;
|
||||
// Left end of the row band — outer (left) corners only.
|
||||
border-radius: 4px 0 0 4px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--l3-background) 40%,
|
||||
var(--l3-background) 80%,
|
||||
transparent
|
||||
) !important;
|
||||
}
|
||||
@@ -471,7 +439,7 @@
|
||||
padding-left: 8px;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
background: linear-gradient(to left, var(--l1-background) 60%, transparent);
|
||||
background: linear-gradient(to left, var(--l2-background) 40%, transparent);
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
@@ -599,6 +567,18 @@
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
// A dimmed span must still show the full-opacity hover state when hovered.
|
||||
// These win over `.isDimmed` on specificity so brightness is restored across
|
||||
// the whole row (name column, status cell, and timeline bar) on hover.
|
||||
.treeRow:hover .isDimmed,
|
||||
.treeRow.hoveredSpan .isDimmed,
|
||||
.timelineRow:hover .isDimmed,
|
||||
.timelineRow.hoveredSpan .isDimmed,
|
||||
.statusCell:hover.isDimmed,
|
||||
.statusCell.hoveredSpan.isDimmed {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.isHighlighted {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -33,14 +33,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { colorToRgb } from 'lib/uPlotLib/utils/generateColor';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleAlert,
|
||||
Link,
|
||||
ListPlus,
|
||||
} from '@signozhq/icons';
|
||||
import { ChevronDown, ChevronRight, Link, ListPlus } from '@signozhq/icons';
|
||||
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
|
||||
import { resolveSpanColor } from 'pages/TraceDetailsV3/utils';
|
||||
import { useBoundaryPagination } from 'pages/TraceDetailsV3/TraceWaterfall/hooks/useBoundaryPagination';
|
||||
@@ -551,7 +544,9 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
cursorX,
|
||||
onMouseMove: onCrosshairMove,
|
||||
onMouseLeave: onCrosshairLeave,
|
||||
} = useCrosshair({ containerRef: timelineAreaRef, enabled: false });
|
||||
// Rows are padded 0 15px while `.timeline` spans full width — inset the
|
||||
// crosshair by the same 15px so it aligns with the ruler ticks and bars.
|
||||
} = useCrosshair({ containerRef: timelineAreaRef, insetX: 15 });
|
||||
|
||||
// Imperative DOM class toggling for hover highlights (avoids React re-renders)
|
||||
const applyHoverClass = useCallback((spanId: string | null): void => {
|
||||
@@ -854,28 +849,6 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{traceMetadata.hasMissingSpans && (
|
||||
<div className={styles.missingSpans}>
|
||||
<section className={styles.leftInfo}>
|
||||
<CircleAlert size={14} />
|
||||
<span className={styles.text}>This trace has missing spans</span>
|
||||
</section>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.rightInfo}
|
||||
suffix={<ArrowUpRight size={14} />}
|
||||
onClick={(): WindowProxy | null =>
|
||||
window.open(
|
||||
'https://signoz.io/docs/userguide/traces/#missing-spans',
|
||||
'_blank',
|
||||
)
|
||||
}
|
||||
>
|
||||
Learn More
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isFetching && <div className={styles.loadingBar} />}
|
||||
<div className={styles.splitPanel} ref={scrollContainerRef}>
|
||||
{/* Sticky header row */}
|
||||
@@ -994,8 +967,8 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
data-span-id={span.span_id}
|
||||
onMouseEnter={(): void => handleRowMouseEnter(span.span_id)}
|
||||
onMouseLeave={handleRowMouseLeave}
|
||||
onMouseEnter={(): void => applyHoverClass(span.span_id)}
|
||||
onMouseLeave={(): void => applyHoverClass(null)}
|
||||
onClick={(): void => handleSpanClick(span)}
|
||||
>
|
||||
{span.response_status_code && (
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { RefObject } from 'react';
|
||||
|
||||
import { useCrosshair } from '../useCrosshair';
|
||||
|
||||
// Container spanning [left, left+width]; getBoundingClientRect is all the hook reads.
|
||||
function mockContainer(left: number, width: number): RefObject<HTMLElement> {
|
||||
const el = document.createElement('div');
|
||||
el.getBoundingClientRect = jest.fn(
|
||||
(): DOMRect =>
|
||||
({
|
||||
left,
|
||||
width,
|
||||
top: 0,
|
||||
height: 0,
|
||||
x: left,
|
||||
y: 0,
|
||||
right: left + width,
|
||||
bottom: 0,
|
||||
toJSON: (): Record<string, unknown> => ({}),
|
||||
}) as DOMRect,
|
||||
);
|
||||
return { current: el };
|
||||
}
|
||||
|
||||
function move(clientX: number): React.MouseEvent {
|
||||
return { clientX } as React.MouseEvent;
|
||||
}
|
||||
|
||||
describe('useCrosshair', () => {
|
||||
it('maps the cursor to 0 at the container edge with no inset', () => {
|
||||
const containerRef = mockContainer(100, 1000);
|
||||
const { result } = renderHook(() => useCrosshair({ containerRef }));
|
||||
|
||||
act(() => result.current.onMouseMove(move(600)));
|
||||
|
||||
expect(result.current.cursorX).toBe(500);
|
||||
expect(result.current.cursorXPercent).toBeCloseTo(0.5);
|
||||
});
|
||||
|
||||
it('offsets and rescales by insetX so 0% aligns with the content start', () => {
|
||||
const containerRef = mockContainer(100, 1000); // content = [115, 1085], width 970
|
||||
const { result } = renderHook(() =>
|
||||
useCrosshair({ containerRef, insetX: 15 }),
|
||||
);
|
||||
|
||||
// At the content start (left + inset) → 0ms, line sits at the inset.
|
||||
act(() => result.current.onMouseMove(move(115)));
|
||||
expect(result.current.cursorX).toBe(15);
|
||||
expect(result.current.cursorXPercent).toBe(0);
|
||||
|
||||
// Halfway through the 970px content → 50%.
|
||||
act(() => result.current.onMouseMove(move(600)));
|
||||
expect(result.current.cursorX).toBe(500);
|
||||
expect(result.current.cursorXPercent).toBeCloseTo(485 / 970);
|
||||
});
|
||||
|
||||
it('clamps the dead padding zones to [0, 1]', () => {
|
||||
const containerRef = mockContainer(100, 1000);
|
||||
const { result } = renderHook(() =>
|
||||
useCrosshair({ containerRef, insetX: 15 }),
|
||||
);
|
||||
|
||||
// Left of the content (inside the left padding) → clamped to start.
|
||||
act(() => result.current.onMouseMove(move(100)));
|
||||
expect(result.current.cursorX).toBe(15);
|
||||
expect(result.current.cursorXPercent).toBe(0);
|
||||
|
||||
// Right of the content (container right edge) → clamped to end.
|
||||
act(() => result.current.onMouseMove(move(1100)));
|
||||
expect(result.current.cursorXPercent).toBe(1);
|
||||
});
|
||||
|
||||
it('resets on mouse leave', () => {
|
||||
const containerRef = mockContainer(0, 800);
|
||||
const { result } = renderHook(() => useCrosshair({ containerRef }));
|
||||
|
||||
act(() => result.current.onMouseMove(move(400)));
|
||||
expect(result.current.cursorX).not.toBeNull();
|
||||
|
||||
act(() => result.current.onMouseLeave());
|
||||
expect(result.current.cursorX).toBeNull();
|
||||
expect(result.current.cursorXPercent).toBeNull();
|
||||
});
|
||||
|
||||
it('is inert when disabled', () => {
|
||||
const containerRef = mockContainer(0, 800);
|
||||
const { result } = renderHook(() =>
|
||||
useCrosshair({ containerRef, enabled: false }),
|
||||
);
|
||||
|
||||
act(() => result.current.onMouseMove(move(400)));
|
||||
expect(result.current.cursorX).toBeNull();
|
||||
expect(result.current.cursorXPercent).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,16 @@ import { RefObject, useCallback, useState } from 'react';
|
||||
interface UseCrosshairArgs {
|
||||
containerRef: RefObject<HTMLElement>;
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Symmetric horizontal inset (px) of the content (ruler ticks + bars) inside
|
||||
* the container: shifts the origin right by `insetX` and shrinks the usable
|
||||
* width by `2 * insetX`. The waterfall pads its rows by 15px while the
|
||||
* crosshair container spans the full width, so the crosshair must map the
|
||||
* cursor into that inset content box to line up with 0ms/ticks/bars.
|
||||
* Flamegraph pads its parent instead, so its container is already the content
|
||||
* box → 0 (default).
|
||||
*/
|
||||
insetX?: number;
|
||||
}
|
||||
|
||||
interface UseCrosshairReturn {
|
||||
@@ -25,6 +35,7 @@ interface UseCrosshairReturn {
|
||||
export function useCrosshair({
|
||||
containerRef,
|
||||
enabled = true,
|
||||
insetX = 0,
|
||||
}: UseCrosshairArgs): UseCrosshairReturn {
|
||||
const [cursorX, setCursorX] = useState<number | null>(null);
|
||||
const [cursorXPercent, setCursorXPercent] = useState<number | null>(null);
|
||||
@@ -39,11 +50,18 @@ export function useCrosshair({
|
||||
return;
|
||||
}
|
||||
|
||||
const x = e.clientX - rect.left;
|
||||
setCursorX(x);
|
||||
setCursorXPercent(x / rect.width);
|
||||
// Map the cursor into the inset content box so 0% aligns with the first
|
||||
// tick / bar origin (not the container edge). Clamp so the dead padding
|
||||
// zones don't produce a line/time before 0ms or past the end.
|
||||
const contentWidth = Math.max(1, rect.width - insetX * 2);
|
||||
const xInContent = Math.max(
|
||||
0,
|
||||
Math.min(e.clientX - rect.left - insetX, contentWidth),
|
||||
);
|
||||
setCursorX(xInContent + insetX);
|
||||
setCursorXPercent(xInContent / contentWidth);
|
||||
},
|
||||
[containerRef, enabled],
|
||||
[containerRef, enabled, insetX],
|
||||
);
|
||||
|
||||
const onMouseLeave = useCallback((): void => {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { ChartNoAxesGantt, TriangleAlert } from '@signozhq/icons';
|
||||
import {
|
||||
ChartNoAxesGantt,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Info,
|
||||
TriangleAlert,
|
||||
} from '@signozhq/icons';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import { Collapse } from 'antd';
|
||||
@@ -34,6 +40,16 @@ import cx from 'classnames';
|
||||
|
||||
import styles from './TraceDetailsV3.module.scss';
|
||||
|
||||
// Lucide chevrons for the flame/waterfall accordion headers, matching the
|
||||
// span-tree chevrons in the waterfall.
|
||||
function renderPanelExpandIcon({
|
||||
isActive,
|
||||
}: {
|
||||
isActive?: boolean;
|
||||
}): JSX.Element {
|
||||
return isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />;
|
||||
}
|
||||
|
||||
function TraceDetailsV3(): JSX.Element {
|
||||
const { id: traceId } = useParams<TraceDetailV3URLProps>();
|
||||
const urlQuery = useUrlQuery();
|
||||
@@ -329,6 +345,7 @@ function TraceDetailsV3(): JSX.Element {
|
||||
rootServiceName: payload.rootServiceName,
|
||||
rootServiceEntryPoint: payload.rootServiceEntryPoint,
|
||||
rootSpanStatusCode: rootSpan?.response_status_code || '',
|
||||
hasMissingSpans: payload.hasMissingSpans || false,
|
||||
};
|
||||
}, [traceData?.payload]);
|
||||
|
||||
@@ -388,6 +405,7 @@ function TraceDetailsV3(): JSX.Element {
|
||||
activeKey={activeKeys.filter((k) => k === 'flame')}
|
||||
onChange={(): void => handleCollapseChange('flame')}
|
||||
size="small"
|
||||
expandIcon={renderPanelExpandIcon}
|
||||
className={styles.flameCollapse}
|
||||
items={[
|
||||
{
|
||||
@@ -401,7 +419,13 @@ function TraceDetailsV3(): JSX.Element {
|
||||
<WarningPopover
|
||||
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
|
||||
placement="bottomLeft"
|
||||
/>
|
||||
>
|
||||
<Info
|
||||
size={16}
|
||||
color="var(--l2-foreground)"
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</WarningPopover>
|
||||
)}
|
||||
</span>
|
||||
{traceData?.payload?.totalSpansCount ? (
|
||||
@@ -442,6 +466,7 @@ function TraceDetailsV3(): JSX.Element {
|
||||
activeKey={activeKeys.filter((k) => k === 'waterfall')}
|
||||
onChange={(): void => handleCollapseChange('waterfall')}
|
||||
size="small"
|
||||
expandIcon={renderPanelExpandIcon}
|
||||
className={cx(styles.waterfallCollapse, {
|
||||
[styles.isDocked]: isWaterfallDocked,
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user