mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-07 19:10:30 +01:00
Compare commits
12 Commits
test/split
...
feat/servi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68b9515385 | ||
|
|
519355a692 | ||
|
|
55bc8592ed | ||
|
|
8fa566d1d0 | ||
|
|
0b1aba04f4 | ||
|
|
5c8d33290b | ||
|
|
30424a3829 | ||
|
|
d2ede7bb16 | ||
|
|
c622bbd112 | ||
|
|
b92d5e934b | ||
|
|
c20520da5f | ||
|
|
0ccb26cbfc |
@@ -3,7 +3,6 @@ import type { Config } from '@jest/types';
|
||||
const USE_SAFE_NAVIGATE_MOCK_PATH = '<rootDir>/__mocks__/useSafeNavigate.ts';
|
||||
|
||||
const config: Config.InitialOptions = {
|
||||
maxWorkers: '50%',
|
||||
silent: true,
|
||||
clearMocks: true,
|
||||
coverageDirectory: 'coverage',
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"@ant-design/icons": "4.8.0",
|
||||
"@codemirror/autocomplete": "6.18.6",
|
||||
"@codemirror/lang-javascript": "6.2.3",
|
||||
"@dagrejs/dagre": "3.0.0",
|
||||
"@dnd-kit/core": "6.1.0",
|
||||
"@dnd-kit/modifiers": "7.0.0",
|
||||
"@dnd-kit/sortable": "8.0.0",
|
||||
@@ -62,6 +63,7 @@
|
||||
"@visx/shape": "3.5.0",
|
||||
"@visx/tooltip": "3.3.0",
|
||||
"@vitejs/plugin-react": "5.1.4",
|
||||
"@xyflow/react": "12.10.2",
|
||||
"ansi-to-html": "0.7.2",
|
||||
"antd": "5.11.0",
|
||||
"antd-table-saveas-excel": "2.2.1",
|
||||
@@ -114,18 +116,15 @@
|
||||
"react-dom": "18.2.0",
|
||||
"react-drag-listview": "2.0.0",
|
||||
"react-error-boundary": "4.0.11",
|
||||
"react-force-graph-2d": "^1.29.1",
|
||||
"react-full-screen": "1.1.1",
|
||||
"react-grid-layout": "^1.3.4",
|
||||
"react-helmet-async": "1.3.0",
|
||||
"react-hook-form": "7.71.2",
|
||||
"react-i18next": "^11.16.1",
|
||||
"react-json-tree": "^0.20.0",
|
||||
"react-lottie": "1.2.10",
|
||||
"react-markdown": "8.0.7",
|
||||
"react-query": "3.39.3",
|
||||
"react-redux": "^7.2.2",
|
||||
"react-rnd": "^10.5.3",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-router-dom-v5-compat": "6.27.0",
|
||||
"react-syntax-highlighter": "15.5.0",
|
||||
|
||||
@@ -65,13 +65,6 @@ export const TraceDetail = Loadable(
|
||||
),
|
||||
);
|
||||
|
||||
export const TraceDetailV3 = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "TraceDetailV3 Page" */ 'pages/TraceDetailV3Page/index'
|
||||
),
|
||||
);
|
||||
|
||||
export const UsageExplorerPage = Loadable(
|
||||
() => import(/* webpackChunkName: "UsageExplorerPage" */ 'modules/Usage'),
|
||||
);
|
||||
|
||||
@@ -48,7 +48,6 @@ import {
|
||||
StatusPage,
|
||||
SupportPage,
|
||||
TraceDetail,
|
||||
TraceDetailV3,
|
||||
TraceFilter,
|
||||
TracesExplorer,
|
||||
TracesFunnelDetails,
|
||||
@@ -139,9 +138,6 @@ const routes: AppRoutes[] = [
|
||||
exact: true,
|
||||
key: 'LOGS_SAVE_VIEWS',
|
||||
},
|
||||
// V3 trace details is gated until release: /trace serves V2 (public),
|
||||
// /trace-old serves V3 (URL-only access). Flip the two `component`
|
||||
// values back to release V3.
|
||||
{
|
||||
path: ROUTES.TRACE_DETAIL,
|
||||
exact: true,
|
||||
@@ -149,13 +145,6 @@ const routes: AppRoutes[] = [
|
||||
isPrivate: true,
|
||||
key: 'TRACE_DETAIL',
|
||||
},
|
||||
{
|
||||
path: ROUTES.TRACE_DETAIL_OLD,
|
||||
exact: true,
|
||||
component: TraceDetailV3,
|
||||
isPrivate: true,
|
||||
key: 'TRACE_DETAIL_OLD',
|
||||
},
|
||||
{
|
||||
path: ROUTES.SETTINGS,
|
||||
exact: false,
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { ApiV3Instance as axios } from 'api';
|
||||
import { omit } from 'lodash-es';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import {
|
||||
GetTraceV3PayloadProps,
|
||||
GetTraceV3SuccessResponse,
|
||||
SpanV3,
|
||||
} from 'types/api/trace/getTraceV3';
|
||||
|
||||
const getTraceV3 = async (
|
||||
props: GetTraceV3PayloadProps,
|
||||
): Promise<SuccessResponse<GetTraceV3SuccessResponse> | ErrorResponse> => {
|
||||
let uncollapsedSpans = [...props.uncollapsedSpans];
|
||||
if (!props.isSelectedSpanIDUnCollapsed) {
|
||||
uncollapsedSpans = uncollapsedSpans.filter(
|
||||
(node) => node !== props.selectedSpanId,
|
||||
);
|
||||
} else if (
|
||||
props.selectedSpanId &&
|
||||
!uncollapsedSpans.includes(props.selectedSpanId)
|
||||
) {
|
||||
// V3 backend only uses uncollapsedSpans list (unlike V2 which also interprets
|
||||
// isSelectedSpanIDUnCollapsed server-side), so explicitly add the selected span
|
||||
uncollapsedSpans.push(props.selectedSpanId);
|
||||
}
|
||||
const postData: GetTraceV3PayloadProps = {
|
||||
...props,
|
||||
uncollapsedSpans,
|
||||
limit: 10000,
|
||||
};
|
||||
const response = await axios.post<GetTraceV3SuccessResponse>(
|
||||
`/traces/${props.traceId}/waterfall`,
|
||||
omit(postData, 'traceId'),
|
||||
);
|
||||
|
||||
// V3 API wraps response in { status, data }
|
||||
const rawPayload = (response.data as any).data || response.data;
|
||||
|
||||
// Derive 'service.name' from resource for convenience — only derived field
|
||||
const spans: SpanV3[] = (rawPayload.spans || []).map((span: any) => ({
|
||||
...span,
|
||||
'service.name': span.resource?.['service.name'] || '',
|
||||
}));
|
||||
|
||||
// V3 API returns startTimestampMillis/endTimestampMillis as relative durations (ms from epoch offset),
|
||||
// not absolute unix millis like V2. The span timestamps are absolute unix millis.
|
||||
// Convert by using the first span's timestamp as the base if there's a mismatch.
|
||||
let { startTimestampMillis, endTimestampMillis } = rawPayload;
|
||||
if (
|
||||
spans.length > 0 &&
|
||||
spans[0].timestamp > 0 &&
|
||||
startTimestampMillis < spans[0].timestamp / 10
|
||||
) {
|
||||
const durationMillis = endTimestampMillis - startTimestampMillis;
|
||||
startTimestampMillis = spans[0].timestamp;
|
||||
endTimestampMillis = startTimestampMillis + durationMillis;
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'Success',
|
||||
payload: {
|
||||
...rawPayload,
|
||||
spans,
|
||||
startTimestampMillis,
|
||||
endTimestampMillis,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default getTraceV3;
|
||||
@@ -1,37 +0,0 @@
|
||||
.details-header {
|
||||
// ghost + secondary missing hover bg token in @signozhq/button
|
||||
--button-ghost-hover-background: var(--l3-background);
|
||||
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
height: 36px;
|
||||
background: var(--l2-background);
|
||||
|
||||
&__title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Button } from '@signozhq/ui';
|
||||
import { X } from '@signozhq/icons';
|
||||
|
||||
import './DetailsHeader.styles.scss';
|
||||
|
||||
export interface HeaderAction {
|
||||
key: string;
|
||||
component: ReactNode; // check later if we can use direct btn itself or not.
|
||||
}
|
||||
|
||||
export interface DetailsHeaderProps {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
actions?: HeaderAction[];
|
||||
closePosition?: 'left' | 'right';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function DetailsHeader({
|
||||
title,
|
||||
onClose,
|
||||
actions,
|
||||
closePosition = 'right',
|
||||
className,
|
||||
}: DetailsHeaderProps): JSX.Element {
|
||||
const closeButton = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
prefix={<X size={14} />}
|
||||
></Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`details-header ${className || ''}`}>
|
||||
{closePosition === 'left' && closeButton}
|
||||
|
||||
<span className="details-header__title">{title}</span>
|
||||
|
||||
{actions && (
|
||||
<div className="details-header__actions">
|
||||
{actions.map((action) => (
|
||||
<div key={action.key}>{action.component}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{closePosition === 'right' && closeButton}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DetailsHeader;
|
||||
@@ -1,7 +0,0 @@
|
||||
.details-panel-drawer {
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { DrawerWrapper } from '@signozhq/ui';
|
||||
|
||||
import './DetailsPanelDrawer.styles.scss';
|
||||
|
||||
interface DetailsPanelDrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function DetailsPanelDrawer({
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
className,
|
||||
}: DetailsPanelDrawerProps): JSX.Element {
|
||||
return (
|
||||
<DrawerWrapper
|
||||
open={isOpen}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
showOverlay={false}
|
||||
className={`details-panel-drawer ${className || ''}`}
|
||||
>
|
||||
<div className="details-panel-drawer__body">{children}</div>
|
||||
</DrawerWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default DetailsPanelDrawer;
|
||||
@@ -1,8 +0,0 @@
|
||||
export type {
|
||||
DetailsHeaderProps,
|
||||
HeaderAction,
|
||||
} from './DetailsHeader/DetailsHeader';
|
||||
export { default as DetailsHeader } from './DetailsHeader/DetailsHeader';
|
||||
export { default as DetailsPanelDrawer } from './DetailsPanelDrawer';
|
||||
export type { DetailsPanelState, UseDetailsPanelOptions } from './types';
|
||||
export { default as useDetailsPanel } from './useDetailsPanel';
|
||||
@@ -1,10 +0,0 @@
|
||||
export interface DetailsPanelState {
|
||||
isOpen: boolean;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export interface UseDetailsPanelOptions {
|
||||
entityId: string | undefined;
|
||||
onClose?: () => void;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { DetailsPanelState, UseDetailsPanelOptions } from './types';
|
||||
|
||||
function useDetailsPanel({
|
||||
entityId,
|
||||
onClose,
|
||||
}: UseDetailsPanelOptions): DetailsPanelState {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const prevEntityIdRef = useRef<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const currentId = entityId || '';
|
||||
if (currentId && currentId !== prevEntityIdRef.current) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
prevEntityIdRef.current = currentId;
|
||||
}, [entityId]);
|
||||
|
||||
const open = useCallback(() => setIsOpen(true), []);
|
||||
const close = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
onClose?.();
|
||||
}, [onClose]);
|
||||
|
||||
return { isOpen, open, close };
|
||||
}
|
||||
|
||||
export default useDetailsPanel;
|
||||
@@ -17,6 +17,7 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import ContextView from 'container/LogDetailedView/ContextView/ContextView';
|
||||
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
|
||||
import JSONView from 'container/LogDetailedView/JsonView';
|
||||
import Overview from 'container/LogDetailedView/Overview';
|
||||
import {
|
||||
aggregateAttributesResourcesToString,
|
||||
@@ -46,7 +47,6 @@ import {
|
||||
TextSelect,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { JsonView } from 'periscope/components/JsonView';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
@@ -562,9 +562,7 @@ function LogDetailInner({
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
)}
|
||||
{selectedView === VIEW_TYPES.JSON && (
|
||||
<JsonView data={LogJsonData} height="68vh" />
|
||||
)}
|
||||
{selectedView === VIEW_TYPES.JSON && <JSONView logData={log} />}
|
||||
|
||||
{selectedView === VIEW_TYPES.CONTEXT && (
|
||||
<ContextView
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
.timeline-v3-container {
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.timeline-v3-cursor-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translateX(-50%);
|
||||
background: var(--l3-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMeasure } from 'react-use';
|
||||
import { resolveTimeFromInterval } from 'components/TimelineV2/utils';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import {
|
||||
getIntervals,
|
||||
getIntervalUnit,
|
||||
getMinimumIntervalsBasedOnWidth,
|
||||
Interval,
|
||||
} from './utils';
|
||||
|
||||
import './TimelineV3.styles.scss';
|
||||
|
||||
interface ITimelineV3Props {
|
||||
startTimestamp: number;
|
||||
endTimestamp: number;
|
||||
timelineHeight: number;
|
||||
offsetTimestamp: number;
|
||||
/** Cursor X as a fraction of the timeline width (0–1). null = no cursor. */
|
||||
cursorXPercent?: number | null;
|
||||
}
|
||||
|
||||
function TimelineV3(props: ITimelineV3Props): JSX.Element {
|
||||
const {
|
||||
startTimestamp,
|
||||
endTimestamp,
|
||||
timelineHeight,
|
||||
offsetTimestamp,
|
||||
cursorXPercent,
|
||||
} = props;
|
||||
const [intervals, setIntervals] = useState<Interval[]>([]);
|
||||
const [ref, { width }] = useMeasure<HTMLDivElement>();
|
||||
|
||||
const spread = endTimestamp - startTimestamp;
|
||||
|
||||
useEffect(() => {
|
||||
if (spread < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const minIntervals = getMinimumIntervalsBasedOnWidth(width);
|
||||
const intervalisedSpread = (spread / minIntervals) * 1.0;
|
||||
const newIntervals = getIntervals(
|
||||
intervalisedSpread,
|
||||
spread,
|
||||
offsetTimestamp,
|
||||
);
|
||||
|
||||
setIntervals(newIntervals);
|
||||
}, [startTimestamp, endTimestamp, width, offsetTimestamp, spread]);
|
||||
|
||||
// Compute cursor time label using the same unit as timeline ticks
|
||||
const cursorLabel = useMemo(() => {
|
||||
if (cursorXPercent == null || spread <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeAtCursor = offsetTimestamp + cursorXPercent * spread;
|
||||
const unit = getIntervalUnit(spread, offsetTimestamp);
|
||||
const formatted = toFixed(resolveTimeFromInterval(timeAtCursor, unit), 2);
|
||||
return `${formatted}${unit.name}`;
|
||||
}, [cursorXPercent, spread, offsetTimestamp]);
|
||||
|
||||
if (endTimestamp < startTimestamp) {
|
||||
console.error(
|
||||
'endTimestamp cannot be less than startTimestamp',
|
||||
startTimestamp,
|
||||
endTimestamp,
|
||||
);
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const strokeColor = 'var(--l3-foreground)';
|
||||
const svgHeight = timelineHeight * 2.5;
|
||||
const cursorX = cursorXPercent != null ? cursorXPercent * width : null;
|
||||
|
||||
return (
|
||||
<div ref={ref as never} className="timeline-v3-container">
|
||||
<svg
|
||||
width={width}
|
||||
height={svgHeight}
|
||||
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>
|
||||
|
||||
{/* Cursor time badge — DOM element for easy CSS styling */}
|
||||
{cursorX !== null && cursorLabel && (
|
||||
<div className="timeline-v3-cursor-badge" style={{ left: cursorX }}>
|
||||
{cursorLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimelineV3;
|
||||
@@ -1,109 +0,0 @@
|
||||
import {
|
||||
IIntervalUnit,
|
||||
Interval,
|
||||
INTERVAL_UNITS,
|
||||
resolveTimeFromInterval,
|
||||
} from 'components/TimelineV2/utils';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
export type { Interval };
|
||||
|
||||
/**
|
||||
* Select the interval unit matching the timeline's logic.
|
||||
* Exported so crosshair labels use the same unit as timeline ticks.
|
||||
*/
|
||||
export function getIntervalUnit(
|
||||
spread: 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) {
|
||||
if (valueForUnitSelection * INTERVAL_UNITS[idx].multiplier >= 1) {
|
||||
unit = INTERVAL_UNITS[idx];
|
||||
break;
|
||||
}
|
||||
}
|
||||
return unit;
|
||||
}
|
||||
|
||||
/** 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,
|
||||
},
|
||||
];
|
||||
|
||||
// Only show even-interval ticks — skip the trailing partial tick at the edge.
|
||||
// The last even tick sits before the full width, so it doesn't conflict with
|
||||
// span duration labels that may have sub-millisecond precision.
|
||||
let elapsedIntervals = 0;
|
||||
|
||||
while (
|
||||
elapsedIntervals + intervalSpreadNormalized <= baseSpread &&
|
||||
intervals.length < 20
|
||||
) {
|
||||
elapsedIntervals += intervalSpreadNormalized;
|
||||
const labelTime = offsetTimestamp + elapsedIntervals;
|
||||
|
||||
intervals.push({
|
||||
label: `${toFixed(resolveTimeFromInterval(labelTime, intervalUnit), 2)}${
|
||||
intervalUnit.name
|
||||
}`,
|
||||
percentage: (elapsedIntervals / baseSpread) * 100,
|
||||
});
|
||||
}
|
||||
|
||||
return intervals;
|
||||
}
|
||||
@@ -37,7 +37,6 @@ export enum LOCALSTORAGE {
|
||||
SHOW_FREQUENCY_CHART = 'SHOW_FREQUENCY_CHART',
|
||||
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
|
||||
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
|
||||
TRACE_DETAILS_SPAN_DETAILS_POSITION = 'TRACE_DETAILS_SPAN_DETAILS_POSITION',
|
||||
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
|
||||
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ export const REACT_QUERY_KEY = {
|
||||
UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE',
|
||||
GET_ACTIVE_LICENSE_V3: 'GET_ACTIVE_LICENSE_V3',
|
||||
GET_TRACE_V2_WATERFALL: 'GET_TRACE_V2_WATERFALL',
|
||||
GET_TRACE_V3_WATERFALL: 'GET_TRACE_V3_WATERFALL',
|
||||
GET_TRACE_V2_FLAMEGRAPH: 'GET_TRACE_V2_FLAMEGRAPH',
|
||||
GET_POD_LIST: 'GET_POD_LIST',
|
||||
GET_NODE_LIST: 'GET_NODE_LIST',
|
||||
|
||||
@@ -8,7 +8,6 @@ const ROUTES = {
|
||||
SERVICE_MAP: '/service-map',
|
||||
TRACE: '/trace',
|
||||
TRACE_DETAIL: '/trace/:id',
|
||||
TRACE_DETAIL_OLD: '/trace-old/:id',
|
||||
TRACES_EXPLORER: '/traces-explorer',
|
||||
ONBOARDING: '/onboarding',
|
||||
GET_STARTED: '/get-started',
|
||||
|
||||
@@ -33,102 +33,6 @@ const themeColors = {
|
||||
purple: '#800080',
|
||||
cyan: '#00FFFF',
|
||||
},
|
||||
traceDetailColorsV3: {
|
||||
// Blues
|
||||
blue1: '#2F80ED',
|
||||
blue2: '#3366E6',
|
||||
blue3: '#4682B4',
|
||||
blue4: '#1F63E0',
|
||||
blue5: '#3A7AED',
|
||||
blue6: '#5A9DF5',
|
||||
blue7: '#2874A6',
|
||||
blue8: '#2E86C1',
|
||||
blue9: '#3498DB',
|
||||
blue10: '#1E90FF',
|
||||
blue11: '#4169E1',
|
||||
|
||||
// Cyans / Teals
|
||||
cyan1: '#00CEC9',
|
||||
cyan2: '#22A6F2',
|
||||
cyan3: '#00B0AA',
|
||||
cyan4: '#33D6C2',
|
||||
cyan5: '#66E9DA',
|
||||
cyan6: '#48DBFB',
|
||||
cyan7: '#00BFFF',
|
||||
cyan8: '#63B8FF',
|
||||
teal1: '#009688',
|
||||
teal2: '#1ABC9C',
|
||||
teal3: '#48C9B0',
|
||||
teal4: '#76D7C4',
|
||||
teal5: '#20B2AA',
|
||||
|
||||
// Greens
|
||||
green1: '#27AE60',
|
||||
green2: '#3CB371',
|
||||
green3: '#1E8449',
|
||||
green4: '#2ECC71',
|
||||
green5: '#58D68D',
|
||||
green6: '#229954',
|
||||
green7: '#52BE80',
|
||||
green8: '#82E0AA',
|
||||
green9: '#73C6B6',
|
||||
|
||||
// Limes
|
||||
lime1: '#A3E635',
|
||||
lime2: '#B9F18D',
|
||||
lime3: '#84CC16',
|
||||
lime4: '#65A30D',
|
||||
|
||||
// Yellows
|
||||
yellow1: '#F1C40F',
|
||||
yellow2: '#F7DC6F',
|
||||
yellow3: '#F9E79F',
|
||||
yellow4: '#F4D03F',
|
||||
yellow5: '#D4AC0D',
|
||||
|
||||
// Golds / Ambers
|
||||
gold1: '#F2C94C',
|
||||
gold2: '#FFD93D',
|
||||
gold3: '#FFCA28',
|
||||
gold4: '#B7950B',
|
||||
gold5: '#D4A017',
|
||||
|
||||
// Oranges (non-red)
|
||||
orange1: '#F39C12',
|
||||
orange2: '#E67E22',
|
||||
orange3: '#F5B041',
|
||||
orange4: '#D35400',
|
||||
orange5: '#EB984E',
|
||||
orange6: '#FAD7A0',
|
||||
|
||||
// Purples / Violets
|
||||
purple1: '#BB6BD9',
|
||||
purple2: '#9B51E0',
|
||||
purple3: '#DA77F2',
|
||||
purple4: '#C77DFF',
|
||||
purple5: '#6C5CE7',
|
||||
purple6: '#8E44AD',
|
||||
purple7: '#9B59B6',
|
||||
purple8: '#BB8FCE',
|
||||
purple9: '#7D3C98',
|
||||
purple10: '#A569BD',
|
||||
|
||||
// Lavenders
|
||||
lavender1: '#AF7AC5',
|
||||
lavender2: '#C39BD3',
|
||||
lavender3: '#D2B4DE',
|
||||
|
||||
// Pinks / Magentas
|
||||
pink1: '#E91E8C',
|
||||
pink2: '#FF6FD8',
|
||||
pink3: '#F06292',
|
||||
pink4: '#CE93D8',
|
||||
|
||||
// Salmons / Corals (distinct from error red)
|
||||
salmon1: '#FF8A65',
|
||||
salmon2: '#FFAB91',
|
||||
salmon3: '#E0876A',
|
||||
},
|
||||
chartcolors: {
|
||||
// Blues (3)
|
||||
dodgerBlue: '#2F80ED',
|
||||
|
||||
@@ -123,7 +123,6 @@
|
||||
&__row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
max-width: 825px;
|
||||
gap: 25px;
|
||||
|
||||
@@ -11,12 +11,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.infra-metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.infra-metrics-card {
|
||||
margin: 1rem 0;
|
||||
height: 300px;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useQueries, UseQueryResult } from 'react-query';
|
||||
import { Card, Skeleton, Typography } from 'antd';
|
||||
import { Card, Col, Row, Skeleton, Typography } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import Uplot from 'components/Uplot';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
@@ -163,16 +163,16 @@ function NodeMetrics({
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className="infra-metrics-grid">
|
||||
<Row gutter={24}>
|
||||
{queries.map((query, idx) => (
|
||||
<div key={widgetInfo[idx].title}>
|
||||
<Col span={12} key={widgetInfo[idx].title}>
|
||||
<Typography.Text>{widgetInfo[idx].title}</Typography.Text>
|
||||
<Card bordered className="infra-metrics-card" ref={graphRef}>
|
||||
{renderCardContent(query, idx)}
|
||||
</Card>
|
||||
</div>
|
||||
</Col>
|
||||
))}
|
||||
</div>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useQueries, UseQueryResult } from 'react-query';
|
||||
import { Card, Skeleton, Typography } from 'antd';
|
||||
import { Card, Col, Row, Skeleton, Typography } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import Uplot from 'components/Uplot';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
@@ -146,16 +146,16 @@ function PodMetrics({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="infra-metrics-grid">
|
||||
<Row gutter={24}>
|
||||
{queries.map((query, idx) => (
|
||||
<div key={podWidgetInfo[idx].title}>
|
||||
<Col span={12} key={podWidgetInfo[idx].title}>
|
||||
<Typography.Text>{podWidgetInfo[idx].title}</Typography.Text>
|
||||
<Card bordered className="infra-metrics-card" ref={graphRef}>
|
||||
{renderCardContent(query, idx)}
|
||||
</Card>
|
||||
</div>
|
||||
</Col>
|
||||
))}
|
||||
</div>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
/**
|
||||
* Login - Session Context & Organization Tests
|
||||
*
|
||||
* Split from Login.test.tsx for better parallelization.
|
||||
* Tests session context fetching and organization selection.
|
||||
*/
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import Login from '../index';
|
||||
import {
|
||||
CALLBACK_AUTHN_ORG,
|
||||
mockMultiOrgMixedAuth,
|
||||
mockSingleOrgPasswordAuth,
|
||||
mockVersionSetupCompleted,
|
||||
PASSWORD_AUTHN_EMAIL,
|
||||
PASSWORD_AUTHN_ORG,
|
||||
SESSIONS_CONTEXT_ENDPOINT,
|
||||
VERSION_ENDPOINT,
|
||||
} from './Login.test-utils';
|
||||
|
||||
// =============================================================================
|
||||
// MOCKS
|
||||
// =============================================================================
|
||||
|
||||
jest.mock('lib/history', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
push: jest.fn(),
|
||||
location: {
|
||||
search: '',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// TESTS
|
||||
// =============================================================================
|
||||
|
||||
describe('Login - Session Context & Organization', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ data: mockVersionSetupCompleted, status: 'success' }),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('Session Context Fetching', () => {
|
||||
it('fetches session context on next button click and enables password', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
|
||||
),
|
||||
);
|
||||
|
||||
const { getByTestId } = render(<Login />);
|
||||
|
||||
const emailInput = await waitFor(() => {
|
||||
const input = getByTestId('email');
|
||||
expect(input).not.toBeDisabled();
|
||||
return input;
|
||||
});
|
||||
|
||||
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
|
||||
|
||||
const nextButton = await waitFor(() => {
|
||||
const button = getByTestId('initiate_login');
|
||||
expect(button).not.toBeDisabled();
|
||||
return button;
|
||||
});
|
||||
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('password')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles session context API errors', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(500),
|
||||
ctx.json({
|
||||
error: {
|
||||
code: 'internal_server',
|
||||
message: 'couldnt fetch the sessions context',
|
||||
url: '',
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const { getByTestId, getByText } = render(<Login />);
|
||||
|
||||
const emailInput = await waitFor(() => {
|
||||
const input = getByTestId('email');
|
||||
expect(input).not.toBeDisabled();
|
||||
return input;
|
||||
});
|
||||
|
||||
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
|
||||
|
||||
const nextButton = await waitFor(() => {
|
||||
const button = getByTestId('initiate_login');
|
||||
expect(button).not.toBeDisabled();
|
||||
return button;
|
||||
});
|
||||
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('couldnt fetch the sessions context')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('auto-selects organization when only one exists', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
|
||||
),
|
||||
);
|
||||
|
||||
const { getByTestId } = render(<Login />);
|
||||
|
||||
const emailInput = await waitFor(() => {
|
||||
const input = getByTestId('email');
|
||||
expect(input).not.toBeDisabled();
|
||||
return input;
|
||||
});
|
||||
|
||||
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
|
||||
|
||||
const nextButton = await waitFor(() => {
|
||||
const button = getByTestId('initiate_login');
|
||||
expect(button).not.toBeDisabled();
|
||||
return button;
|
||||
});
|
||||
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('password')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/organization name/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Organization Selection', () => {
|
||||
it('shows organization dropdown when multiple orgs exist', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockMultiOrgMixedAuth })),
|
||||
),
|
||||
);
|
||||
|
||||
const { getByTestId, getByText } = render(<Login />);
|
||||
|
||||
const emailInput = await waitFor(() => {
|
||||
const input = getByTestId('email');
|
||||
expect(input).not.toBeDisabled();
|
||||
return input;
|
||||
});
|
||||
|
||||
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
|
||||
|
||||
const nextButton = await waitFor(() => {
|
||||
const button = getByTestId('initiate_login');
|
||||
expect(button).not.toBeDisabled();
|
||||
return button;
|
||||
});
|
||||
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Organization Name')).toBeInTheDocument();
|
||||
});
|
||||
await screen.findByRole('combobox');
|
||||
|
||||
await user.click(screen.getByRole('combobox'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(PASSWORD_AUTHN_ORG)).toBeInTheDocument();
|
||||
expect(screen.getByText(CALLBACK_AUTHN_ORG)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('updates selected organization on dropdown change', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockMultiOrgMixedAuth })),
|
||||
),
|
||||
);
|
||||
|
||||
render(<Login />);
|
||||
|
||||
const emailInput = await waitFor(() => {
|
||||
const input = screen.getByTestId('email');
|
||||
expect(input).not.toBeDisabled();
|
||||
return input;
|
||||
});
|
||||
|
||||
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
|
||||
|
||||
const nextButton = await waitFor(() => {
|
||||
const button = screen.getByTestId('initiate_login');
|
||||
expect(button).not.toBeDisabled();
|
||||
return button;
|
||||
});
|
||||
|
||||
await user.click(nextButton);
|
||||
|
||||
await screen.findByRole('combobox');
|
||||
|
||||
await user.click(screen.getByRole('combobox'));
|
||||
await user.click(screen.getByText(CALLBACK_AUTHN_ORG));
|
||||
|
||||
await screen.findByRole('button', { name: /sign in with sso/i });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* Login - Initial Render & Setup Tests
|
||||
*
|
||||
* Split from Login.test.tsx for better parallelization.
|
||||
* Tests initial render, loading states, and setup validation.
|
||||
*/
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import Login from '../index';
|
||||
import {
|
||||
mockVersionSetupCompleted,
|
||||
mockVersionSetupIncomplete,
|
||||
VERSION_ENDPOINT,
|
||||
} from './Login.test-utils';
|
||||
|
||||
// =============================================================================
|
||||
// MOCKS
|
||||
// =============================================================================
|
||||
|
||||
jest.mock('lib/history', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
push: jest.fn(),
|
||||
location: {
|
||||
search: '',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const mockHistoryPush = history.push as jest.MockedFunction<
|
||||
typeof history.push
|
||||
>;
|
||||
|
||||
// =============================================================================
|
||||
// TESTS
|
||||
// =============================================================================
|
||||
|
||||
describe('Login - Initial Render & Setup', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ data: mockVersionSetupCompleted, status: 'success' }),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('Initial Render', () => {
|
||||
it('renders login form with email input and next button', () => {
|
||||
const { getByTestId, getByPlaceholderText } = render(<Login />);
|
||||
|
||||
expect(
|
||||
screen.getByText(/sign in to monitor, trace, and troubleshoot/i),
|
||||
).toBeInTheDocument();
|
||||
expect(getByTestId('email')).toBeInTheDocument();
|
||||
expect(getByTestId('initiate_login')).toBeInTheDocument();
|
||||
expect(getByPlaceholderText('e.g. john@signoz.io')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading state when version data is being fetched', () => {
|
||||
server.use(
|
||||
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.delay(100),
|
||||
ctx.status(200),
|
||||
ctx.json({ data: mockVersionSetupCompleted, status: 'success' }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const { getByTestId } = render(<Login />);
|
||||
|
||||
expect(getByTestId('initiate_login')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Setup Check', () => {
|
||||
it('redirects to signup when setup is not completed', async () => {
|
||||
server.use(
|
||||
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ data: mockVersionSetupIncomplete, status: 'success' }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<Login />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.SIGN_UP);
|
||||
});
|
||||
});
|
||||
|
||||
it('stays on login page when setup is completed', async () => {
|
||||
render(<Login />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHistoryPush).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles version API error gracefully', async () => {
|
||||
server.use(
|
||||
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ error: 'Server error' })),
|
||||
),
|
||||
);
|
||||
|
||||
render(<Login />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHistoryPush).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,172 +0,0 @@
|
||||
/**
|
||||
* Shared test utilities for Login tests.
|
||||
* Extract common mocks, data, and setup to avoid duplication across split test files.
|
||||
*/
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { Info } from 'types/api/v1/version/get';
|
||||
import { SessionsContext } from 'types/api/v2/sessions/context/get';
|
||||
import { Token } from 'types/api/v2/sessions/email_password/post';
|
||||
import { ErrorV2 } from 'types/api';
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
export const VERSION_ENDPOINT = '*/api/v1/version';
|
||||
export const SESSIONS_CONTEXT_ENDPOINT = '*/api/v2/sessions/context';
|
||||
export const EMAIL_PASSWORD_ENDPOINT = '*/api/v2/sessions/email_password';
|
||||
export const CALLBACK_AUTHN_ORG = 'callback_authn_org';
|
||||
export const CALLBACK_AUTHN_URL = 'https://sso.example.com/auth';
|
||||
export const PASSWORD_AUTHN_ORG = 'password_authn_org';
|
||||
export const PASSWORD_AUTHN_EMAIL = 'jest.test@signoz.io';
|
||||
|
||||
// =============================================================================
|
||||
// MOCK DATA
|
||||
// =============================================================================
|
||||
|
||||
export const mockVersionSetupCompleted: Info = {
|
||||
setupCompleted: true,
|
||||
ee: 'Y',
|
||||
version: '0.25.0',
|
||||
};
|
||||
|
||||
export const mockVersionSetupIncomplete: Info = {
|
||||
setupCompleted: false,
|
||||
ee: 'Y',
|
||||
version: '0.25.0',
|
||||
};
|
||||
|
||||
export const mockSingleOrgPasswordAuth: SessionsContext = {
|
||||
exists: true,
|
||||
orgs: [
|
||||
{
|
||||
id: 'org-1',
|
||||
name: 'Test Organization',
|
||||
authNSupport: {
|
||||
password: [{ provider: 'email_password' }],
|
||||
callback: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const mockSingleOrgCallbackAuth: SessionsContext = {
|
||||
exists: true,
|
||||
orgs: [
|
||||
{
|
||||
id: 'org-1',
|
||||
name: 'Test Organization',
|
||||
authNSupport: {
|
||||
password: [],
|
||||
callback: [{ provider: 'google', url: CALLBACK_AUTHN_URL }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const mockMultiOrgMixedAuth: SessionsContext = {
|
||||
exists: true,
|
||||
orgs: [
|
||||
{
|
||||
id: 'org-1',
|
||||
name: PASSWORD_AUTHN_ORG,
|
||||
authNSupport: {
|
||||
password: [{ provider: 'email_password' }],
|
||||
callback: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'org-2',
|
||||
name: CALLBACK_AUTHN_ORG,
|
||||
authNSupport: {
|
||||
password: [],
|
||||
callback: [{ provider: 'google', url: CALLBACK_AUTHN_URL }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const mockOrgWithWarning: SessionsContext = {
|
||||
exists: true,
|
||||
orgs: [
|
||||
{
|
||||
id: 'org-1',
|
||||
name: 'Warning Organization',
|
||||
authNSupport: {
|
||||
password: [{ provider: 'email_password' }],
|
||||
callback: [],
|
||||
},
|
||||
warning: {
|
||||
code: 'ORG_WARNING',
|
||||
message: 'Organization has limited access',
|
||||
url: 'https://example.com/warning',
|
||||
errors: [{ message: 'Contact admin for full access' }],
|
||||
} as ErrorV2,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const mockMultiOrgWithWarning: SessionsContext = {
|
||||
exists: true,
|
||||
orgs: [
|
||||
{
|
||||
id: 'org-1',
|
||||
name: 'Normal Organization',
|
||||
authNSupport: {
|
||||
password: [{ provider: 'email_password' }],
|
||||
callback: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'org-2',
|
||||
name: 'Warning Organization',
|
||||
authNSupport: {
|
||||
password: [{ provider: 'email_password' }],
|
||||
callback: [],
|
||||
},
|
||||
warning: {
|
||||
code: 'ORG_WARNING',
|
||||
message: 'Organization has limited access',
|
||||
url: 'https://example.com/warning',
|
||||
errors: [{ message: 'Contact admin for full access' }],
|
||||
} as ErrorV2,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const mockEmailPasswordResponse: Token = {
|
||||
accessToken: 'mock-access-token',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// MOCK SETUP HELPERS
|
||||
// =============================================================================
|
||||
|
||||
export function setupVersionEndpoint(
|
||||
data: Info = mockVersionSetupCompleted,
|
||||
): void {
|
||||
server.use(
|
||||
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data, status: 'success' })),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function setupSessionContextEndpoint(data: SessionsContext): void {
|
||||
server.use(
|
||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data })),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function setupEmailPasswordEndpoint(
|
||||
data: Token = mockEmailPasswordResponse,
|
||||
): void {
|
||||
server.use(
|
||||
rest.post(EMAIL_PASSWORD_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data })),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,20 @@
|
||||
/**
|
||||
* Login - Authentication & Form Tests
|
||||
*
|
||||
* Split from Login.test.tsx for better parallelization.
|
||||
* Tests password auth, callback auth, URL params, warnings, form state, and edge cases.
|
||||
*/
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { ErrorV2 } from 'types/api';
|
||||
import { Info } from 'types/api/v1/version/get';
|
||||
import { SessionsContext } from 'types/api/v2/sessions/context/get';
|
||||
import { Token } from 'types/api/v2/sessions/email_password/post';
|
||||
|
||||
import Login from '../index';
|
||||
import {
|
||||
CALLBACK_AUTHN_URL,
|
||||
EMAIL_PASSWORD_ENDPOINT,
|
||||
mockEmailPasswordResponse,
|
||||
mockMultiOrgWithWarning,
|
||||
mockOrgWithWarning,
|
||||
mockSingleOrgCallbackAuth,
|
||||
mockSingleOrgPasswordAuth,
|
||||
mockVersionSetupCompleted,
|
||||
PASSWORD_AUTHN_EMAIL,
|
||||
SESSIONS_CONTEXT_ENDPOINT,
|
||||
VERSION_ENDPOINT,
|
||||
} from './Login.test-utils';
|
||||
|
||||
// =============================================================================
|
||||
// MOCKS
|
||||
// =============================================================================
|
||||
const VERSION_ENDPOINT = '*/api/v1/version';
|
||||
const SESSIONS_CONTEXT_ENDPOINT = '*/api/v2/sessions/context';
|
||||
const CALLBACK_AUTHN_ORG = 'callback_authn_org';
|
||||
const CALLBACK_AUTHN_URL = 'https://sso.example.com/auth';
|
||||
const PASSWORD_AUTHN_ORG = 'password_authn_org';
|
||||
const PASSWORD_AUTHN_EMAIL = 'jest.test@signoz.io';
|
||||
|
||||
jest.mock('lib/history', () => ({
|
||||
__esModule: true,
|
||||
@@ -37,13 +26,102 @@ jest.mock('lib/history', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// TESTS
|
||||
// =============================================================================
|
||||
const mockHistoryPush = history.push as jest.MockedFunction<
|
||||
typeof history.push
|
||||
>;
|
||||
|
||||
describe('Login - Authentication & Form', () => {
|
||||
// Mock data
|
||||
const mockVersionSetupCompleted: Info = {
|
||||
setupCompleted: true,
|
||||
ee: 'Y',
|
||||
version: '0.25.0',
|
||||
};
|
||||
|
||||
const mockVersionSetupIncomplete: Info = {
|
||||
setupCompleted: false,
|
||||
ee: 'Y',
|
||||
version: '0.25.0',
|
||||
};
|
||||
|
||||
const mockSingleOrgPasswordAuth: SessionsContext = {
|
||||
exists: true,
|
||||
orgs: [
|
||||
{
|
||||
id: 'org-1',
|
||||
name: 'Test Organization',
|
||||
authNSupport: {
|
||||
password: [{ provider: 'email_password' }],
|
||||
callback: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockSingleOrgCallbackAuth: SessionsContext = {
|
||||
exists: true,
|
||||
orgs: [
|
||||
{
|
||||
id: 'org-1',
|
||||
name: 'Test Organization',
|
||||
authNSupport: {
|
||||
password: [],
|
||||
callback: [{ provider: 'google', url: CALLBACK_AUTHN_URL }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockMultiOrgMixedAuth: SessionsContext = {
|
||||
exists: true,
|
||||
orgs: [
|
||||
{
|
||||
id: 'org-1',
|
||||
name: PASSWORD_AUTHN_ORG,
|
||||
authNSupport: {
|
||||
password: [{ provider: 'email_password' }],
|
||||
callback: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'org-2',
|
||||
name: CALLBACK_AUTHN_ORG,
|
||||
authNSupport: {
|
||||
password: [],
|
||||
callback: [{ provider: 'google', url: CALLBACK_AUTHN_URL }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockOrgWithWarning: SessionsContext = {
|
||||
exists: true,
|
||||
orgs: [
|
||||
{
|
||||
id: 'org-1',
|
||||
name: 'Warning Organization',
|
||||
authNSupport: {
|
||||
password: [{ provider: 'email_password' }],
|
||||
callback: [],
|
||||
},
|
||||
warning: {
|
||||
code: 'ORG_WARNING',
|
||||
message: 'Organization has limited access',
|
||||
url: 'https://example.com/warning',
|
||||
errors: [{ message: 'Contact admin for full access' }],
|
||||
} as ErrorV2,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockEmailPasswordResponse: Token = {
|
||||
accessToken: 'mock-access-token',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
};
|
||||
|
||||
describe('Login Component', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
server.use(
|
||||
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
@@ -58,6 +136,269 @@ describe('Login - Authentication & Form', () => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('Initial Render', () => {
|
||||
it('renders login form with email input and next button', () => {
|
||||
const { getByTestId, getByPlaceholderText } = render(<Login />);
|
||||
|
||||
expect(
|
||||
screen.getByText(/sign in to monitor, trace, and troubleshoot/i),
|
||||
).toBeInTheDocument();
|
||||
expect(getByTestId('email')).toBeInTheDocument();
|
||||
expect(getByTestId('initiate_login')).toBeInTheDocument();
|
||||
expect(getByPlaceholderText('e.g. john@signoz.io')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading state when version data is being fetched', () => {
|
||||
server.use(
|
||||
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.delay(100),
|
||||
ctx.status(200),
|
||||
ctx.json({ data: mockVersionSetupCompleted, status: 'success' }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const { getByTestId } = render(<Login />);
|
||||
|
||||
expect(getByTestId('initiate_login')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Setup Check', () => {
|
||||
it('redirects to signup when setup is not completed', async () => {
|
||||
server.use(
|
||||
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ data: mockVersionSetupIncomplete, status: 'success' }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<Login />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.SIGN_UP);
|
||||
});
|
||||
});
|
||||
|
||||
it('stays on login page when setup is completed', async () => {
|
||||
render(<Login />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHistoryPush).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles version API error gracefully', async () => {
|
||||
server.use(
|
||||
rest.get(VERSION_ENDPOINT, (req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ error: 'Server error' })),
|
||||
),
|
||||
);
|
||||
|
||||
render(<Login />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHistoryPush).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session Context Fetching', () => {
|
||||
it('fetches session context on next button click and enables password', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
|
||||
),
|
||||
);
|
||||
|
||||
const { getByTestId } = render(<Login />);
|
||||
|
||||
// Wait for version API to complete (email input becomes enabled)
|
||||
const emailInput = await waitFor(() => {
|
||||
const input = getByTestId('email');
|
||||
expect(input).not.toBeDisabled();
|
||||
return input;
|
||||
});
|
||||
|
||||
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
|
||||
|
||||
const nextButton = await waitFor(() => {
|
||||
const button = getByTestId('initiate_login');
|
||||
expect(button).not.toBeDisabled();
|
||||
return button;
|
||||
});
|
||||
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('password')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles session context API errors', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(500),
|
||||
ctx.json({
|
||||
error: {
|
||||
code: 'internal_server',
|
||||
message: 'couldnt fetch the sessions context',
|
||||
url: '',
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const { getByTestId, getByText } = render(<Login />);
|
||||
|
||||
// Wait for version API to complete (email input becomes enabled)
|
||||
const emailInput = await waitFor(() => {
|
||||
const input = getByTestId('email');
|
||||
expect(input).not.toBeDisabled();
|
||||
return input;
|
||||
});
|
||||
|
||||
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
|
||||
|
||||
const nextButton = await waitFor(() => {
|
||||
const button = getByTestId('initiate_login');
|
||||
expect(button).not.toBeDisabled();
|
||||
return button;
|
||||
});
|
||||
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('couldnt fetch the sessions context')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('auto-selects organization when only one exists', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
|
||||
),
|
||||
);
|
||||
|
||||
const { getByTestId } = render(<Login />);
|
||||
|
||||
// Wait for version API to complete (email input becomes enabled)
|
||||
const emailInput = await waitFor(() => {
|
||||
const input = getByTestId('email');
|
||||
expect(input).not.toBeDisabled();
|
||||
return input;
|
||||
});
|
||||
|
||||
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
|
||||
|
||||
const nextButton = await waitFor(() => {
|
||||
const button = getByTestId('initiate_login');
|
||||
expect(button).not.toBeDisabled();
|
||||
return button;
|
||||
});
|
||||
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show password field directly (no org selection needed)
|
||||
expect(getByTestId('password')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/organization name/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Organization Selection', () => {
|
||||
it('shows organization dropdown when multiple orgs exist', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockMultiOrgMixedAuth })),
|
||||
),
|
||||
);
|
||||
|
||||
const { getByTestId, getByText } = render(<Login />);
|
||||
|
||||
// Wait for version API to complete (email input becomes enabled)
|
||||
const emailInput = await waitFor(() => {
|
||||
const input = getByTestId('email');
|
||||
expect(input).not.toBeDisabled();
|
||||
return input;
|
||||
});
|
||||
|
||||
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
|
||||
|
||||
const nextButton = await waitFor(() => {
|
||||
const button = getByTestId('initiate_login');
|
||||
expect(button).not.toBeDisabled();
|
||||
return button;
|
||||
});
|
||||
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Organization Name')).toBeInTheDocument();
|
||||
});
|
||||
await screen.findByRole('combobox');
|
||||
|
||||
// Click on the dropdown to reveal the options
|
||||
await user.click(screen.getByRole('combobox'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(PASSWORD_AUTHN_ORG)).toBeInTheDocument();
|
||||
expect(screen.getByText(CALLBACK_AUTHN_ORG)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('updates selected organization on dropdown change', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockMultiOrgMixedAuth })),
|
||||
),
|
||||
);
|
||||
|
||||
render(<Login />);
|
||||
|
||||
// Wait for version API to complete (email input becomes enabled)
|
||||
const emailInput = await waitFor(() => {
|
||||
const input = screen.getByTestId('email');
|
||||
expect(input).not.toBeDisabled();
|
||||
return input;
|
||||
});
|
||||
|
||||
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
|
||||
|
||||
const nextButton = await waitFor(() => {
|
||||
const button = screen.getByTestId('initiate_login');
|
||||
expect(button).not.toBeDisabled();
|
||||
return button;
|
||||
});
|
||||
|
||||
await user.click(nextButton);
|
||||
|
||||
await screen.findByRole('combobox');
|
||||
|
||||
// Select CALLBACK_AUTHN_ORG
|
||||
await user.click(screen.getByRole('combobox'));
|
||||
await user.click(screen.getByText(CALLBACK_AUTHN_ORG));
|
||||
|
||||
await screen.findByRole('button', { name: /sign in with sso/i });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Password Authentication', () => {
|
||||
it('shows password field when password auth is supported', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
@@ -70,6 +411,7 @@ describe('Login - Authentication & Form', () => {
|
||||
|
||||
const { getByTestId, getByText } = render(<Login />);
|
||||
|
||||
// Wait for version API to complete (email input becomes enabled)
|
||||
const emailInput = await waitFor(() => {
|
||||
const input = getByTestId('email');
|
||||
expect(input).not.toBeDisabled();
|
||||
@@ -106,6 +448,7 @@ describe('Login - Authentication & Form', () => {
|
||||
initialRoute: '/login?password=Y',
|
||||
});
|
||||
|
||||
// Wait for version API to complete (email input becomes enabled)
|
||||
const emailInput = await waitFor(() => {
|
||||
const input = getByTestId('email');
|
||||
expect(input).not.toBeDisabled();
|
||||
@@ -123,6 +466,7 @@ describe('Login - Authentication & Form', () => {
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show password field even for SSO org due to password=Y override
|
||||
expect(getByTestId('password')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -140,6 +484,7 @@ describe('Login - Authentication & Form', () => {
|
||||
|
||||
const { getByTestId, queryByTestId } = render(<Login />);
|
||||
|
||||
// Wait for version API to complete (email input becomes enabled)
|
||||
const emailInput = await waitFor(() => {
|
||||
const input = getByTestId('email');
|
||||
expect(input).not.toBeDisabled();
|
||||
@@ -165,7 +510,10 @@ describe('Login - Authentication & Form', () => {
|
||||
it('redirects to callback URL on button click', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
const mockLocation = { href: 'http://localhost/' };
|
||||
// Mock window.location.href
|
||||
const mockLocation = {
|
||||
href: 'http://localhost/',
|
||||
};
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: mockLocation,
|
||||
writable: true,
|
||||
@@ -179,6 +527,7 @@ describe('Login - Authentication & Form', () => {
|
||||
|
||||
const { getByTestId, queryByTestId } = render(<Login />);
|
||||
|
||||
// Wait for version API to complete (email input becomes enabled)
|
||||
const emailInput = await waitFor(() => {
|
||||
const input = getByTestId('email');
|
||||
expect(input).not.toBeDisabled();
|
||||
@@ -203,6 +552,7 @@ describe('Login - Authentication & Form', () => {
|
||||
const callbackButton = getByTestId('callback_authn_submit');
|
||||
await user.click(callbackButton);
|
||||
|
||||
// Check that window.location.href was set to the callback URL
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe(CALLBACK_AUTHN_URL);
|
||||
});
|
||||
@@ -217,7 +567,7 @@ describe('Login - Authentication & Form', () => {
|
||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
|
||||
),
|
||||
rest.post(EMAIL_PASSWORD_ENDPOINT, async (_, res, ctx) =>
|
||||
rest.post('*/api/v2/sessions/email_password', async (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: mockEmailPasswordResponse }),
|
||||
@@ -227,6 +577,7 @@ describe('Login - Authentication & Form', () => {
|
||||
|
||||
const { getByTestId } = render(<Login />);
|
||||
|
||||
// Wait for version API to complete (email input becomes enabled)
|
||||
const emailInput = await waitFor(() => {
|
||||
const input = getByTestId('email');
|
||||
expect(input).not.toBeDisabled();
|
||||
@@ -253,6 +604,8 @@ describe('Login - Authentication & Form', () => {
|
||||
await user.type(passwordInput, 'testpassword');
|
||||
await user.click(loginButton);
|
||||
|
||||
// do not test for the request paramters here. Reference: https://mswjs.io/docs/best-practices/avoid-request-assertions
|
||||
// rather test for the effects of the request
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem('AUTH_TOKEN')).toBe('mock-access-token');
|
||||
});
|
||||
@@ -265,7 +618,7 @@ describe('Login - Authentication & Form', () => {
|
||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
|
||||
),
|
||||
rest.post(EMAIL_PASSWORD_ENDPOINT, (_, res, ctx) =>
|
||||
rest.post('*/api/v2/sessions/email_password', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(401),
|
||||
ctx.json({
|
||||
@@ -281,6 +634,7 @@ describe('Login - Authentication & Form', () => {
|
||||
|
||||
const { getByTestId, getByText } = render(<Login />);
|
||||
|
||||
// Wait for version API to complete (email input becomes enabled)
|
||||
const emailInput = await waitFor(() => {
|
||||
const input = getByTestId('email');
|
||||
expect(input).not.toBeDisabled();
|
||||
@@ -354,13 +708,14 @@ describe('Login - Authentication & Form', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockOrgWithWarning })),
|
||||
),
|
||||
);
|
||||
|
||||
render(<Login />);
|
||||
|
||||
// Wait for version API to complete (email input becomes enabled)
|
||||
const emailInput = await waitFor(() => {
|
||||
const input = screen.getByTestId('email');
|
||||
expect(input).not.toBeDisabled();
|
||||
@@ -387,6 +742,23 @@ describe('Login - Authentication & Form', () => {
|
||||
it('shows warning modal when a warning org is selected among multiple orgs', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
// Mock multiple orgs including one with a warning
|
||||
const mockMultiOrgWithWarning = {
|
||||
orgs: [
|
||||
{ id: 'org1', name: 'Org 1' },
|
||||
{
|
||||
id: 'org2',
|
||||
name: 'Org 2',
|
||||
warning: {
|
||||
code: 'ORG_WARNING',
|
||||
message: 'Organization has limited access',
|
||||
url: 'https://example.com/warning',
|
||||
errors: [{ message: 'Contact admin for full access' }],
|
||||
} as ErrorV2,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
server.use(
|
||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockMultiOrgWithWarning })),
|
||||
@@ -395,6 +767,7 @@ describe('Login - Authentication & Form', () => {
|
||||
|
||||
const { getByTestId } = render(<Login />);
|
||||
|
||||
// Wait for version API to complete (email input becomes enabled)
|
||||
const emailInput = await waitFor(() => {
|
||||
const input = getByTestId('email');
|
||||
expect(input).not.toBeDisabled();
|
||||
@@ -413,8 +786,9 @@ describe('Login - Authentication & Form', () => {
|
||||
|
||||
await screen.findByRole('combobox');
|
||||
|
||||
// Select the organization with a warning
|
||||
await user.click(screen.getByRole('combobox'));
|
||||
await user.click(screen.getByText('Warning Organization'));
|
||||
await user.click(screen.getByText('Org 2'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
@@ -429,7 +803,7 @@ describe('Login - Authentication & Form', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
|
||||
res(
|
||||
ctx.delay(100),
|
||||
ctx.status(200),
|
||||
@@ -440,6 +814,7 @@ describe('Login - Authentication & Form', () => {
|
||||
|
||||
render(<Login />);
|
||||
|
||||
// Wait for version API to complete (email input becomes enabled)
|
||||
const emailInput = await waitFor(() => {
|
||||
const input = screen.getByTestId('email');
|
||||
expect(input).not.toBeDisabled();
|
||||
@@ -456,6 +831,7 @@ describe('Login - Authentication & Form', () => {
|
||||
|
||||
await user.click(nextButton);
|
||||
|
||||
// Button should be disabled during API call
|
||||
expect(nextButton).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -463,15 +839,17 @@ describe('Login - Authentication & Form', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
|
||||
),
|
||||
);
|
||||
|
||||
render(<Login />);
|
||||
|
||||
// Initially shows "Next" button
|
||||
expect(screen.getByTestId('initiate_login')).toBeInTheDocument();
|
||||
|
||||
// Wait for version API to complete (email input becomes enabled)
|
||||
const emailInput = await waitFor(() => {
|
||||
const input = screen.getByTestId('email');
|
||||
expect(input).not.toBeDisabled();
|
||||
@@ -489,6 +867,7 @@ describe('Login - Authentication & Form', () => {
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show "Sign in with Password" button for password auth
|
||||
expect(screen.getByTestId('password_authn_submit')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('initiate_login')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -505,13 +884,14 @@ describe('Login - Authentication & Form', () => {
|
||||
};
|
||||
|
||||
server.use(
|
||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockNoOrgs })),
|
||||
),
|
||||
);
|
||||
|
||||
render(<Login />);
|
||||
|
||||
// Wait for version API to complete (email input becomes enabled)
|
||||
const emailInput = await waitFor(() => {
|
||||
const input = screen.getByTestId('email');
|
||||
expect(input).not.toBeDisabled();
|
||||
@@ -529,6 +909,7 @@ describe('Login - Authentication & Form', () => {
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should not show any auth method buttons
|
||||
expect(
|
||||
screen.queryByTestId('password_authn_submit'),
|
||||
).not.toBeInTheDocument();
|
||||
@@ -556,13 +937,14 @@ describe('Login - Authentication & Form', () => {
|
||||
};
|
||||
|
||||
server.use(
|
||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
|
||||
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockNoAuthSupport })),
|
||||
),
|
||||
);
|
||||
|
||||
render(<Login />);
|
||||
|
||||
// Wait for version API to complete (email input becomes enabled)
|
||||
const emailInput = await waitFor(() => {
|
||||
const input = screen.getByTestId('email');
|
||||
expect(input).not.toBeDisabled();
|
||||
@@ -580,6 +962,7 @@ describe('Login - Authentication & Form', () => {
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should not show any auth method buttons
|
||||
expect(
|
||||
screen.queryByTestId('password_authn_submit'),
|
||||
).not.toBeInTheDocument();
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ApiRoutingPolicy } from 'api/routingPolicies/getRoutingPolicies';
|
||||
import { IAppContext } from 'providers/App/types';
|
||||
import { getAppContextMockMinimal } from 'tests/test-utils';
|
||||
import { IAppContext, IUser } from 'providers/App/types';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
|
||||
import { RoutingPolicy, UseRoutingPoliciesReturn } from '../types';
|
||||
@@ -79,14 +78,49 @@ export function getUseRoutingPoliciesMockData(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use getAppContextMockMinimal from 'tests/test-utils' directly.
|
||||
* This is a backwards-compatible wrapper that will be removed in a future version.
|
||||
*/
|
||||
export function getAppContextMockState(
|
||||
overrides?: Partial<IAppContext['user']>,
|
||||
overrides?: Partial<IUser>,
|
||||
): IAppContext {
|
||||
return getAppContextMockMinimal(overrides);
|
||||
return {
|
||||
user: {
|
||||
accessJwt: 'some-token',
|
||||
refreshJwt: 'some-refresh-token',
|
||||
id: 'some-user-id',
|
||||
email: 'user@signoz.io',
|
||||
displayName: 'John Doe',
|
||||
createdAt: 1732544623,
|
||||
organization: 'Nightswatch',
|
||||
orgId: 'does-not-matter-id',
|
||||
role: 'ADMIN',
|
||||
...overrides,
|
||||
},
|
||||
activeLicense: null,
|
||||
trialInfo: null,
|
||||
featureFlags: null,
|
||||
orgPreferences: null,
|
||||
userPreferences: null,
|
||||
isLoggedIn: false,
|
||||
org: null,
|
||||
isFetchingUser: false,
|
||||
isFetchingActiveLicense: false,
|
||||
isFetchingFeatureFlags: false,
|
||||
isFetchingOrgPreferences: false,
|
||||
userFetchError: undefined,
|
||||
activeLicenseFetchError: null,
|
||||
featureFlagsFetchError: undefined,
|
||||
orgPreferencesFetchError: undefined,
|
||||
changelog: null,
|
||||
showChangelogModal: false,
|
||||
activeLicenseRefetch: jest.fn(),
|
||||
updateUser: jest.fn(),
|
||||
updateOrgPreferences: jest.fn(),
|
||||
updateUserPreferenceInContext: jest.fn(),
|
||||
updateOrg: jest.fn(),
|
||||
updateChangelog: jest.fn(),
|
||||
toggleChangelogModal: jest.fn(),
|
||||
versionData: null,
|
||||
hasEditPermission: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function mockLocation(pathname: string): jest.Mock {
|
||||
|
||||
@@ -46,7 +46,6 @@ export const routeConfig: Record<string, QueryParams[]> = {
|
||||
[ROUTES.TRACES_EXPLORER]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.TRACE]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.TRACE_DETAIL]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.TRACE_DETAIL_OLD]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.UN_AUTHORIZED]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.USAGE_EXPLORER]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.VERSION]: [QueryParams.resourceAttributes],
|
||||
|
||||
@@ -51,10 +51,7 @@ import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { formatEpochTimestamp } from 'utils/timeUtils';
|
||||
|
||||
import Attributes from './Attributes/Attributes';
|
||||
import {
|
||||
RelatedSignalsViews,
|
||||
SPAN_PERCENTILE_INITIAL_DELAY_MS,
|
||||
} from './constants';
|
||||
import { RelatedSignalsViews } from './constants';
|
||||
import EventAttribute from './Events/components/EventAttribute';
|
||||
import Events from './Events/Events';
|
||||
import LinkedSpans from './LinkedSpans/LinkedSpans';
|
||||
@@ -413,7 +410,7 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setInitialWaitCompleted(true);
|
||||
}, SPAN_PERCENTILE_INITIAL_DELAY_MS);
|
||||
}, 2000); // 2-second delay
|
||||
|
||||
return (): void => {
|
||||
// clean the old state around span percentile data
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
&-empty-content {
|
||||
height: 100%;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-top: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -9,12 +9,6 @@ import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import SpanDetailsDrawer from '../SpanDetailsDrawer';
|
||||
|
||||
// Mock delay constant for faster tests
|
||||
jest.mock('container/SpanDetailsDrawer/constants', () => ({
|
||||
...jest.requireActual('container/SpanDetailsDrawer/constants'),
|
||||
SPAN_PERCENTILE_INITIAL_DELAY_MS: 0,
|
||||
}));
|
||||
|
||||
// Mock external dependencies
|
||||
const mockRedirectWithQueryBuilderData = jest.fn();
|
||||
const mockNotifications = {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import getSpanPercentiles from 'api/trace/getSpanPercentiles';
|
||||
import getUserPreference from 'api/v1/user/preferences/name/get';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { server } from 'mocks-server/server';
|
||||
@@ -8,13 +6,6 @@ import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import SpanDetailsDrawer from '../SpanDetailsDrawer';
|
||||
import {
|
||||
mockSpanPercentileResponse,
|
||||
mockUserPreferenceResponse,
|
||||
} from './SpanDetailsDrawer.test-utils';
|
||||
|
||||
const mockGetSpanPercentiles = jest.mocked(getSpanPercentiles);
|
||||
const mockGetUserPreference = jest.mocked(getUserPreference);
|
||||
import {
|
||||
expectedHostOnlyMetadata,
|
||||
expectedInfraMetadata,
|
||||
@@ -31,21 +22,6 @@ import {
|
||||
} from './infraMetricsTestData';
|
||||
|
||||
// Mock external dependencies
|
||||
jest.mock('container/SpanDetailsDrawer/constants', () => ({
|
||||
...jest.requireActual('container/SpanDetailsDrawer/constants'),
|
||||
SPAN_PERCENTILE_INITIAL_DELAY_MS: 0,
|
||||
}));
|
||||
|
||||
jest.mock('api/trace/getSpanPercentiles', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/v1/user/preferences/name/get', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string } => ({
|
||||
@@ -196,10 +172,6 @@ describe('SpanDetailsDrawer - Infra Metrics', () => {
|
||||
mockWindowOpen.mockClear();
|
||||
mockUpdateAllQueriesOperators.mockClear();
|
||||
|
||||
// Setup default mocks for percentile APIs to avoid delays
|
||||
mockGetUserPreference.mockResolvedValue(mockUserPreferenceResponse);
|
||||
mockGetSpanPercentiles.mockResolvedValue(mockSpanPercentileResponse);
|
||||
|
||||
// Setup API call tracking for infra metrics
|
||||
(GetMetricQueryRange as jest.Mock).mockImplementation((query) => {
|
||||
apiCallHistory.push(query);
|
||||
|
||||
@@ -1,414 +0,0 @@
|
||||
/**
|
||||
* SpanDetailsDrawer - Logs Tests
|
||||
*
|
||||
* Split from SpanDetailsDrawer.test.tsx for better parallelization.
|
||||
* Tests logs tab display, API queries, navigation, and highlighting.
|
||||
*/
|
||||
import getSpanPercentiles from 'api/trace/getSpanPercentiles';
|
||||
import getUserPreference from 'api/v1/user/preferences/name/get';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import {
|
||||
expectedAfterFilterExpression,
|
||||
expectedBeforeFilterExpression,
|
||||
expectedSpanFilterExpression,
|
||||
expectedTraceOnlyFilterExpression,
|
||||
mockAfterLogsResponse,
|
||||
mockBeforeLogsResponse,
|
||||
mockSpanLogsResponse,
|
||||
} from './mockData';
|
||||
import {
|
||||
ApiCallHistory,
|
||||
CI_SENSITIVE_LOGS_TEST_TIMEOUT,
|
||||
clearAllMocks,
|
||||
createApiCallHistory,
|
||||
mockSafeNavigate,
|
||||
mockSpanPercentileResponse,
|
||||
mockUpdateAllQueriesOperators,
|
||||
mockUserPreferenceResponse,
|
||||
mockWindowOpen,
|
||||
renderSpanDetailsDrawer,
|
||||
setupLogsApiMock,
|
||||
setupSpanDetailsDrawerMocks,
|
||||
} from './SpanDetailsDrawer.test-utils';
|
||||
|
||||
const mockGetSpanPercentiles = jest.mocked(getSpanPercentiles);
|
||||
const mockGetUserPreference = jest.mocked(getUserPreference);
|
||||
|
||||
// =============================================================================
|
||||
// MOCK SETUP
|
||||
// =============================================================================
|
||||
|
||||
jest.mock('container/SpanDetailsDrawer/constants', () => ({
|
||||
...jest.requireActual('container/SpanDetailsDrawer/constants'),
|
||||
SPAN_PERCENTILE_INITIAL_DELAY_MS: 0,
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string; search: string } => ({
|
||||
pathname: ROUTES.TRACE_DETAIL,
|
||||
search: 'trace_id=test-trace-id',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.MockedFunction<() => void> } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): any => ({
|
||||
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('lib/dashboard/getQueryResults', () => ({
|
||||
GetMetricQueryRange: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/trace/getSpanPercentiles', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/v1/user/preferences/name/get', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'components/Logs/RawLogView',
|
||||
() =>
|
||||
function MockRawLogView({
|
||||
data,
|
||||
onLogClick,
|
||||
isHighlighted,
|
||||
helpTooltip,
|
||||
}: {
|
||||
data: any;
|
||||
onLogClick: (data: any, event: React.MouseEvent) => void;
|
||||
isHighlighted: boolean;
|
||||
helpTooltip: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
data-testid={`raw-log-${data.id}`}
|
||||
className={isHighlighted ? 'log-highlighted' : 'log-context'}
|
||||
title={helpTooltip}
|
||||
onClick={(e): void => onLogClick?.(data, e)}
|
||||
>
|
||||
<div>{data.body}</div>
|
||||
<div>{data.timestamp}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
|
||||
PreferenceContextProvider: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// TESTS
|
||||
// =============================================================================
|
||||
|
||||
describe('SpanDetailsDrawer - Logs', () => {
|
||||
let apiCallHistory: ApiCallHistory;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useRealTimers();
|
||||
clearAllMocks();
|
||||
setupSpanDetailsDrawerMocks();
|
||||
|
||||
// Setup percentile API mocks to avoid delays
|
||||
mockGetUserPreference.mockResolvedValue(mockUserPreferenceResponse);
|
||||
mockGetSpanPercentiles.mockResolvedValue(mockSpanPercentileResponse);
|
||||
|
||||
apiCallHistory = createApiCallHistory();
|
||||
setupLogsApiMock(
|
||||
apiCallHistory,
|
||||
mockSpanLogsResponse,
|
||||
mockBeforeLogsResponse,
|
||||
mockAfterLogsResponse,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('should display logs tab in right sidebar when span is selected', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
const logsButton = screen.getByRole('button', { name: /logs/i });
|
||||
expect(logsButton).toBeInTheDocument();
|
||||
expect(logsButton).toBeVisible();
|
||||
});
|
||||
|
||||
it(
|
||||
'should open related logs view when logs tab is clicked',
|
||||
async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
const logsButton = screen.getByRole('button', { name: /logs/i });
|
||||
await user.click(logsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('overlay-scrollbar')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('raw-log-span-log-1')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('raw-log-span-log-2')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('raw-log-context-log-before'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('raw-log-context-log-after')).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
CI_SENSITIVE_LOGS_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should make 3 API queries when logs tab is opened',
|
||||
async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
const logsButton = screen.getByRole('button', { name: /logs/i });
|
||||
await user.click(logsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
const {
|
||||
span_logs: spanQuery,
|
||||
before_logs: beforeQuery,
|
||||
after_logs: afterQuery,
|
||||
trace_only_logs: traceOnlyQuery,
|
||||
} = apiCallHistory;
|
||||
|
||||
expect((spanQuery as any).query.builder.queryData[0].filter.expression).toBe(
|
||||
expectedSpanFilterExpression,
|
||||
);
|
||||
expect(
|
||||
(beforeQuery as any).query.builder.queryData[0].filter.expression,
|
||||
).toBe(expectedBeforeFilterExpression);
|
||||
expect(
|
||||
(afterQuery as any).query.builder.queryData[0].filter.expression,
|
||||
).toBe(expectedAfterFilterExpression);
|
||||
|
||||
if (traceOnlyQuery) {
|
||||
expect(traceOnlyQuery.query.builder.queryData[0].filter.expression).toBe(
|
||||
expectedTraceOnlyFilterExpression,
|
||||
);
|
||||
}
|
||||
},
|
||||
CI_SENSITIVE_LOGS_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should use correct timestamp ordering for different query types',
|
||||
async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
const logsButton = screen.getByRole('button', { name: /logs/i });
|
||||
await user.click(logsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
const {
|
||||
span_logs: spanQuery,
|
||||
before_logs: beforeQuery,
|
||||
after_logs: afterQuery,
|
||||
} = apiCallHistory;
|
||||
|
||||
expect((spanQuery as any).query.builder.queryData[0].orderBy[0].order).toBe(
|
||||
'desc',
|
||||
);
|
||||
expect(
|
||||
(beforeQuery as any).query.builder.queryData[0].orderBy[0].order,
|
||||
).toBe('desc');
|
||||
expect((afterQuery as any).query.builder.queryData[0].orderBy[0].order).toBe(
|
||||
'asc',
|
||||
);
|
||||
},
|
||||
CI_SENSITIVE_LOGS_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should navigate to logs explorer with span filters when span log is clicked',
|
||||
async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
const logsButton = screen.getByRole('button', { name: /logs/i });
|
||||
await user.click(logsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('raw-log-span-log-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const spanLog = screen.getByTestId('raw-log-span-log-1');
|
||||
await user.click(spanLog);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
expect.stringContaining(ROUTES.LOGS_EXPLORER),
|
||||
'_blank',
|
||||
);
|
||||
});
|
||||
|
||||
const navigationCall = mockWindowOpen.mock.calls[0][0];
|
||||
const urlParams = new URLSearchParams(navigationCall.split('?')[1]);
|
||||
|
||||
expect(urlParams.get(QueryParams.activeLogId)).toBe('"span-log-1"');
|
||||
expect(urlParams.get(QueryParams.startTime)).toBe('1640994900000');
|
||||
expect(urlParams.get(QueryParams.endTime)).toBe('1640995560000');
|
||||
|
||||
const compositeQuery = JSON.parse(
|
||||
urlParams.get(QueryParams.compositeQuery) || '{}',
|
||||
);
|
||||
expect(compositeQuery.builder.queryData[0].filter.expression).toContain(
|
||||
"trace_id = 'test-trace-id'",
|
||||
);
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
},
|
||||
CI_SENSITIVE_LOGS_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should navigate to logs explorer with trace filter when context log is clicked',
|
||||
async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
const logsButton = screen.getByRole('button', { name: /logs/i });
|
||||
await user.click(logsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('raw-log-context-log-before'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const contextLog = screen.getByTestId('raw-log-context-log-before');
|
||||
await user.click(contextLog);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
expect.stringContaining(ROUTES.LOGS_EXPLORER),
|
||||
'_blank',
|
||||
);
|
||||
});
|
||||
|
||||
const navigationCall = mockWindowOpen.mock.calls[0][0];
|
||||
const urlParams = new URLSearchParams(navigationCall.split('?')[1]);
|
||||
|
||||
expect(urlParams.get(QueryParams.activeLogId)).toBe('"context-log-before"');
|
||||
|
||||
const compositeQuery = JSON.parse(
|
||||
urlParams.get(QueryParams.compositeQuery) || '{}',
|
||||
);
|
||||
expect(compositeQuery.builder.queryData[0].filter.expression).toContain(
|
||||
"trace_id = 'test-trace-id'",
|
||||
);
|
||||
expect(compositeQuery.builder.queryData[0].filter.expression).not.toContain(
|
||||
'span_id',
|
||||
);
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
},
|
||||
CI_SENSITIVE_LOGS_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should always open logs explorer in new tab regardless of click type',
|
||||
async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
const logsButton = screen.getByRole('button', { name: /logs/i });
|
||||
await user.click(logsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('raw-log-span-log-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const spanLog = screen.getByTestId('raw-log-span-log-1');
|
||||
await user.click(spanLog);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
expect.stringContaining(ROUTES.LOGS_EXPLORER),
|
||||
'_blank',
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
},
|
||||
CI_SENSITIVE_LOGS_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should display span logs as highlighted and context logs as regular',
|
||||
async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
const logsButton = screen.getByRole('button', { name: /logs/i });
|
||||
await user.click(logsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('raw-log-span-log-1')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('raw-log-span-log-2')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('raw-log-context-log-before'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('raw-log-context-log-after')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const spanLog1 = screen.getByTestId('raw-log-span-log-1');
|
||||
const spanLog2 = screen.getByTestId('raw-log-span-log-2');
|
||||
expect(spanLog1).toHaveClass('log-highlighted');
|
||||
expect(spanLog2).toHaveClass('log-highlighted');
|
||||
expect(spanLog1).toHaveAttribute(
|
||||
'title',
|
||||
'This log belongs to the current span',
|
||||
);
|
||||
|
||||
const contextLogBefore = screen.getByTestId('raw-log-context-log-before');
|
||||
const contextLogAfter = screen.getByTestId('raw-log-context-log-after');
|
||||
expect(contextLogBefore).toHaveClass('log-context');
|
||||
expect(contextLogAfter).toHaveClass('log-context');
|
||||
expect(contextLogBefore).not.toHaveAttribute('title');
|
||||
},
|
||||
CI_SENSITIVE_LOGS_TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
@@ -1,565 +0,0 @@
|
||||
/**
|
||||
* SpanDetailsDrawer - Span Percentile Tests
|
||||
*
|
||||
* Split from SpanDetailsDrawer.test.tsx for better parallelization.
|
||||
* Tests percentile display, expansion, time range selection, and resource attributes.
|
||||
*/
|
||||
import getSpanPercentiles from 'api/trace/getSpanPercentiles';
|
||||
import getUserPreference from 'api/v1/user/preferences/name/get';
|
||||
import { SPAN_ATTRIBUTES } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { QueryBuilderContext } from 'providers/QueryBuilder';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { GetSpanPercentilesResponseDataProps } from 'types/api/trace/getSpanPercentiles';
|
||||
|
||||
import SpanDetailsDrawer from '../SpanDetailsDrawer';
|
||||
import { mockEmptyLogsResponse, mockSpan } from './mockData';
|
||||
|
||||
// =============================================================================
|
||||
// TYPED MOCKS (defined before jest.mock for proper hoisting)
|
||||
// =============================================================================
|
||||
|
||||
const mockGetSpanPercentiles = jest.mocked(getSpanPercentiles);
|
||||
const mockGetUserPreference = jest.mocked(getUserPreference);
|
||||
const mockSafeNavigate = jest.fn();
|
||||
|
||||
// =============================================================================
|
||||
// JEST MOCKS
|
||||
// =============================================================================
|
||||
|
||||
jest.mock('container/SpanDetailsDrawer/constants', () => ({
|
||||
...jest.requireActual('container/SpanDetailsDrawer/constants'),
|
||||
SPAN_PERCENTILE_INITIAL_DELAY_MS: 0,
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string; search: string } => ({
|
||||
pathname: '/trace',
|
||||
search: 'trace_id=test-trace-id',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.MockedFunction<() => void> } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockUpdateAllQueriesOperators = jest.fn().mockReturnValue({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'noop',
|
||||
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
|
||||
groupBy: [],
|
||||
limit: null,
|
||||
having: [],
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
},
|
||||
queryType: 'builder',
|
||||
});
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): any => ({
|
||||
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('lib/dashboard/getQueryResults', () => ({
|
||||
GetMetricQueryRange: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/trace/getSpanPercentiles', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/v1/user/preferences/name/get', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
|
||||
PreferenceContextProvider: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// MOCK DATA
|
||||
// =============================================================================
|
||||
|
||||
const mockSpanPercentileResponse = {
|
||||
httpStatusCode: 200 as const,
|
||||
data: {
|
||||
percentiles: {
|
||||
p50: 500000000,
|
||||
p90: 1000000000,
|
||||
p95: 1500000000,
|
||||
p99: 2000000000,
|
||||
},
|
||||
position: {
|
||||
percentile: 75.5,
|
||||
description: 'This span is in the 75th percentile',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockUserPreferenceResponse = {
|
||||
statusCode: 200,
|
||||
httpStatusCode: 200,
|
||||
error: null,
|
||||
message: 'Success',
|
||||
data: {
|
||||
name: 'span_percentile_resource_attributes',
|
||||
description: 'Resource attributes for span percentile calculation',
|
||||
valueType: 'array',
|
||||
defaultValue: [],
|
||||
value: ['service.name', 'name', 'http.method'],
|
||||
allowedValues: [],
|
||||
allowedScopes: [],
|
||||
createdAt: '2023-01-01T00:00:00Z',
|
||||
updatedAt: '2023-01-01T00:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
const mockSpanPercentileErrorResponse = {
|
||||
httpStatusCode: 500,
|
||||
data: null,
|
||||
} as unknown as SuccessResponseV2<GetSpanPercentilesResponseDataProps>;
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
const P75_TEXT = 'p75';
|
||||
const SPAN_PERCENTILE_TEXT = 'Span Percentile';
|
||||
const SEARCH_RESOURCE_ATTRIBUTES_PLACEHOLDER = 'Search resource attributes';
|
||||
|
||||
// =============================================================================
|
||||
// RENDER HELPER
|
||||
// =============================================================================
|
||||
|
||||
const mockQueryBuilderContextValue = {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
stagedQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
|
||||
panelType: 'list',
|
||||
redirectWithQuery: jest.fn(),
|
||||
handleRunQuery: jest.fn(),
|
||||
handleStageQuery: jest.fn(),
|
||||
resetQuery: jest.fn(),
|
||||
};
|
||||
|
||||
function renderSpanDetailsDrawer(): void {
|
||||
render(
|
||||
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
|
||||
<SpanDetailsDrawer
|
||||
isSpanDetailsDocked={false}
|
||||
setIsSpanDetailsDocked={jest.fn()}
|
||||
selectedSpan={mockSpan}
|
||||
traceStartTime={1640995200000}
|
||||
traceEndTime={1640995260000}
|
||||
/>
|
||||
</QueryBuilderContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TESTS
|
||||
// =============================================================================
|
||||
|
||||
describe('SpanDetailsDrawer - Span Percentile Functionality', () => {
|
||||
beforeEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.clearAllMocks();
|
||||
mockSafeNavigate.mockClear();
|
||||
mockUpdateAllQueriesOperators.mockClear();
|
||||
|
||||
// Setup default mocks
|
||||
mockGetUserPreference.mockResolvedValue(mockUserPreferenceResponse);
|
||||
mockGetSpanPercentiles.mockResolvedValue(mockSpanPercentileResponse);
|
||||
|
||||
(GetMetricQueryRange as jest.Mock).mockImplementation(() =>
|
||||
Promise.resolve(mockEmptyLogsResponse),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('should display span percentile value after successful API call', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call API with correct parameters', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetSpanPercentiles).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(mockGetSpanPercentiles).toHaveBeenCalledWith({
|
||||
start: expect.any(Number),
|
||||
end: expect.any(Number),
|
||||
spanDuration: mockSpan.durationNano,
|
||||
serviceName: mockSpan.serviceName,
|
||||
name: mockSpan.name,
|
||||
resourceAttributes: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle user preference loading', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetUserPreference).toHaveBeenCalledWith({
|
||||
name: 'span_percentile_resource_attributes',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should show loading spinner while fetching percentile data', async () => {
|
||||
mockGetSpanPercentiles.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => resolve(mockSpanPercentileResponse), 1000);
|
||||
}),
|
||||
);
|
||||
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
await waitFor(() => {
|
||||
const spinnerContainer = document.querySelector(
|
||||
'.loading-spinner-container',
|
||||
);
|
||||
expect(spinnerContainer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle API error gracefully', async () => {
|
||||
mockGetSpanPercentiles.mockResolvedValue(mockSpanPercentileErrorResponse);
|
||||
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/p\d+/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not display percentile value when API returns non-200 status', async () => {
|
||||
mockGetSpanPercentiles.mockResolvedValue({
|
||||
httpStatusCode: 500 as const,
|
||||
data: null,
|
||||
} as unknown as Awaited<ReturnType<typeof getSpanPercentiles>>);
|
||||
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/p\d+/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty percentile data gracefully', async () => {
|
||||
mockGetSpanPercentiles.mockResolvedValue({
|
||||
httpStatusCode: 200,
|
||||
data: {
|
||||
percentiles: {},
|
||||
position: {
|
||||
percentile: 0,
|
||||
description: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('p0')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display tooltip with correct content', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const percentileValue = screen.getByText(P75_TEXT);
|
||||
fireEvent.mouseEnter(percentileValue);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/This span duration is/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/out of the distribution/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/evaluated for 1 hour\(s\) since the span start time/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Click to learn more')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should expand percentile details when percentile value is clicked', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const percentileValue = screen.getByText(P75_TEXT);
|
||||
fireEvent.click(percentileValue);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(SPAN_PERCENTILE_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(/This span duration is/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/out of the distribution for this resource/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display percentile table with correct values', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const percentileValue = screen.getByText(P75_TEXT);
|
||||
fireEvent.click(percentileValue);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(SPAN_PERCENTILE_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Percentile')).toBeInTheDocument();
|
||||
expect(screen.getByText('Duration')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('p50')).toBeInTheDocument();
|
||||
expect(screen.getByText('p90')).toBeInTheDocument();
|
||||
expect(screen.getByText('p95')).toBeInTheDocument();
|
||||
expect(screen.getByText('p99')).toBeInTheDocument();
|
||||
expect(screen.getAllByText(P75_TEXT)).toHaveLength(3);
|
||||
expect(screen.getAllByText(/this span/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should allow time range selection and trigger API call', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const percentileValue = screen.getByText(P75_TEXT);
|
||||
fireEvent.click(percentileValue);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(SPAN_PERCENTILE_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const timeRangeSelector = screen.getByRole('combobox');
|
||||
expect(timeRangeSelector).toBeInTheDocument();
|
||||
expect(screen.getByText(/1.*hour/i)).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetSpanPercentiles).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
start: expect.any(Number),
|
||||
end: expect.any(Number),
|
||||
spanDuration: mockSpan.durationNano,
|
||||
serviceName: mockSpan.serviceName,
|
||||
name: mockSpan.name,
|
||||
resourceAttributes: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should show resource attributes selector when plus icon is clicked', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const percentileValue = screen.getByText(P75_TEXT);
|
||||
fireEvent.click(percentileValue);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(SPAN_PERCENTILE_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const plusIcon = screen.getByTestId('plus-icon');
|
||||
fireEvent.click(plusIcon);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByPlaceholderText(SEARCH_RESOURCE_ATTRIBUTES_PLACEHOLDER),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter resource attributes based on search query', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const percentileValue = screen.getByText(P75_TEXT);
|
||||
fireEvent.click(percentileValue);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(SPAN_PERCENTILE_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const plusIcon = screen.getByTestId('plus-icon');
|
||||
fireEvent.click(plusIcon);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByPlaceholderText(SEARCH_RESOURCE_ATTRIBUTES_PLACEHOLDER),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(
|
||||
SEARCH_RESOURCE_ATTRIBUTES_PLACEHOLDER,
|
||||
);
|
||||
fireEvent.change(searchInput, { target: { value: 'http' } });
|
||||
|
||||
expect(screen.getAllByText('http.method').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(SPAN_ATTRIBUTES.HTTP_URL).length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
expect(screen.getAllByText('http.status_code').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle resource attribute selection and trigger API call', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const percentileValue = screen.getByText(P75_TEXT);
|
||||
fireEvent.click(percentileValue);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(SPAN_PERCENTILE_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const plusIcon = screen.getByTestId('plus-icon');
|
||||
fireEvent.click(plusIcon);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByPlaceholderText(SEARCH_RESOURCE_ATTRIBUTES_PLACEHOLDER),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const httpMethodCheckbox = screen.getByRole('checkbox', {
|
||||
name: /http\.method/i,
|
||||
});
|
||||
fireEvent.click(httpMethodCheckbox);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetSpanPercentiles).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resourceAttributes: expect.objectContaining({
|
||||
'http.method': 'GET',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should close resource attributes selector when check icon is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const percentileValue = screen.getByText(P75_TEXT);
|
||||
await user.click(percentileValue);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(SPAN_PERCENTILE_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const plusIcon = screen.getByTestId('plus-icon');
|
||||
await user.click(plusIcon);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByPlaceholderText(SEARCH_RESOURCE_ATTRIBUTES_PLACEHOLDER),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const checkIcon = screen.getByTestId('check-icon');
|
||||
await user.click(checkIcon);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByPlaceholderText(SEARCH_RESOURCE_ATTRIBUTES_PLACEHOLDER),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,188 +0,0 @@
|
||||
/**
|
||||
* SpanDetailsDrawer - Search Visibility Tests
|
||||
*
|
||||
* Split from SpanDetailsDrawer.test.tsx for better parallelization.
|
||||
* Tests search functionality in the attributes tab.
|
||||
*/
|
||||
import { SPAN_ATTRIBUTES } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { fireEvent, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { mockEmptyLogsResponse } from './mockData';
|
||||
import {
|
||||
clearAllMocks,
|
||||
mockSafeNavigate,
|
||||
mockUpdateAllQueriesOperators,
|
||||
renderSpanDetailsDrawer,
|
||||
SEARCH_PLACEHOLDER,
|
||||
setupSpanDetailsDrawerMocks,
|
||||
} from './SpanDetailsDrawer.test-utils';
|
||||
|
||||
// =============================================================================
|
||||
// MOCK SETUP
|
||||
// =============================================================================
|
||||
|
||||
jest.mock('container/SpanDetailsDrawer/constants', () => ({
|
||||
...jest.requireActual('container/SpanDetailsDrawer/constants'),
|
||||
SPAN_PERCENTILE_INITIAL_DELAY_MS: 0,
|
||||
}));
|
||||
|
||||
// Mock external dependencies
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string; search: string } => ({
|
||||
pathname: '/trace',
|
||||
search: 'trace_id=test-trace-id',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.MockedFunction<() => void> } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): any => ({
|
||||
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('lib/dashboard/getQueryResults', () => ({
|
||||
GetMetricQueryRange: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock getSpanPercentiles API
|
||||
jest.mock('api/trace/getSpanPercentiles', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock getUserPreference API
|
||||
jest.mock('api/v1/user/preferences/name/get', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock PreferenceContextProvider
|
||||
jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
|
||||
PreferenceContextProvider: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// TESTS
|
||||
// =============================================================================
|
||||
|
||||
describe('SpanDetailsDrawer - Search Visibility User Flows', () => {
|
||||
beforeEach(() => {
|
||||
jest.useRealTimers();
|
||||
clearAllMocks();
|
||||
setupSpanDetailsDrawerMocks();
|
||||
|
||||
(GetMetricQueryRange as jest.Mock).mockImplementation(() =>
|
||||
Promise.resolve(mockEmptyLogsResponse),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
// Journey 1: Default Search Visibility
|
||||
|
||||
it('should display search visible by default when user opens span details', () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// User sees search input in the Attributes tab by default
|
||||
const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER);
|
||||
expect(searchInput).toBeInTheDocument();
|
||||
expect(searchInput).toBeVisible();
|
||||
});
|
||||
|
||||
it('should filter attributes when user types in search', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// User sees all attributes initially
|
||||
expect(screen.getByText('http.method')).toBeInTheDocument();
|
||||
expect(screen.getByText(SPAN_ATTRIBUTES.HTTP_URL)).toBeInTheDocument();
|
||||
expect(screen.getByText('http.status_code')).toBeInTheDocument();
|
||||
|
||||
// User types "method" in search
|
||||
const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER);
|
||||
await user.type(searchInput, 'method');
|
||||
|
||||
// User sees only matching attributes
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('http.method')).toBeInTheDocument();
|
||||
expect(screen.queryByText(SPAN_ATTRIBUTES.HTTP_URL)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('http.status_code')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// Journey 2: Search Toggle & Focus Management
|
||||
|
||||
it('should hide search when user clicks search icon', () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// User sees search initially
|
||||
expect(screen.getByPlaceholderText(SEARCH_PLACEHOLDER)).toBeInTheDocument();
|
||||
|
||||
// User clicks search icon to hide search
|
||||
const tabBar = screen.getByRole('tablist');
|
||||
const searchIcon = tabBar.querySelector('.search-icon');
|
||||
if (searchIcon) {
|
||||
fireEvent.click(searchIcon);
|
||||
}
|
||||
|
||||
// Search is now hidden
|
||||
expect(
|
||||
screen.queryByPlaceholderText(SEARCH_PLACEHOLDER),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show and focus search when user clicks search icon again', () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// User clicks search icon to hide
|
||||
const tabBar = screen.getByRole('tablist');
|
||||
const searchIcon = tabBar.querySelector('.search-icon');
|
||||
if (searchIcon) {
|
||||
fireEvent.click(searchIcon);
|
||||
}
|
||||
|
||||
// Search is hidden
|
||||
expect(
|
||||
screen.queryByPlaceholderText(SEARCH_PLACEHOLDER),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// User clicks search icon again to show
|
||||
if (searchIcon) {
|
||||
fireEvent.click(searchIcon);
|
||||
}
|
||||
|
||||
// Search appears and receives focus
|
||||
const searchInput = screen.getByPlaceholderText(
|
||||
SEARCH_PLACEHOLDER,
|
||||
) as HTMLInputElement;
|
||||
expect(searchInput).toBeInTheDocument();
|
||||
expect(searchInput).toHaveFocus();
|
||||
});
|
||||
});
|
||||
@@ -1,228 +0,0 @@
|
||||
/**
|
||||
* SpanDetailsDrawer - Status Message Truncation Tests
|
||||
*
|
||||
* Split from SpanDetailsDrawer.test.tsx for better parallelization.
|
||||
* Tests status message display and expandable popover functionality.
|
||||
*/
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { QueryBuilderContext } from 'providers/QueryBuilder';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import SpanDetailsDrawer from '../SpanDetailsDrawer';
|
||||
import {
|
||||
mockEmptyLogsResponse,
|
||||
mockSpanWithLongStatusMessage,
|
||||
mockSpanWithShortStatusMessage,
|
||||
} from './mockData';
|
||||
import {
|
||||
clearAllMocks,
|
||||
mockQueryBuilderContextValue,
|
||||
mockSafeNavigate,
|
||||
mockUpdateAllQueriesOperators,
|
||||
setupSpanDetailsDrawerMocks,
|
||||
} from './SpanDetailsDrawer.test-utils';
|
||||
|
||||
// =============================================================================
|
||||
// MOCK SETUP
|
||||
// =============================================================================
|
||||
|
||||
jest.mock('container/SpanDetailsDrawer/constants', () => ({
|
||||
...jest.requireActual('container/SpanDetailsDrawer/constants'),
|
||||
SPAN_PERCENTILE_INITIAL_DELAY_MS: 0,
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string; search: string } => ({
|
||||
pathname: '/trace',
|
||||
search: 'trace_id=test-trace-id',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.MockedFunction<() => void> } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): any => ({
|
||||
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('lib/dashboard/getQueryResults', () => ({
|
||||
GetMetricQueryRange: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/trace/getSpanPercentiles', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/v1/user/preferences/name/get', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'container/SpanDetailsDrawer/Events/components/AttributeWithExpandablePopover',
|
||||
() =>
|
||||
function AttributeWithExpandablePopover({
|
||||
attributeKey,
|
||||
attributeValue,
|
||||
onExpand,
|
||||
}: {
|
||||
attributeKey: string;
|
||||
attributeValue: string;
|
||||
onExpand: (title: string, content: string) => void;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="attribute-container" key={attributeKey}>
|
||||
<div className="attribute-key">{attributeKey}</div>
|
||||
<div className="wrapper">
|
||||
<div className="attribute-value">{attributeValue}</div>
|
||||
<div data-testid="popover-content">
|
||||
<pre>{attributeValue}</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(): void => onExpand(attributeKey, attributeValue)}
|
||||
>
|
||||
Expand
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
|
||||
PreferenceContextProvider: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// TESTS
|
||||
// =============================================================================
|
||||
|
||||
describe('SpanDetailsDrawer - Status Message Truncation User Flows', () => {
|
||||
beforeEach(() => {
|
||||
jest.useRealTimers();
|
||||
clearAllMocks();
|
||||
setupSpanDetailsDrawerMocks();
|
||||
|
||||
(GetMetricQueryRange as jest.Mock).mockImplementation(() =>
|
||||
Promise.resolve(mockEmptyLogsResponse),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('should display expandable popover with Expand button for long status message', () => {
|
||||
render(
|
||||
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
|
||||
<SpanDetailsDrawer
|
||||
isSpanDetailsDocked={false}
|
||||
setIsSpanDetailsDocked={jest.fn()}
|
||||
selectedSpan={mockSpanWithLongStatusMessage}
|
||||
traceStartTime={1640995200000}
|
||||
traceEndTime={1640995260000}
|
||||
/>
|
||||
</QueryBuilderContext.Provider>,
|
||||
);
|
||||
|
||||
// User sees status message label
|
||||
expect(screen.getByText('status message')).toBeInTheDocument();
|
||||
|
||||
// User sees the status message value
|
||||
const statusMessageElements = screen.getAllByText(
|
||||
mockSpanWithLongStatusMessage.statusMessage,
|
||||
);
|
||||
expect(statusMessageElements.length).toBeGreaterThan(0);
|
||||
|
||||
// User sees Expand button in popover
|
||||
const expandButton = screen.getByRole('button', { name: /expand/i });
|
||||
expect(expandButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should open modal with full status message when user clicks Expand button', async () => {
|
||||
render(
|
||||
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
|
||||
<SpanDetailsDrawer
|
||||
isSpanDetailsDocked={false}
|
||||
setIsSpanDetailsDocked={jest.fn()}
|
||||
selectedSpan={mockSpanWithLongStatusMessage}
|
||||
traceStartTime={1640995200000}
|
||||
traceEndTime={1640995260000}
|
||||
/>
|
||||
</QueryBuilderContext.Provider>,
|
||||
);
|
||||
|
||||
// User clicks the Expand button
|
||||
const expandButton = screen.getByRole('button', { name: /expand/i });
|
||||
await fireEvent.click(expandButton);
|
||||
|
||||
// User sees modal with the full status message content
|
||||
await waitFor(() => {
|
||||
const modalTitle = document.querySelector('.ant-modal-title');
|
||||
expect(modalTitle).toBeInTheDocument();
|
||||
expect(modalTitle?.textContent).toBe('status message');
|
||||
const preElement = document.querySelector(
|
||||
'.attribute-with-expandable-popover__full-view',
|
||||
);
|
||||
expect(preElement).toBeInTheDocument();
|
||||
expect(preElement?.textContent).toBe(
|
||||
mockSpanWithLongStatusMessage.statusMessage,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should display short status message as simple text without popover', () => {
|
||||
render(
|
||||
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
|
||||
<SpanDetailsDrawer
|
||||
isSpanDetailsDocked={false}
|
||||
setIsSpanDetailsDocked={jest.fn()}
|
||||
selectedSpan={mockSpanWithShortStatusMessage}
|
||||
traceStartTime={1640995200000}
|
||||
traceEndTime={1640995260000}
|
||||
/>
|
||||
</QueryBuilderContext.Provider>,
|
||||
);
|
||||
|
||||
// User sees status message label and value
|
||||
expect(screen.getByText('status message')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(mockSpanWithShortStatusMessage.statusMessage),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// User hovers over the status message value
|
||||
const statusMessageValue = screen.getByText(
|
||||
mockSpanWithShortStatusMessage.statusMessage,
|
||||
);
|
||||
fireEvent.mouseEnter(statusMessageValue);
|
||||
|
||||
// No Expand button should appear
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /expand/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,249 +0,0 @@
|
||||
/**
|
||||
* Shared test utilities for SpanDetailsDrawer tests.
|
||||
* Extract common mocks, setup, and render helpers to avoid duplication across split test files.
|
||||
*/
|
||||
import getSpanPercentiles from 'api/trace/getSpanPercentiles';
|
||||
import getUserPreference from 'api/v1/user/preferences/name/get';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { QueryBuilderContext } from 'providers/QueryBuilder';
|
||||
import { render } from 'tests/test-utils';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { GetSpanPercentilesResponseDataProps } from 'types/api/trace/getSpanPercentiles';
|
||||
|
||||
import SpanDetailsDrawer from '../SpanDetailsDrawer';
|
||||
import { mockEmptyLogsResponse, mockSpan } from './mockData';
|
||||
|
||||
// =============================================================================
|
||||
// TYPED MOCKS
|
||||
// =============================================================================
|
||||
|
||||
export const mockGetSpanPercentiles = jest.mocked(getSpanPercentiles);
|
||||
export const mockGetUserPreference = jest.mocked(getUserPreference);
|
||||
export const mockSafeNavigate = jest.fn();
|
||||
export const mockWindowOpen = jest.fn();
|
||||
|
||||
// =============================================================================
|
||||
// MOCK SETUP (call in beforeAll or at module level)
|
||||
// =============================================================================
|
||||
|
||||
export function setupSpanDetailsDrawerMocks(): void {
|
||||
// Mock window.open
|
||||
Object.defineProperty(window, 'open', {
|
||||
writable: true,
|
||||
value: mockWindowOpen,
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MOCK UPDATE OPERATORS
|
||||
// =============================================================================
|
||||
|
||||
export const mockUpdateAllQueriesOperators = jest.fn().mockReturnValue({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'noop',
|
||||
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
|
||||
groupBy: [],
|
||||
limit: null,
|
||||
having: [],
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
},
|
||||
queryType: 'builder',
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// QUERY BUILDER CONTEXT MOCK
|
||||
// =============================================================================
|
||||
|
||||
export const mockQueryBuilderContextValue = {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
stagedQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
|
||||
panelType: 'list',
|
||||
redirectWithQuery: jest.fn(),
|
||||
handleRunQuery: jest.fn(),
|
||||
handleStageQuery: jest.fn(),
|
||||
resetQuery: jest.fn(),
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// RENDER HELPER
|
||||
// =============================================================================
|
||||
|
||||
interface RenderSpanDetailsDrawerProps {
|
||||
selectedSpan?: typeof mockSpan;
|
||||
traceStartTime?: number;
|
||||
traceEndTime?: number;
|
||||
isSpanDetailsDocked?: boolean;
|
||||
setIsSpanDetailsDocked?: jest.Mock;
|
||||
}
|
||||
|
||||
export const renderSpanDetailsDrawer = (
|
||||
props: RenderSpanDetailsDrawerProps = {},
|
||||
): void => {
|
||||
const {
|
||||
selectedSpan = mockSpan,
|
||||
traceStartTime = 1640995200000,
|
||||
traceEndTime = 1640995260000,
|
||||
isSpanDetailsDocked = false,
|
||||
setIsSpanDetailsDocked = jest.fn(),
|
||||
} = props;
|
||||
|
||||
render(
|
||||
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
|
||||
<SpanDetailsDrawer
|
||||
isSpanDetailsDocked={isSpanDetailsDocked}
|
||||
setIsSpanDetailsDocked={setIsSpanDetailsDocked}
|
||||
selectedSpan={selectedSpan}
|
||||
traceStartTime={traceStartTime}
|
||||
traceEndTime={traceEndTime}
|
||||
/>
|
||||
</QueryBuilderContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
export const CI_SENSITIVE_LOGS_TEST_TIMEOUT = 15000;
|
||||
export const P75_TEXT = 'p75';
|
||||
export const SPAN_PERCENTILE_TEXT = 'Span Percentile';
|
||||
export const SEARCH_PLACEHOLDER = 'Search for attribute...';
|
||||
export const SEARCH_RESOURCE_ATTRIBUTES_PLACEHOLDER =
|
||||
'Search resource attributes';
|
||||
|
||||
// =============================================================================
|
||||
// MOCK DATA FOR PERCENTILES
|
||||
// =============================================================================
|
||||
|
||||
export const mockSpanPercentileResponse = {
|
||||
httpStatusCode: 200 as const,
|
||||
data: {
|
||||
percentiles: {
|
||||
p50: 500000000,
|
||||
p90: 1000000000,
|
||||
p95: 1500000000,
|
||||
p99: 2000000000,
|
||||
},
|
||||
position: {
|
||||
percentile: 75.5,
|
||||
description: 'This span is in the 75th percentile',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockUserPreferenceResponse = {
|
||||
statusCode: 200,
|
||||
httpStatusCode: 200,
|
||||
error: null,
|
||||
message: 'Success',
|
||||
data: {
|
||||
name: 'span_percentile_resource_attributes',
|
||||
description: 'Resource attributes for span percentile calculation',
|
||||
valueType: 'array',
|
||||
defaultValue: [],
|
||||
value: ['service.name', 'name', 'http.method'],
|
||||
allowedValues: [],
|
||||
allowedScopes: [],
|
||||
createdAt: '2023-01-01T00:00:00Z',
|
||||
updatedAt: '2023-01-01T00:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
export const mockSpanPercentileErrorResponse = {
|
||||
httpStatusCode: 500,
|
||||
data: null,
|
||||
} as unknown as SuccessResponseV2<GetSpanPercentilesResponseDataProps>;
|
||||
|
||||
// =============================================================================
|
||||
// COMMON BEFOREEACH SETUP
|
||||
// =============================================================================
|
||||
|
||||
export interface ApiCallHistory {
|
||||
span_logs: any;
|
||||
before_logs: any;
|
||||
after_logs: any;
|
||||
trace_only_logs: any;
|
||||
}
|
||||
|
||||
export function createApiCallHistory(): ApiCallHistory {
|
||||
return {
|
||||
span_logs: null,
|
||||
before_logs: null,
|
||||
after_logs: null,
|
||||
trace_only_logs: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function setupLogsApiMock(
|
||||
apiCallHistory: ApiCallHistory,
|
||||
mockSpanLogsResponse: any,
|
||||
mockBeforeLogsResponse: any,
|
||||
mockAfterLogsResponse: any,
|
||||
): void {
|
||||
(GetMetricQueryRange as jest.Mock).mockImplementation((query) => {
|
||||
const filterExpression = (query as any)?.query?.builder?.queryData?.[0]
|
||||
?.filter?.expression;
|
||||
|
||||
if (!filterExpression) {
|
||||
return Promise.resolve(mockEmptyLogsResponse);
|
||||
}
|
||||
|
||||
if (filterExpression.includes('span_id')) {
|
||||
apiCallHistory.span_logs = query;
|
||||
return Promise.resolve(mockSpanLogsResponse);
|
||||
}
|
||||
if (filterExpression.includes('id <')) {
|
||||
apiCallHistory.before_logs = query;
|
||||
return Promise.resolve(mockBeforeLogsResponse);
|
||||
}
|
||||
if (filterExpression.includes('id >')) {
|
||||
apiCallHistory.after_logs = query;
|
||||
return Promise.resolve(mockAfterLogsResponse);
|
||||
}
|
||||
if (filterExpression.includes('trace_id =')) {
|
||||
apiCallHistory.trace_only_logs = query;
|
||||
return Promise.resolve(mockAfterLogsResponse);
|
||||
}
|
||||
|
||||
return Promise.resolve(mockEmptyLogsResponse);
|
||||
});
|
||||
}
|
||||
|
||||
export function clearAllMocks(): void {
|
||||
jest.clearAllMocks();
|
||||
mockSafeNavigate.mockClear();
|
||||
mockWindowOpen.mockClear();
|
||||
mockUpdateAllQueriesOperators.mockClear();
|
||||
mockGetSpanPercentiles.mockClear();
|
||||
mockGetUserPreference.mockClear();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,3 @@ export const RELATED_SIGNALS_VIEW_TYPES = {
|
||||
// METRICS: RelatedSignalsViews.METRICS,
|
||||
INFRA: RelatedSignalsViews.INFRA,
|
||||
};
|
||||
|
||||
/**
|
||||
* Delay in milliseconds before fetching span percentile data on initial load.
|
||||
* Product requirement to avoid overwhelming API on rapid span selections.
|
||||
*/
|
||||
export const SPAN_PERCENTILE_INITIAL_DELAY_MS = 2000;
|
||||
|
||||
@@ -143,7 +143,6 @@ export const routesToSkip = [
|
||||
ROUTES.SETTINGS,
|
||||
ROUTES.LIST_ALL_ALERT,
|
||||
ROUTES.TRACE_DETAIL,
|
||||
ROUTES.TRACE_DETAIL_OLD,
|
||||
ROUTES.ALL_CHANNELS,
|
||||
ROUTES.USAGE_EXPLORER,
|
||||
ROUTES.GET_STARTED,
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
import { MouseEventHandler, useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { toast } from '@signozhq/ui';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
// Accepts both V2 (spanId) and V3 (span_id) span shapes
|
||||
// TODO: Remove V2 (spanId) support when phasing out V2
|
||||
interface SpanLike {
|
||||
spanId?: string;
|
||||
span_id?: string;
|
||||
}
|
||||
|
||||
export const useCopySpanLink = (
|
||||
span?: SpanLike,
|
||||
span?: Span,
|
||||
): { onSpanCopy: MouseEventHandler<HTMLElement> } => {
|
||||
const urlQuery = useUrlQuery();
|
||||
const { pathname } = useLocation();
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const onSpanCopy: MouseEventHandler<HTMLElement> = useCallback(
|
||||
(event) => {
|
||||
@@ -30,19 +25,18 @@ export const useCopySpanLink = (
|
||||
|
||||
urlQuery.delete('spanId');
|
||||
|
||||
const id = span.span_id || span.spanId;
|
||||
if (id) {
|
||||
urlQuery.set('spanId', id);
|
||||
if (span.spanId) {
|
||||
urlQuery.set('spanId', span?.spanId);
|
||||
}
|
||||
|
||||
const link = getAbsoluteUrl(`${pathname}?${urlQuery.toString()}`);
|
||||
|
||||
setCopy(link);
|
||||
toast.success('Copied to clipboard', {
|
||||
position: 'top-right',
|
||||
notifications.success({
|
||||
message: 'Copied to clipboard',
|
||||
});
|
||||
},
|
||||
[span, urlQuery, pathname, setCopy],
|
||||
[span, urlQuery, pathname, setCopy, notifications],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -13,7 +13,11 @@ const useGetTraceFlamegraph = (
|
||||
useQuery({
|
||||
queryFn: () => getTraceFlamegraph(props),
|
||||
// if any of the props changes then we need to trigger an API call as the older data will be obsolete
|
||||
queryKey: [REACT_QUERY_KEY.GET_TRACE_V2_FLAMEGRAPH, props.traceId],
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_TRACE_V2_FLAMEGRAPH,
|
||||
props.traceId,
|
||||
props.selectedSpanId,
|
||||
],
|
||||
enabled: !!props.traceId,
|
||||
keepPreviousData: true,
|
||||
refetchOnWindowFocus: false,
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import getTraceV3 from 'api/trace/getTraceV3';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import {
|
||||
GetTraceV3PayloadProps,
|
||||
GetTraceV3SuccessResponse,
|
||||
} from 'types/api/trace/getTraceV3';
|
||||
|
||||
const useGetTraceV3 = (props: GetTraceV3PayloadProps): UseTraceV3 =>
|
||||
useQuery({
|
||||
queryFn: () => getTraceV3(props),
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_TRACE_V3_WATERFALL,
|
||||
props.traceId,
|
||||
props.selectedSpanId,
|
||||
props.isSelectedSpanIDUnCollapsed,
|
||||
],
|
||||
enabled: !!props.traceId,
|
||||
keepPreviousData: true,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
type UseTraceV3 = UseQueryResult<
|
||||
SuccessResponse<GetTraceV3SuccessResponse> | ErrorResponse,
|
||||
unknown
|
||||
>;
|
||||
|
||||
export default useGetTraceV3;
|
||||
@@ -7,23 +7,6 @@ export function hashFn(str: string): number {
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
export function colorToRgb(color: string): string {
|
||||
// Handle hex colors
|
||||
const hexMatch = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color);
|
||||
if (hexMatch) {
|
||||
return `${parseInt(hexMatch[1], 16)}, ${parseInt(
|
||||
hexMatch[2],
|
||||
16,
|
||||
)}, ${parseInt(hexMatch[3], 16)}`;
|
||||
}
|
||||
// Handle rgb() colors
|
||||
const rgbMatch = /^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/.exec(color);
|
||||
if (rgbMatch) {
|
||||
return `${rgbMatch[1]}, ${rgbMatch[2]}, ${rgbMatch[3]}`;
|
||||
}
|
||||
return '136, 136, 136';
|
||||
}
|
||||
|
||||
export function generateColor(
|
||||
key: string,
|
||||
colorMap: Record<string, string>,
|
||||
|
||||
@@ -867,12 +867,8 @@ describe('TooltipPlugin', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const resizeCall = addSpy.mock.calls.find(
|
||||
([type]) => type === ('resize' as keyof WindowEventMap),
|
||||
);
|
||||
const scrollCall = addSpy.mock.calls.find(
|
||||
([type]) => type === ('scroll' as keyof WindowEventMap),
|
||||
);
|
||||
const resizeCall = addSpy.mock.calls.find(([type]) => type === 'resize');
|
||||
const scrollCall = addSpy.mock.calls.find(([type]) => type === 'scroll');
|
||||
|
||||
expect(resizeCall).toBeDefined();
|
||||
expect(scrollCall).toBeDefined();
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
/* eslint-disable */
|
||||
//@ts-nocheck
|
||||
import { memo } from 'react';
|
||||
import ForceGraph2D from 'react-force-graph-2d';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import { getGraphData, getTooltip, transformLabel } from './utils';
|
||||
|
||||
function ServiceMap({ fgRef, serviceMap }: any): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const { nodes, links } = getGraphData(serviceMap, isDarkMode);
|
||||
|
||||
const graphData = { nodes, links };
|
||||
|
||||
let zoomLevel = 1;
|
||||
|
||||
return (
|
||||
<ForceGraph2D
|
||||
ref={fgRef}
|
||||
cooldownTicks={100}
|
||||
graphData={graphData}
|
||||
linkLabel={getTooltip}
|
||||
linkAutoColorBy={(d) => d.target}
|
||||
linkDirectionalParticles="value"
|
||||
linkDirectionalParticleSpeed={(d) => d.value}
|
||||
nodeCanvasObject={(node, ctx) => {
|
||||
const label = transformLabel(node.id, zoomLevel);
|
||||
let { fontSize } = node;
|
||||
fontSize = (fontSize * 3) / zoomLevel;
|
||||
ctx.font = `${fontSize}px Roboto`;
|
||||
const { width } = node;
|
||||
|
||||
ctx.fillStyle = node.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x, node.y, width, 0, 2 * Math.PI, false);
|
||||
ctx.fill();
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = isDarkMode ? '#ffffff' : '#000000';
|
||||
ctx.fillText(label, node.x, node.y);
|
||||
}}
|
||||
onLinkHover={(node) => {
|
||||
const tooltip = document.querySelector('.graph-tooltip');
|
||||
if (tooltip && node) {
|
||||
tooltip.innerHTML = getTooltip(node);
|
||||
}
|
||||
}}
|
||||
onZoom={(zoom) => {
|
||||
zoomLevel = zoom.k;
|
||||
}}
|
||||
nodePointerAreaPaint={(node, color, ctx) => {
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x, node.y, 5, 0, 2 * Math.PI, false);
|
||||
ctx.fill();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ServiceMap);
|
||||
@@ -1,6 +1,6 @@
|
||||
//@ts-nocheck
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { connect } from 'react-redux';
|
||||
import { RouteComponentProps, withRouter } from 'react-router-dom';
|
||||
@@ -16,27 +16,9 @@ import { AppState } from 'store/reducers';
|
||||
import styled from 'styled-components';
|
||||
import { GlobalTime } from 'types/actions/globalTime';
|
||||
|
||||
import Map from './Map';
|
||||
import Map from './components/Map/Map';
|
||||
|
||||
const Container = styled.div`
|
||||
.force-graph-container {
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.force-graph-container .graph-tooltip {
|
||||
background: black;
|
||||
padding: 1px;
|
||||
.keyval {
|
||||
display: flex;
|
||||
.key {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.val {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const Container = styled.div``;
|
||||
|
||||
interface ServiceMapProps extends RouteComponentProps<any> {
|
||||
serviceMap: ServiceMapStore;
|
||||
@@ -48,7 +30,8 @@ interface ServiceMapProps extends RouteComponentProps<any> {
|
||||
}
|
||||
interface graphNode {
|
||||
id: string;
|
||||
group: number;
|
||||
status: 'healthy' | 'error';
|
||||
name: string;
|
||||
}
|
||||
interface graphLink {
|
||||
source: string;
|
||||
@@ -64,8 +47,6 @@ export interface graphDataType {
|
||||
}
|
||||
|
||||
function ServiceMap(props: ServiceMapProps): JSX.Element {
|
||||
const fgRef = useRef();
|
||||
|
||||
const { getDetailedServiceMapItems, globalTime, serviceMap } = props;
|
||||
|
||||
const { queries } = useResourceAttribute();
|
||||
@@ -78,10 +59,6 @@ function ServiceMap(props: ServiceMapProps): JSX.Element {
|
||||
getDetailedServiceMapItems(globalTime, queries);
|
||||
}, [globalTime, getDetailedServiceMapItems, queries]);
|
||||
|
||||
useEffect(() => {
|
||||
fgRef.current && fgRef.current.d3Force('charge').strength(-400);
|
||||
});
|
||||
|
||||
if (serviceMap.loading) {
|
||||
return <Spinner size="large" tip="Loading..." />;
|
||||
}
|
||||
@@ -108,7 +85,7 @@ function ServiceMap(props: ServiceMapProps): JSX.Element {
|
||||
}
|
||||
/>
|
||||
|
||||
<Map fgRef={fgRef} serviceMap={serviceMap} />
|
||||
<Map serviceMap={serviceMap} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Edge, EdgeProps, getBezierPath } from '@xyflow/react';
|
||||
|
||||
import { EDGE_DASH_PERIOD, getDashAnimation } from '../Map/Map.constants';
|
||||
|
||||
export interface FlowEdgeData extends Record<string, unknown> {
|
||||
p99: number;
|
||||
callRate: number;
|
||||
errorRate: number;
|
||||
maxCallRate: number;
|
||||
}
|
||||
|
||||
// Matches @xyflow/react's BaseEdge default — the wider transparent path that
|
||||
// catches hover for the tooltip even though the visible dashes are thin.
|
||||
const INTERACTION_WIDTH = 20;
|
||||
|
||||
function FlowEdge({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
style,
|
||||
markerEnd,
|
||||
data,
|
||||
}: EdgeProps<Edge<FlowEdgeData>>): JSX.Element {
|
||||
const [edgePath] = getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
});
|
||||
|
||||
const callRate = data?.callRate ?? 0;
|
||||
const maxCallRate = data?.maxCallRate ?? 0;
|
||||
const { duration } = getDashAnimation(callRate, maxCallRate);
|
||||
|
||||
return (
|
||||
<>
|
||||
<path
|
||||
id={id}
|
||||
className="react-flow__edge-path"
|
||||
d={edgePath}
|
||||
fill="none"
|
||||
style={style}
|
||||
markerEnd={markerEnd}
|
||||
>
|
||||
{duration > 0 && (
|
||||
// Positive `stroke-dashoffset` shifts the dash pattern toward the
|
||||
// path's start, so visually the dashes flow target -> source
|
||||
// (callee -> caller), matching the original particle direction.
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
from="0"
|
||||
to={EDGE_DASH_PERIOD}
|
||||
dur={`${duration}s`}
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
)}
|
||||
</path>
|
||||
<path
|
||||
d={edgePath}
|
||||
fill="none"
|
||||
strokeOpacity={0}
|
||||
strokeWidth={INTERACTION_WIDTH}
|
||||
className="react-flow__edge-interaction"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default FlowEdge;
|
||||
@@ -0,0 +1,27 @@
|
||||
.tooltip {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
padding: 12px;
|
||||
min-width: 160px;
|
||||
font-size: 12px;
|
||||
font-family: Inter, sans-serif;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
color: var(--popover-foreground);
|
||||
background: var(--popover);
|
||||
border: 1px solid var(--secondary-border);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.value {
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import styles from './LinkTooltip.module.scss';
|
||||
|
||||
export interface LinkTooltipData {
|
||||
p99: string | number;
|
||||
callRate: string | number;
|
||||
errorRate: string | number;
|
||||
}
|
||||
|
||||
export interface LinkTooltipProps {
|
||||
tooltip: LinkTooltipData;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const POINTER_OFFSET = 12;
|
||||
|
||||
function LinkTooltip({ tooltip, x, y }: LinkTooltipProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={styles.tooltip}
|
||||
style={{ top: y + POINTER_OFFSET, left: x + POINTER_OFFSET }}
|
||||
>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.label}>P99 latency:</span>
|
||||
<span className={styles.value}>{tooltip.p99}ms</span>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.label}>Request:</span>
|
||||
<span className={styles.value}>{tooltip.callRate}/sec</span>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.label}>Error Rate:</span>
|
||||
<span className={styles.value}>{tooltip.errorRate}%</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LinkTooltip;
|
||||
@@ -0,0 +1,45 @@
|
||||
// Geometry of a service node as drawn on the map. Pills are split into a
|
||||
// colored icon box and a tinted body; the small monospace service-id label
|
||||
// rendered above the pill is what `NODE_OUTER_HEIGHT` accounts for so dagre
|
||||
// reserves enough vertical room and `Map.tsx` can centre the visual mass on
|
||||
// the simulated coordinate.
|
||||
export const NODE_WIDTH = 184;
|
||||
export const NODE_HEIGHT = 58;
|
||||
export const LABEL_HEIGHT = 16;
|
||||
export const NODE_LABEL_GAP = 6;
|
||||
export const NODE_OUTER_HEIGHT = NODE_HEIGHT + LABEL_HEIGHT + NODE_LABEL_GAP;
|
||||
|
||||
// Edge dash pattern. `EDGE_DASH_PERIOD` (dash + gap) is the loop distance the
|
||||
// marching-dash animation must travel for a seamless wrap; deriving it from
|
||||
// the parts keeps it locked to the dasharray.
|
||||
const EDGE_DASH_LENGTH = 5;
|
||||
const EDGE_DASH_GAP = 4;
|
||||
export const EDGE_DASH_ARRAY = `${EDGE_DASH_LENGTH} ${EDGE_DASH_GAP}`;
|
||||
export const EDGE_DASH_PERIOD = EDGE_DASH_LENGTH + EDGE_DASH_GAP;
|
||||
|
||||
// Per-edge marching-dash speed scales with the edge's call rate *relative to
|
||||
// the busiest edge in the current graph*, on a log10 ladder. The busiest edge
|
||||
// always pegs the fastest march; the slowest gets a slow drift. This keeps
|
||||
// the visualisation legible whether the busiest service handles 5 req/sec or 5k.
|
||||
export const DASH_FAST_SECS = 0.2;
|
||||
export const DASH_SLOW_SECS = 1.1;
|
||||
|
||||
// Compute per-period duration for an edge's call rate, scaled against the max
|
||||
// call rate observed across the graph. A duration of 0 means "no call rate,
|
||||
// don't animate". Pure so it can be unit-tested without rendering the edge.
|
||||
export function getDashAnimation(
|
||||
callRate: number,
|
||||
maxCallRate: number,
|
||||
): { duration: number } {
|
||||
if (callRate <= 0) {
|
||||
return { duration: 0 };
|
||||
}
|
||||
// Defensive: if a stale/zero max sneaks in, treat this edge as the max so
|
||||
// `factor` stays in [0, 1] rather than going to Infinity or NaN.
|
||||
const effectiveMax = Math.max(maxCallRate, callRate);
|
||||
const logRate = Math.log10(callRate + 1);
|
||||
const logMax = Math.log10(effectiveMax + 1);
|
||||
const factor = logMax > 0 ? logRate / logMax : 1;
|
||||
const duration = DASH_SLOW_SECS - factor * (DASH_SLOW_SECS - DASH_FAST_SECS);
|
||||
return { duration };
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
height: calc(100vh - 124px);
|
||||
position: relative;
|
||||
border: 1px solid var(--l3-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
// ReactFlow defaults edge pointer-events to `visibleStroke`, which means
|
||||
// our thin dashed line only captures hover on the painted dash segments.
|
||||
// Force `stroke` on the wide invisible interaction path so the entire edge
|
||||
// length is hoverable for the tooltip.
|
||||
:global(.react-flow__edge-interaction) {
|
||||
pointer-events: stroke;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
:global(.react-flow__edge) {
|
||||
pointer-events: stroke;
|
||||
}
|
||||
}
|
||||
286
frontend/src/modules/Servicemap/components/Map/Map.tsx
Normal file
286
frontend/src/modules/Servicemap/components/Map/Map.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
import '@xyflow/react/dist/style.css';
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Controls,
|
||||
Edge,
|
||||
Node,
|
||||
ReactFlow,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
} from '@xyflow/react';
|
||||
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import FlowEdge, { FlowEdgeData } from '../FlowEdge/FlowEdge';
|
||||
import {
|
||||
EDGE_DASH_ARRAY,
|
||||
NODE_OUTER_HEIGHT,
|
||||
NODE_WIDTH,
|
||||
} from './Map.constants';
|
||||
import styles from './Map.module.scss';
|
||||
import ServiceNode, { ServiceNodeData } from '../ServiceNode/ServiceNode';
|
||||
import LinkTooltip from '../LinkTooltip/LinkTooltip';
|
||||
import {
|
||||
computeNodePositions,
|
||||
getEdgeColor,
|
||||
getGraphData,
|
||||
getLinkTooltip,
|
||||
LinkTooltip as LinkTooltipData,
|
||||
} from '../../utils';
|
||||
|
||||
const nodeTypes = { service: ServiceNode };
|
||||
const edgeTypes = { flow: FlowEdge };
|
||||
|
||||
const BG_COLOR = 'var(--l2-background)';
|
||||
|
||||
const BASE_EDGE_STYLE = {
|
||||
strokeWidth: 1.25,
|
||||
strokeDasharray: EDGE_DASH_ARRAY,
|
||||
};
|
||||
interface HoverState {
|
||||
tooltip: LinkTooltipData;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
// Opacity applied to dimmed nodes/edges while a node is being hovered. Picked
|
||||
// to push background elements far enough out of focus that the highlighted
|
||||
// neighborhood reads as a single cluster, without making them unreadable.
|
||||
const DIM_NODE_OPACITY = 0.18;
|
||||
const DIM_EDGE_OPACITY = 0.12;
|
||||
const DIM_TRANSITION = 'opacity 0.15s ease-out';
|
||||
|
||||
function ServiceMap({ serviceMap }: any): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const [hovered, setHovered] = useState<HoverState | null>(null);
|
||||
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
|
||||
|
||||
const { nodes: rawNodes, links } = useMemo(
|
||||
() => getGraphData(serviceMap, isDarkMode),
|
||||
[serviceMap, isDarkMode],
|
||||
);
|
||||
|
||||
const positions = useMemo(
|
||||
() => computeNodePositions(rawNodes, links),
|
||||
[rawNodes, links],
|
||||
);
|
||||
|
||||
const initialNodes: Node<ServiceNodeData>[] = useMemo(
|
||||
() =>
|
||||
rawNodes.map((node) => {
|
||||
const center = positions[node.id] ?? { x: 0, y: 0 };
|
||||
return {
|
||||
id: node.id,
|
||||
type: 'service',
|
||||
// `position` is the top-left of the node bounding box; centre the
|
||||
// pill (plus its above-label) on the simulated coordinate.
|
||||
position: {
|
||||
x: center.x - NODE_WIDTH / 2,
|
||||
y: center.y - NODE_OUTER_HEIGHT / 2,
|
||||
},
|
||||
data: { label: node.id, status: node.status },
|
||||
draggable: true,
|
||||
selectable: false,
|
||||
};
|
||||
}),
|
||||
[rawNodes, positions],
|
||||
);
|
||||
|
||||
// Dash march speed is scaled relative to the busiest edge in the current
|
||||
// graph, so each render of the edge layer needs the per-graph max.
|
||||
const maxCallRate = useMemo(
|
||||
() => links.reduce((max, link) => Math.max(max, link.callRate ?? 0), 0),
|
||||
[links],
|
||||
);
|
||||
|
||||
// Edge stroke is driven by the target node's health, so build a quick
|
||||
// lookup once per graph to avoid an O(n) scan per edge.
|
||||
const nodeStatusById = useMemo(() => {
|
||||
const map: Record<string, 'healthy' | 'error'> = {};
|
||||
rawNodes.forEach((node) => {
|
||||
map[node.id] = node.status;
|
||||
});
|
||||
return map;
|
||||
}, [rawNodes]);
|
||||
|
||||
const initialEdges: Edge<FlowEdgeData>[] = useMemo(
|
||||
() =>
|
||||
links.map((link, i) => ({
|
||||
id: `${link.source}->${link.target}-${i}`,
|
||||
source: link.source,
|
||||
target: link.target,
|
||||
type: 'flow',
|
||||
data: {
|
||||
p99: link.p99,
|
||||
callRate: link.callRate,
|
||||
errorRate: link.errorRate,
|
||||
maxCallRate,
|
||||
},
|
||||
style: {
|
||||
...BASE_EDGE_STYLE,
|
||||
stroke: getEdgeColor(nodeStatusById[link.target] ?? 'healthy'),
|
||||
},
|
||||
})),
|
||||
[links, maxCallRate, nodeStatusById],
|
||||
);
|
||||
|
||||
const [flowNodes, setFlowNodes, onNodesChange] =
|
||||
useNodesState<Node<ServiceNodeData>>(initialNodes);
|
||||
const [flowEdges, setFlowEdges, onEdgesChange] =
|
||||
useEdgesState<Edge<FlowEdgeData>>(initialEdges);
|
||||
|
||||
// Reset internal node/edge state when the source graph changes (filters,
|
||||
// time range, theme). User drag positions during a stable graph are kept.
|
||||
useEffect(() => {
|
||||
setFlowNodes(initialNodes);
|
||||
}, [initialNodes, setFlowNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
setFlowEdges(initialEdges);
|
||||
}, [initialEdges, setFlowEdges]);
|
||||
|
||||
// Undirected adjacency: when hovering a node we want the cluster of its
|
||||
// callers AND callees lit, regardless of the edge's source/target order.
|
||||
const adjacency = useMemo(() => {
|
||||
const map: Record<string, Set<string>> = {};
|
||||
links.forEach((link) => {
|
||||
(map[link.source] ??= new Set()).add(link.target);
|
||||
(map[link.target] ??= new Set()).add(link.source);
|
||||
});
|
||||
return map;
|
||||
}, [links]);
|
||||
|
||||
const isNodeHighlighted = useCallback(
|
||||
(id: string): boolean => {
|
||||
if (!hoveredNodeId) {
|
||||
return true;
|
||||
}
|
||||
if (id === hoveredNodeId) {
|
||||
return true;
|
||||
}
|
||||
return adjacency[hoveredNodeId]?.has(id) ?? false;
|
||||
},
|
||||
[hoveredNodeId, adjacency],
|
||||
);
|
||||
|
||||
const isEdgeHighlighted = useCallback(
|
||||
(source: string, target: string): boolean => {
|
||||
if (!hoveredNodeId) {
|
||||
return true;
|
||||
}
|
||||
// Only edges directly touching the hovered node stay lit; edges
|
||||
// between two of its neighbours are dimmed too.
|
||||
return source === hoveredNodeId || target === hoveredNodeId;
|
||||
},
|
||||
[hoveredNodeId],
|
||||
);
|
||||
|
||||
// Display lists wrap the live `flowNodes`/`flowEdges` with a per-element
|
||||
// opacity derived from the hover state. Keeping this separate from the
|
||||
// state setters means a hover doesn't perturb drag positions or the
|
||||
// re-init useEffect above.
|
||||
const displayNodes = useMemo(
|
||||
() =>
|
||||
flowNodes.map((node) => ({
|
||||
...node,
|
||||
style: {
|
||||
...node.style,
|
||||
opacity: isNodeHighlighted(node.id) ? 1 : DIM_NODE_OPACITY,
|
||||
transition: DIM_TRANSITION,
|
||||
},
|
||||
})),
|
||||
[flowNodes, isNodeHighlighted],
|
||||
);
|
||||
|
||||
const displayEdges = useMemo(
|
||||
() =>
|
||||
flowEdges.map((edge) => ({
|
||||
...edge,
|
||||
style: {
|
||||
...edge.style,
|
||||
opacity: isEdgeHighlighted(edge.source, edge.target)
|
||||
? 1
|
||||
: DIM_EDGE_OPACITY,
|
||||
transition: DIM_TRANSITION,
|
||||
},
|
||||
})),
|
||||
[flowEdges, isEdgeHighlighted],
|
||||
);
|
||||
|
||||
const handleNodeMouseEnter = useCallback(
|
||||
(_event: React.MouseEvent, node: Node): void => {
|
||||
setHoveredNodeId(node.id);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleNodeMouseLeave = useCallback((): void => {
|
||||
setHoveredNodeId(null);
|
||||
}, []);
|
||||
|
||||
const handleEdgeMouseEnter = (event: React.MouseEvent, edge: Edge): void => {
|
||||
setHovered({
|
||||
tooltip: getLinkTooltip(edge.data as any),
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdgeMouseMove = (event: React.MouseEvent, edge: Edge): void => {
|
||||
setHovered((prev) =>
|
||||
prev
|
||||
? { ...prev, x: event.clientX, y: event.clientY }
|
||||
: {
|
||||
tooltip: getLinkTooltip(edge.data as any),
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleEdgeMouseLeave = (): void => {
|
||||
setHovered(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<ReactFlow
|
||||
nodes={displayNodes}
|
||||
edges={displayEdges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
fitView
|
||||
minZoom={0.2}
|
||||
maxZoom={4}
|
||||
nodesDraggable
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
colorMode={isDarkMode ? 'dark' : 'light'}
|
||||
onNodeMouseEnter={handleNodeMouseEnter}
|
||||
onNodeMouseLeave={handleNodeMouseLeave}
|
||||
onEdgeMouseEnter={handleEdgeMouseEnter}
|
||||
onEdgeMouseMove={handleEdgeMouseMove}
|
||||
onEdgeMouseLeave={handleEdgeMouseLeave}
|
||||
>
|
||||
<Background
|
||||
bgColor={BG_COLOR}
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={24}
|
||||
size={1}
|
||||
/>
|
||||
<Controls showInteractive={false} />
|
||||
</ReactFlow>
|
||||
{hovered && (
|
||||
<LinkTooltip tooltip={hovered.tooltip} x={hovered.x} y={hovered.y} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ServiceMap);
|
||||
@@ -0,0 +1,110 @@
|
||||
.wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.id {
|
||||
// `max-width` is set inline from `NODE_WIDTH` so truncation stays in
|
||||
// lockstep with the pill below.
|
||||
margin-bottom: 6px;
|
||||
padding-left: 4px;
|
||||
font-family: 'Geist Mono', 'SF Mono', 'Roboto Mono', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: var(--l1-foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 12px;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
position: relative;
|
||||
color: var(--l1-foreground);
|
||||
// Frosted glass: the .healthy/.error backgrounds are intentionally
|
||||
// translucent (color-mix with transparent), so this blurs whatever sits
|
||||
// behind the pill — the dotted background grid and any edges that pass
|
||||
// through the node bounds — for a softer fill.
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.healthy {
|
||||
border-color: var(--success-background);
|
||||
background: color-mix(in srgb, var(--success-background) 32%, transparent);
|
||||
|
||||
.iconBox {
|
||||
background: var(--success-background);
|
||||
color: var(--l1-background);
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
border-color: var(--danger-background);
|
||||
background: color-mix(in srgb, var(--danger-background) 32%, transparent);
|
||||
|
||||
.iconBox {
|
||||
background: var(--danger-background);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.iconBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.statusText {
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
user-select: none;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.handle {
|
||||
opacity: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
pointer-events: none;
|
||||
border: none;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Handle, Node, NodeProps, Position } from '@xyflow/react';
|
||||
import cx from 'classnames';
|
||||
import { HardDrive } from '@signozhq/icons';
|
||||
|
||||
import { NODE_HEIGHT, NODE_WIDTH } from '../Map/Map.constants';
|
||||
import styles from './ServiceNode.module.scss';
|
||||
|
||||
export type ServiceNodeStatus = 'healthy' | 'error';
|
||||
|
||||
export interface ServiceNodeData extends Record<string, unknown> {
|
||||
label: string;
|
||||
status: ServiceNodeStatus;
|
||||
}
|
||||
|
||||
const ICON_SIZE = 24;
|
||||
|
||||
// Render a friendlier name inside the pill: split on hyphens / underscores /
|
||||
// whitespace and title-case each word. The lowercase service id is preserved
|
||||
// above the pill for unambiguous lookup.
|
||||
function formatDisplayName(label: string): string {
|
||||
const parts = label.split(/[-_\s]+/).filter(Boolean);
|
||||
if (parts.length === 0) {
|
||||
return label;
|
||||
}
|
||||
return parts
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function ServiceNode({ data }: NodeProps<Node<ServiceNodeData>>): JSX.Element {
|
||||
const { status, label } = data;
|
||||
const displayName = formatDisplayName(label);
|
||||
const statusLabel = status === 'error' ? 'Errors' : 'Healthy';
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<div className={styles.id} title={label} style={{ maxWidth: NODE_WIDTH }}>
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
data-testid="service-node-pill"
|
||||
className={cx(styles.pill, styles[status])}
|
||||
style={{ width: NODE_WIDTH, height: NODE_HEIGHT }}
|
||||
>
|
||||
<div className={styles.iconBox}>
|
||||
<HardDrive size={ICON_SIZE} strokeWidth={1.75} aria-hidden />
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
<div className={styles.name}>{displayName}</div>
|
||||
<div className={styles.statusText}>{statusLabel}</div>
|
||||
</div>
|
||||
<Handle type="target" position={Position.Left} className={styles.handle} />
|
||||
<Handle type="source" position={Position.Right} className={styles.handle} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServiceNode;
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Position } from '@xyflow/react';
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import FlowEdge, { FlowEdgeData } from '../FlowEdge/FlowEdge';
|
||||
import { EDGE_DASH_PERIOD } from '../Map/Map.constants';
|
||||
|
||||
// Stub getBezierPath so assertions don't depend on the internal path geometry
|
||||
// — we only care that FlowEdge wires its inputs through and animates the
|
||||
// stroke-dashoffset at a relative speed.
|
||||
jest.mock('@xyflow/react', () => {
|
||||
const actual = jest.requireActual('@xyflow/react');
|
||||
return {
|
||||
...actual,
|
||||
getBezierPath: ({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
}: {
|
||||
sourceX: number;
|
||||
sourceY: number;
|
||||
targetX: number;
|
||||
targetY: number;
|
||||
}): [string, number, number, number, number] => [
|
||||
`M${sourceX},${sourceY} L${targetX},${targetY}`,
|
||||
(sourceX + targetX) / 2,
|
||||
(sourceY + targetY) / 2,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const baseEdgeProps = {
|
||||
id: 'edge-1',
|
||||
source: 'a',
|
||||
target: 'b',
|
||||
sourceX: 0,
|
||||
sourceY: 0,
|
||||
targetX: 100,
|
||||
targetY: 0,
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
style: { stroke: '#000' },
|
||||
markerEnd: 'url(#arrow)',
|
||||
} as const;
|
||||
|
||||
function renderEdge(data: FlowEdgeData | undefined): ReturnType<typeof render> {
|
||||
return render(<FlowEdge {...(baseEdgeProps as any)} data={data} />);
|
||||
}
|
||||
|
||||
const SAMPLE_DATA: FlowEdgeData = {
|
||||
p99: 1000000,
|
||||
callRate: 25,
|
||||
errorRate: 0,
|
||||
maxCallRate: 1000,
|
||||
};
|
||||
|
||||
function getVisiblePath(container: HTMLElement): SVGPathElement {
|
||||
const path = container.querySelector<SVGPathElement>(
|
||||
'path.react-flow__edge-path',
|
||||
);
|
||||
if (!path) {
|
||||
throw new Error('expected to find react-flow__edge-path path');
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
describe('FlowEdge', () => {
|
||||
it('forwards id, path, style, and markerEnd to the visible edge path', () => {
|
||||
const { container } = renderEdge(SAMPLE_DATA);
|
||||
|
||||
const visible = getVisiblePath(container);
|
||||
expect(visible).toHaveAttribute('id', 'edge-1');
|
||||
// Visible path uses the forward bezier (source -> target).
|
||||
expect(visible).toHaveAttribute('d', 'M0,0 L100,0');
|
||||
expect(visible).toHaveAttribute('marker-end', 'url(#arrow)');
|
||||
expect(visible).toHaveStyle({ stroke: '#000' });
|
||||
});
|
||||
|
||||
it('renders a transparent wider interaction path so hover is robust', () => {
|
||||
// Without this, react-flow's default hover wouldn't be triggered and
|
||||
// the link tooltip would only appear when the cursor lands on a 1.25px
|
||||
// painted dash segment.
|
||||
const { container } = renderEdge(SAMPLE_DATA);
|
||||
|
||||
const interaction = container.querySelector<SVGPathElement>(
|
||||
'path.react-flow__edge-interaction',
|
||||
);
|
||||
expect(interaction).not.toBeNull();
|
||||
expect(interaction).toHaveAttribute('d', 'M0,0 L100,0');
|
||||
expect(interaction).toHaveAttribute('stroke-opacity', '0');
|
||||
});
|
||||
|
||||
it('omits the dash animation when callRate is zero', () => {
|
||||
const { container } = renderEdge({ ...SAMPLE_DATA, callRate: 0 });
|
||||
|
||||
expect(container.querySelectorAll('animate')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('omits the dash animation when data is missing', () => {
|
||||
const { container } = renderEdge(undefined);
|
||||
|
||||
expect(container.querySelectorAll('animate')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('animates stroke-dashoffset by exactly one dash period so the loop is seamless', () => {
|
||||
const { container } = renderEdge(SAMPLE_DATA);
|
||||
|
||||
const animate = container.querySelector('animate');
|
||||
expect(animate).not.toBeNull();
|
||||
expect(animate).toHaveAttribute('attributeName', 'stroke-dashoffset');
|
||||
expect(animate).toHaveAttribute('from', '0');
|
||||
expect(animate).toHaveAttribute('to', String(EDGE_DASH_PERIOD));
|
||||
expect(animate).toHaveAttribute('repeatCount', 'indefinite');
|
||||
});
|
||||
|
||||
it('sets a faster dash duration for the busiest edge than for a quieter one', () => {
|
||||
// Relative scaling: same maxCallRate, higher callRate -> shorter period.
|
||||
const { container: busy } = renderEdge({
|
||||
...SAMPLE_DATA,
|
||||
callRate: 1000,
|
||||
maxCallRate: 1000,
|
||||
});
|
||||
const { container: quiet } = renderEdge({
|
||||
...SAMPLE_DATA,
|
||||
callRate: 1,
|
||||
maxCallRate: 1000,
|
||||
});
|
||||
|
||||
const parseDur = (root: HTMLElement): number => {
|
||||
const animate = root.querySelector('animate');
|
||||
const dur = animate?.getAttribute('dur') ?? '';
|
||||
return parseFloat(dur);
|
||||
};
|
||||
|
||||
expect(parseDur(busy)).toBeLessThan(parseDur(quiet));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import LinkTooltip, { LinkTooltipData } from '../LinkTooltip/LinkTooltip';
|
||||
|
||||
const baseTooltip: LinkTooltipData = {
|
||||
p99: 12.34,
|
||||
callRate: 5.6,
|
||||
errorRate: 0.1,
|
||||
};
|
||||
|
||||
describe('LinkTooltip', () => {
|
||||
it('renders p99, request, and error rate rows with their suffixes', () => {
|
||||
render(<LinkTooltip tooltip={baseTooltip} x={0} y={0} />);
|
||||
|
||||
expect(screen.getByText('P99 latency:')).toBeInTheDocument();
|
||||
expect(screen.getByText('12.34ms')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('Request:')).toBeInTheDocument();
|
||||
expect(screen.getByText('5.6/sec')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('Error Rate:')).toBeInTheDocument();
|
||||
expect(screen.getByText('0.1%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders string-typed metric values verbatim', () => {
|
||||
render(
|
||||
<LinkTooltip
|
||||
tooltip={{ p99: '0', callRate: '0', errorRate: '0' }}
|
||||
x={0}
|
||||
y={0}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('0ms')).toBeInTheDocument();
|
||||
expect(screen.getByText('0/sec')).toBeInTheDocument();
|
||||
expect(screen.getByText('0%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('positions itself offset from the cursor coordinates', () => {
|
||||
const { container } = render(
|
||||
<LinkTooltip tooltip={baseTooltip} x={100} y={200} />,
|
||||
);
|
||||
|
||||
// POINTER_OFFSET is 12 in the component; the tooltip should sit at
|
||||
// (x + 12, y + 12) so it does not occlude the hovered edge segment.
|
||||
const tooltip = container.firstChild as HTMLElement;
|
||||
expect(tooltip).toHaveStyle({ top: '212px', left: '112px' });
|
||||
});
|
||||
|
||||
it('handles negative coordinates without breaking the offset math', () => {
|
||||
const { container } = render(
|
||||
<LinkTooltip tooltip={baseTooltip} x={-50} y={-30} />,
|
||||
);
|
||||
|
||||
const tooltip = container.firstChild as HTMLElement;
|
||||
expect(tooltip).toHaveStyle({ top: '-18px', left: '-38px' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
DASH_FAST_SECS,
|
||||
DASH_SLOW_SECS,
|
||||
getDashAnimation,
|
||||
} from '../Map/Map.constants';
|
||||
|
||||
describe('getDashAnimation', () => {
|
||||
it('returns duration: 0 for non-positive call rates so the dash march is skipped', () => {
|
||||
expect(getDashAnimation(0, 1000)).toStrictEqual({ duration: 0 });
|
||||
expect(getDashAnimation(-5, 1000)).toStrictEqual({ duration: 0 });
|
||||
});
|
||||
|
||||
it('saturates at DASH_FAST_SECS when callRate equals max', () => {
|
||||
// Whatever the absolute scale, the busiest edge should peg the
|
||||
// visualisation — that's the point of the relative scaling.
|
||||
const EPS = 1e-9;
|
||||
[5, 50, 500, 5_000, 1_000_000].forEach((rate) => {
|
||||
const { duration } = getDashAnimation(rate, rate);
|
||||
expect(duration).toBeGreaterThanOrEqual(DASH_FAST_SECS - EPS);
|
||||
expect(duration).toBeLessThanOrEqual(DASH_FAST_SECS + EPS);
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps to DASH_FAST_SECS even if max is stale or zero', () => {
|
||||
// Defensive: if max somehow lags behind callRate, factor still clamps to 1.
|
||||
const EPS = 1e-9;
|
||||
expect(getDashAnimation(1000, 0).duration).toBeLessThanOrEqual(
|
||||
DASH_FAST_SECS + EPS,
|
||||
);
|
||||
expect(getDashAnimation(1000, 100).duration).toBeLessThanOrEqual(
|
||||
DASH_FAST_SECS + EPS,
|
||||
);
|
||||
});
|
||||
|
||||
it('produces different durations for the same callRate at different scales', () => {
|
||||
// 50 req/sec is "busy" in a 50-max graph but "trickle" in a 5k-max graph.
|
||||
const busy = getDashAnimation(50, 50);
|
||||
const trickle = getDashAnimation(50, 5000);
|
||||
expect(busy.duration).toBeLessThan(trickle.duration);
|
||||
});
|
||||
|
||||
it('monotonically decreases per-period duration as rate climbs toward max', () => {
|
||||
const max = 5000;
|
||||
const rates = [0.5, 5, 50, 500, max];
|
||||
const durations = rates.map((r) => getDashAnimation(r, max).duration);
|
||||
for (let i = 1; i < durations.length; i += 1) {
|
||||
expect(durations[i]).toBeLessThanOrEqual(durations[i - 1]);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps positive-rate duration bounded between DASH_FAST_SECS and DASH_SLOW_SECS', () => {
|
||||
// At saturation the formula computes to DASH_FAST_SECS up to floating
|
||||
// point error (~1e-16), so allow a small epsilon.
|
||||
const EPS = 1e-9;
|
||||
const cases: Array<[number, number]> = [
|
||||
[0.01, 1000],
|
||||
[1, 1000],
|
||||
[10, 1000],
|
||||
[100, 1000],
|
||||
[1000, 1000],
|
||||
[1000, 0], // defensive max
|
||||
[1_000_000, 1_000_000],
|
||||
];
|
||||
cases.forEach(([rate, max]) => {
|
||||
const { duration } = getDashAnimation(rate, max);
|
||||
expect(duration).toBeGreaterThanOrEqual(DASH_FAST_SECS - EPS);
|
||||
expect(duration).toBeLessThanOrEqual(DASH_SLOW_SECS + EPS);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import ServiceNode, { ServiceNodeData } from '../ServiceNode/ServiceNode';
|
||||
import { NODE_HEIGHT, NODE_WIDTH } from '../Map/Map.constants';
|
||||
|
||||
// `Handle` requires a ReactFlowProvider to mount. We don't exercise its
|
||||
// connection logic from this component, so a stub keeps the test isolated to
|
||||
// ServiceNode's own rendering responsibilities.
|
||||
jest.mock('@xyflow/react', () => {
|
||||
const actual = jest.requireActual('@xyflow/react');
|
||||
return {
|
||||
...actual,
|
||||
Handle: ({
|
||||
type,
|
||||
position,
|
||||
}: {
|
||||
type: string;
|
||||
position: string;
|
||||
}): JSX.Element => (
|
||||
<div data-testid={`handle-${type}`} data-position={position} />
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const baseNodeProps = {
|
||||
id: 'frontend',
|
||||
type: 'service',
|
||||
dragging: false,
|
||||
isConnectable: true,
|
||||
positionAbsoluteX: 0,
|
||||
positionAbsoluteY: 0,
|
||||
zIndex: 0,
|
||||
selectable: false,
|
||||
deletable: false,
|
||||
draggable: true,
|
||||
selected: false,
|
||||
} as const;
|
||||
|
||||
function renderNode(data: ServiceNodeData): ReturnType<typeof render> {
|
||||
return render(<ServiceNode {...(baseNodeProps as any)} data={data} />);
|
||||
}
|
||||
|
||||
describe('ServiceNode', () => {
|
||||
it('renders the raw service id above the pill so users can map it back to backend names', () => {
|
||||
// The above-pill text stays verbatim (no case change, no separator
|
||||
// rewrite) because that is the canonical service identifier.
|
||||
renderNode({ label: 'checkout-service', status: 'healthy' });
|
||||
|
||||
const id = screen.getByText('checkout-service');
|
||||
expect(id).toBeInTheDocument();
|
||||
expect(id).toHaveAttribute('title', 'checkout-service');
|
||||
});
|
||||
|
||||
it('renders a title-cased display name inside the pill, splitting on hyphens and underscores', () => {
|
||||
renderNode({ label: 'checkout-service', status: 'healthy' });
|
||||
|
||||
expect(screen.getByText('Checkout Service')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the raw label inside the pill when there are no separators to split on', () => {
|
||||
// Single token gets a leading capital but isn't otherwise transformed —
|
||||
// we don't try to split unknown camel/compound boundaries.
|
||||
renderNode({ label: 'redis', status: 'healthy' });
|
||||
|
||||
expect(screen.getByText('Redis')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Healthy" inside the pill when status is healthy', () => {
|
||||
renderNode({ label: 'frontend', status: 'healthy' });
|
||||
|
||||
expect(screen.getByText('Healthy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Errors" inside the pill when status is error', () => {
|
||||
// The non-healthy state visually flips the icon-box and body tints to
|
||||
// the danger color via the .error class — assert via the status text
|
||||
// since CSS module classnames are hashed and brittle to match on.
|
||||
renderNode({ label: 'frontend', status: 'error' });
|
||||
|
||||
expect(screen.getByText('Errors')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sizes the pill to NODE_WIDTH x NODE_HEIGHT', () => {
|
||||
// All pills render at the same configured dimensions — there is no
|
||||
// per-node sizing, so layout in dagre stays predictable.
|
||||
renderNode({ label: 'frontend', status: 'healthy' });
|
||||
|
||||
expect(screen.getByTestId('service-node-pill')).toHaveStyle({
|
||||
width: `${NODE_WIDTH}px`,
|
||||
height: `${NODE_HEIGHT}px`,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a target handle on the left and a source handle on the right', () => {
|
||||
renderNode({ label: 'frontend', status: 'healthy' });
|
||||
|
||||
const target = screen.getByTestId('handle-target');
|
||||
const source = screen.getByTestId('handle-source');
|
||||
|
||||
expect(target).toHaveAttribute('data-position', 'left');
|
||||
expect(source).toHaveAttribute('data-position', 'right');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
//@ts-nocheck
|
||||
|
||||
import dagre from '@dagrejs/dagre';
|
||||
import {
|
||||
cloneDeep,
|
||||
find,
|
||||
@@ -12,27 +13,7 @@ import {
|
||||
|
||||
import { graphDataType } from './ServiceMap';
|
||||
|
||||
const MIN_WIDTH = 10;
|
||||
const MAX_WIDTH = 20;
|
||||
const DEFAULT_FONT_SIZE = 6;
|
||||
|
||||
export const getDimensions = (
|
||||
num: number,
|
||||
highest: number,
|
||||
): {
|
||||
fontSize: number;
|
||||
width: number;
|
||||
} => {
|
||||
const percentage = (num / highest) * 100;
|
||||
const width = (percentage * (MAX_WIDTH - MIN_WIDTH)) / 100 + MIN_WIDTH;
|
||||
const fontSize = DEFAULT_FONT_SIZE;
|
||||
return {
|
||||
fontSize,
|
||||
width,
|
||||
};
|
||||
};
|
||||
|
||||
export const getGraphData = (serviceMap, isDarkMode): graphDataType => {
|
||||
export const getGraphData = (serviceMap, _isDarkMode): graphDataType => {
|
||||
const { items } = serviceMap;
|
||||
const services = Object.values(groupBy(items, 'child')).map((e) => {
|
||||
return {
|
||||
@@ -42,7 +23,6 @@ export const getGraphData = (serviceMap, isDarkMode): graphDataType => {
|
||||
};
|
||||
});
|
||||
const highestCallCount = maxBy(items, (e) => e?.callCount)?.callCount;
|
||||
const highestCallRate = maxBy(services, (e) => e?.callRate)?.callRate;
|
||||
|
||||
const divNum = Number(
|
||||
String(1).padEnd(highestCallCount.toString().length, '0'),
|
||||
@@ -62,31 +42,13 @@ export const getGraphData = (serviceMap, isDarkMode): graphDataType => {
|
||||
const uniqParent = uniqBy(cloneDeep(items), 'parent').map((e) => e.parent);
|
||||
const uniqChild = uniqBy(cloneDeep(items), 'child').map((e) => e.child);
|
||||
const uniqNodes = uniq([...uniqParent, ...uniqChild]);
|
||||
const nodes = uniqNodes.map((node, i) => {
|
||||
const nodes = uniqNodes.map((node) => {
|
||||
const service = find(services, (service) => service.serviceName === node);
|
||||
let color = isDarkMode ? '#7CA568' : '#D5F2BB';
|
||||
if (!service) {
|
||||
return {
|
||||
id: node,
|
||||
group: i + 1,
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
width: MIN_WIDTH,
|
||||
color,
|
||||
nodeVal: MIN_WIDTH,
|
||||
name: node,
|
||||
};
|
||||
}
|
||||
if (service.errorRate > 0) {
|
||||
color = isDarkMode ? '#DB836E' : '#F98989';
|
||||
}
|
||||
const { fontSize, width } = getDimensions(service.callRate, highestCallRate);
|
||||
const status: 'healthy' | 'error' =
|
||||
service && service.errorRate > 0 ? 'error' : 'healthy';
|
||||
return {
|
||||
id: node,
|
||||
group: i + 1,
|
||||
fontSize,
|
||||
width,
|
||||
color,
|
||||
nodeVal: width,
|
||||
status,
|
||||
name: node,
|
||||
};
|
||||
});
|
||||
@@ -96,20 +58,6 @@ export const getGraphData = (serviceMap, isDarkMode): graphDataType => {
|
||||
};
|
||||
};
|
||||
|
||||
export const getZoomPx = (): number => {
|
||||
const { width } = window.screen;
|
||||
if (width < 1400) {
|
||||
return 190;
|
||||
}
|
||||
if (width > 1400 && width < 1700) {
|
||||
return 380;
|
||||
}
|
||||
if (width > 1700) {
|
||||
return 470;
|
||||
}
|
||||
return 190;
|
||||
};
|
||||
|
||||
const getRound2DigitsAfterDecimal = (num: number): number => {
|
||||
if (num === 0) {
|
||||
return 0;
|
||||
@@ -117,27 +65,30 @@ const getRound2DigitsAfterDecimal = (num: number): number => {
|
||||
return num.toFixed(20).match(/^-?\d*\.?0*\d{0,2}/)[0];
|
||||
};
|
||||
|
||||
export const getTooltip = (link: {
|
||||
export interface LinkTooltip {
|
||||
p99: string | number;
|
||||
callRate: string | number;
|
||||
errorRate: string | number;
|
||||
}
|
||||
|
||||
export const getLinkTooltip = (link: {
|
||||
p99: number;
|
||||
errorRate: number;
|
||||
callRate: number;
|
||||
id: string;
|
||||
}): string => {
|
||||
return `<div style="color:#333333;padding:12px;background: white;border-radius: 2px;">
|
||||
<div class="keyval">
|
||||
<div class="key">P99 latency:</div>
|
||||
<div class="val">${getRound2DigitsAfterDecimal(link.p99 / 1000000)}ms</div>
|
||||
</div>
|
||||
<div class="keyval">
|
||||
<div class="key">Request:</div>
|
||||
<div class="val">${getRound2DigitsAfterDecimal(link.callRate)}/sec</div>
|
||||
</div>
|
||||
<div class="keyval">
|
||||
<div class="key">Error Rate:</div>
|
||||
<div class="val">${getRound2DigitsAfterDecimal(link.errorRate)}%</div>
|
||||
</div>
|
||||
</div>`;
|
||||
};
|
||||
}): LinkTooltip => ({
|
||||
p99: getRound2DigitsAfterDecimal(link.p99 / 1000000),
|
||||
callRate: getRound2DigitsAfterDecimal(link.callRate),
|
||||
errorRate: getRound2DigitsAfterDecimal(link.errorRate),
|
||||
});
|
||||
|
||||
// Edges inherit the target node's health: red when the callee has errors,
|
||||
// green otherwise. Mixed with transparent so the dashed strokes read as a
|
||||
// softer accent against the busy node fills, while still letting a glance
|
||||
// at the map surface which downstream services are unhappy.
|
||||
export const getEdgeColor = (targetStatus: 'healthy' | 'error'): string =>
|
||||
targetStatus === 'error'
|
||||
? 'color-mix(in srgb, var(--danger-background) 65%, transparent)'
|
||||
: 'color-mix(in srgb, var(--success-background) 65%, transparent)';
|
||||
|
||||
export const transformLabel = (label: string, zoomLevel: number): string => {
|
||||
//? 13 is the minimum label length. Scaling factor of 0.9 which is slightly less than 1
|
||||
@@ -150,3 +101,51 @@ export const transformLabel = (label: string, zoomLevel: number): string => {
|
||||
}
|
||||
return label;
|
||||
};
|
||||
|
||||
// Layered DAG layout via dagre. For service maps the data flows
|
||||
// caller -> callee, so a left-to-right rank direction reads naturally and
|
||||
// minimises edge crossings vs. a force-directed simulation.
|
||||
//
|
||||
// `nodeBoxWidth`/`nodeBoxHeight` reserve the pill's bounding box plus the
|
||||
// monospace service-id rendered above it, with a little breathing room so
|
||||
// adjacent ranks don't overlap.
|
||||
export const computeNodePositions = (
|
||||
nodes: { id: string }[],
|
||||
links: { source: string; target: string }[],
|
||||
nodeBoxWidth = 220,
|
||||
nodeBoxHeight = 110,
|
||||
): Record<string, { x: number; y: number }> => {
|
||||
const result: Record<string, { x: number; y: number }> = {};
|
||||
if (nodes.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const g = new dagre.graphlib.Graph({ multigraph: true, compound: false });
|
||||
g.setGraph({
|
||||
rankdir: 'LR',
|
||||
nodesep: 40,
|
||||
ranksep: 90,
|
||||
marginx: 40,
|
||||
marginy: 40,
|
||||
});
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
nodes.forEach((node) => {
|
||||
g.setNode(node.id, { width: nodeBoxWidth, height: nodeBoxHeight });
|
||||
});
|
||||
links.forEach((link, i) => {
|
||||
// `name` makes parallel edges (same source+target, different metrics)
|
||||
// safe under multigraph mode.
|
||||
g.setEdge(link.source, link.target, {}, `${link.source}-${link.target}-${i}`);
|
||||
});
|
||||
|
||||
dagre.layout(g);
|
||||
|
||||
nodes.forEach((node) => {
|
||||
const laidOut = g.node(node.id);
|
||||
if (laidOut) {
|
||||
result[node.id] = { x: laidOut.x, y: laidOut.y };
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import TraceDetailsV3 from '../TraceDetailsV3';
|
||||
|
||||
export default function TraceDetailV3Page(): JSX.Element {
|
||||
return <TraceDetailsV3 />;
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
.analytics-panel {
|
||||
&__body {
|
||||
padding: 12px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--l1-background);
|
||||
|
||||
// TabsRoot — last direct child div
|
||||
> div:last-child {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[role='tablist'] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__tabs-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
&__list {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr;
|
||||
gap: 4px 8px;
|
||||
padding: 8px 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&__service-name {
|
||||
font-size: 13px;
|
||||
color: var(--l1-foreground);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
&__bar-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: var(--l3-background);
|
||||
border-radius: 3px;
|
||||
min-width: 40px;
|
||||
|
||||
&--small {
|
||||
max-width: 80px;
|
||||
flex: 0 0 80px;
|
||||
}
|
||||
}
|
||||
|
||||
&__bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&__value {
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&--wide {
|
||||
min-width: 55px;
|
||||
}
|
||||
|
||||
&--narrow {
|
||||
min-width: 25px;
|
||||
}
|
||||
}
|
||||
|
||||
// Tabs root
|
||||
[class*='tabs__list-wrapper'] {
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from '@signozhq/ui';
|
||||
import { DetailsHeader } from 'components/DetailsPanel';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { FloatingPanel } from 'periscope/components/FloatingPanel';
|
||||
|
||||
import './AnalyticsPanel.styles.scss';
|
||||
|
||||
interface AnalyticsPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
serviceExecTime?: Record<string, number>;
|
||||
traceStartTime?: number;
|
||||
traceEndTime?: number;
|
||||
// TODO: Re-enable when backend provides per-service span counts
|
||||
// spans?: Span[];
|
||||
}
|
||||
|
||||
const PANEL_WIDTH = 350;
|
||||
const PANEL_MARGIN_RIGHT = 100;
|
||||
const PANEL_MARGIN_TOP = 50;
|
||||
const PANEL_MARGIN_BOTTOM = 50;
|
||||
|
||||
function AnalyticsPanel({
|
||||
isOpen,
|
||||
onClose,
|
||||
serviceExecTime = {},
|
||||
traceStartTime = 0,
|
||||
traceEndTime = 0,
|
||||
}: AnalyticsPanelProps): JSX.Element | null {
|
||||
const spread = traceEndTime - traceStartTime;
|
||||
|
||||
const execTimeRows = useMemo(() => {
|
||||
if (spread <= 0) {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(serviceExecTime)
|
||||
.map(([service, duration]) => ({
|
||||
service,
|
||||
percentage: (duration * 100) / spread,
|
||||
color: generateColor(service, themeColors.traceDetailColorsV3),
|
||||
}))
|
||||
.sort((a, b) => b.percentage - a.percentage);
|
||||
}, [serviceExecTime, spread]);
|
||||
|
||||
// const spanCountRows = useMemo(() => {
|
||||
// const counts: Record<string, number> = {};
|
||||
// for (const span of spans) {
|
||||
// const name = span.serviceName || 'unknown';
|
||||
// counts[name] = (counts[name] || 0) + 1;
|
||||
// }
|
||||
// return Object.entries(counts)
|
||||
// .map(([service, count]) => ({
|
||||
// service,
|
||||
// count,
|
||||
// color: generateColor(service, themeColors.traceDetailColorsV3),
|
||||
// }))
|
||||
// .sort((a, b) => b.count - a.count);
|
||||
// }, [spans]);
|
||||
|
||||
// const maxSpanCount = spanCountRows[0]?.count || 1;
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FloatingPanel
|
||||
isOpen
|
||||
className="analytics-panel"
|
||||
width={PANEL_WIDTH}
|
||||
height={window.innerHeight - PANEL_MARGIN_TOP - PANEL_MARGIN_BOTTOM}
|
||||
defaultPosition={{
|
||||
x: window.innerWidth - PANEL_WIDTH - PANEL_MARGIN_RIGHT,
|
||||
y: PANEL_MARGIN_TOP,
|
||||
}}
|
||||
enableResizing={{
|
||||
top: true,
|
||||
bottom: true,
|
||||
left: false,
|
||||
right: false,
|
||||
topLeft: false,
|
||||
topRight: false,
|
||||
bottomLeft: false,
|
||||
bottomRight: false,
|
||||
}}
|
||||
>
|
||||
<DetailsHeader
|
||||
title="Analytics"
|
||||
onClose={onClose}
|
||||
className="floating-panel__drag-handle"
|
||||
/>
|
||||
|
||||
<div className="analytics-panel__body">
|
||||
<TabsRoot defaultValue="exec-time">
|
||||
<TabsList variant="secondary">
|
||||
<TabsTrigger value="exec-time" variant="secondary">
|
||||
% exec time
|
||||
</TabsTrigger>
|
||||
{/* TODO: Enable when backend provides per-service span counts
|
||||
<TabsTrigger value="spans" variant="secondary">
|
||||
Spans
|
||||
</TabsTrigger>
|
||||
*/}
|
||||
</TabsList>
|
||||
|
||||
<div className="analytics-panel__tabs-scroll">
|
||||
<TabsContent value="exec-time">
|
||||
<div className="analytics-panel__list">
|
||||
{execTimeRows.map((row) => (
|
||||
<>
|
||||
<div
|
||||
key={`${row.service}-dot`}
|
||||
className="analytics-panel__dot"
|
||||
style={{ backgroundColor: row.color }}
|
||||
/>
|
||||
<span
|
||||
key={`${row.service}-name`}
|
||||
className="analytics-panel__service-name"
|
||||
>
|
||||
{row.service}
|
||||
</span>
|
||||
<div key={`${row.service}-bar`} className="analytics-panel__bar-cell">
|
||||
<div className="analytics-panel__bar">
|
||||
<div
|
||||
className="analytics-panel__bar-fill"
|
||||
style={{
|
||||
width: `${Math.min(row.percentage, 100)}%`,
|
||||
backgroundColor: row.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="analytics-panel__value analytics-panel__value--wide">
|
||||
{row.percentage.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* TODO: Enable when backend provides per-service span counts
|
||||
<TabsContent value="spans">
|
||||
<div className="analytics-panel__list">
|
||||
{spanCountRows.map((row) => (
|
||||
<>
|
||||
<div
|
||||
key={`${row.service}-dot`}
|
||||
className="analytics-panel__dot"
|
||||
style={{ backgroundColor: row.color }}
|
||||
/>
|
||||
<span
|
||||
key={`${row.service}-name`}
|
||||
className="analytics-panel__service-name"
|
||||
>
|
||||
{row.service}
|
||||
</span>
|
||||
<div key={`${row.service}-bar`} className="analytics-panel__bar-cell">
|
||||
<div className="analytics-panel__bar">
|
||||
<div
|
||||
className="analytics-panel__bar-fill"
|
||||
style={{
|
||||
width: `${(row.count / maxSpanCount) * 100}%`,
|
||||
backgroundColor: row.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="analytics-panel__value analytics-panel__value--narrow">
|
||||
{row.count}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
*/}
|
||||
</div>
|
||||
</TabsRoot>
|
||||
</div>
|
||||
</FloatingPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export default AnalyticsPanel;
|
||||
@@ -1,34 +0,0 @@
|
||||
.linked-spans {
|
||||
position: relative;
|
||||
|
||||
&__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
&__label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__chevron {
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&--open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
&__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui';
|
||||
import ROUTES from 'constants/routes';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
|
||||
import './LinkedSpans.styles.scss';
|
||||
|
||||
interface SpanReference {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
refType: string;
|
||||
}
|
||||
|
||||
interface LinkedSpansProps {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
references: any;
|
||||
}
|
||||
|
||||
interface LinkedSpansState {
|
||||
linkedSpans: SpanReference[];
|
||||
count: number;
|
||||
isOpen: boolean;
|
||||
toggleOpen: () => void;
|
||||
}
|
||||
|
||||
export function useLinkedSpans(references: any): LinkedSpansState {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const linkedSpans: SpanReference[] = useMemo(
|
||||
() =>
|
||||
(references || []).filter(
|
||||
(ref: SpanReference) => ref.refType !== 'CHILD_OF',
|
||||
),
|
||||
[references],
|
||||
);
|
||||
|
||||
const toggleOpen = useCallback(() => setIsOpen((prev) => !prev), []);
|
||||
|
||||
return {
|
||||
linkedSpans,
|
||||
count: linkedSpans.length,
|
||||
isOpen,
|
||||
toggleOpen,
|
||||
};
|
||||
}
|
||||
|
||||
export function LinkedSpansToggle({
|
||||
count,
|
||||
isOpen,
|
||||
toggleOpen,
|
||||
}: {
|
||||
count: number;
|
||||
isOpen: boolean;
|
||||
toggleOpen: () => void;
|
||||
}): JSX.Element {
|
||||
if (count === 0) {
|
||||
return <span className="linked-spans__label">0 linked spans</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" className="linked-spans__toggle" onClick={toggleOpen}>
|
||||
<span className="linked-spans__label">
|
||||
{count} linked span{count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{isOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function LinkedSpansPanel({
|
||||
linkedSpans,
|
||||
isOpen,
|
||||
}: {
|
||||
linkedSpans: SpanReference[];
|
||||
isOpen: boolean;
|
||||
}): JSX.Element | null {
|
||||
const getLink = useCallback(
|
||||
(item: SpanReference): string =>
|
||||
`${ROUTES.TRACE}/${item.traceId}?spanId=${item.spanId}`,
|
||||
[],
|
||||
);
|
||||
|
||||
if (!isOpen || linkedSpans.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="linked-spans__list">
|
||||
{linkedSpans.map((item) => (
|
||||
<KeyValueLabel
|
||||
key={item.spanId}
|
||||
badgeKey="Linked Span ID"
|
||||
badgeValue={
|
||||
<Link to={getLink(item)}>
|
||||
<Badge color="vanilla">{item.spanId}</Badge>
|
||||
</Link>
|
||||
}
|
||||
direction="column"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkedSpans({ references }: LinkedSpansProps): JSX.Element {
|
||||
const { linkedSpans, count, isOpen, toggleOpen } = useLinkedSpans(references);
|
||||
|
||||
return (
|
||||
<div className="linked-spans">
|
||||
<LinkedSpansToggle count={count} isOpen={isOpen} toggleOpen={toggleOpen} />
|
||||
<LinkedSpansPanel linkedSpans={linkedSpans} isOpen={isOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LinkedSpans;
|
||||
@@ -1,154 +0,0 @@
|
||||
.span-details-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
&__header-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
background: var(--l1-background);
|
||||
font-size: 14px;
|
||||
gap: 16px;
|
||||
|
||||
.data-viewer {
|
||||
min-height: 500px;
|
||||
}
|
||||
}
|
||||
|
||||
&__details-section {
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__tabs-section {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-height: 100%;
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
// TabsRoot — direct child of tabs-section
|
||||
> div {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[role='tablist'] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[class*='tabs__list-wrapper'] {
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__tabs-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
[role='tabpanel'] {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__span-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__span-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
&__span-info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--l2-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__highlighted-options {
|
||||
padding: 8px 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
|
||||
.key-value-label {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&__service-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--success-500);
|
||||
}
|
||||
|
||||
&__trace-id {
|
||||
color: var(--accent-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__key-attributes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
|
||||
&-label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--l2-foreground);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.48px;
|
||||
line-height: var(--line-height-20);
|
||||
}
|
||||
|
||||
&-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tooltip is rendered in a portal but the SpanDetailsPanel can be docked as a
|
||||
// FloatingPanel (z-index 999), which would otherwise sit on top of the default
|
||||
// tooltip (z-index 50). Bump the tooltip above the panel.
|
||||
.dock-toggle-tooltip {
|
||||
--tooltip-z-index: 1000;
|
||||
}
|
||||
@@ -1,618 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsRoot,
|
||||
TabsTrigger,
|
||||
} from '@signozhq/ui';
|
||||
import {
|
||||
Bookmark,
|
||||
CalendarClock,
|
||||
ChartBar,
|
||||
ChartColumnBig,
|
||||
Dock,
|
||||
Link2,
|
||||
Logs,
|
||||
PanelBottom,
|
||||
ScrollText,
|
||||
Timer,
|
||||
} from '@signozhq/icons';
|
||||
import { Skeleton } from 'antd';
|
||||
import { DetailsHeader, DetailsPanelDrawer } from 'components/DetailsPanel';
|
||||
import { HeaderAction } from 'components/DetailsPanel/DetailsHeader/DetailsHeader';
|
||||
import { DetailsPanelState } from 'components/DetailsPanel/types';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryState,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
|
||||
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
|
||||
import Events from 'container/SpanDetailsDrawer/Events/Events';
|
||||
import SpanLogs from 'container/SpanDetailsDrawer/SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from 'container/SpanDetailsDrawer/SpanLogs/useSpanContextLogs';
|
||||
import dayjs from 'dayjs';
|
||||
import { getSpanAttribute, hasInfraMetadata } from 'pages/TraceDetailsV3/utils';
|
||||
import { DataViewer } from 'periscope/components/DataViewer';
|
||||
import { FloatingPanel } from 'periscope/components/FloatingPanel';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { getLeafKeyFromPath } from 'periscope/components/PrettyView/utils';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import AnalyticsPanel from './AnalyticsPanel/AnalyticsPanel';
|
||||
import { HIGHLIGHTED_OPTIONS } from './config';
|
||||
import {
|
||||
// KEY_ATTRIBUTE_KEYS, // uncomment when key attributes section is re-enabled
|
||||
SpanDetailVariant,
|
||||
VISIBLE_ACTIONS,
|
||||
} from './constants';
|
||||
import { useSpanAttributeActions } from './hooks/useSpanAttributeActions';
|
||||
import {
|
||||
LinkedSpansPanel,
|
||||
LinkedSpansToggle,
|
||||
useLinkedSpans,
|
||||
} from './LinkedSpans/LinkedSpans';
|
||||
import SpanPercentileBadge from './SpanPercentile/SpanPercentileBadge';
|
||||
import SpanPercentilePanel from './SpanPercentile/SpanPercentilePanel';
|
||||
import useSpanPercentile from './SpanPercentile/useSpanPercentile';
|
||||
|
||||
import './SpanDetailsPanel.styles.scss';
|
||||
|
||||
interface SpanDetailsPanelProps {
|
||||
panelState: DetailsPanelState;
|
||||
selectedSpan: SpanV3 | undefined;
|
||||
variant?: SpanDetailVariant;
|
||||
onVariantChange?: (variant: SpanDetailVariant) => void;
|
||||
traceStartTime?: number;
|
||||
traceEndTime?: number;
|
||||
serviceExecTime?: Record<string, number>;
|
||||
}
|
||||
|
||||
function SpanDetailsContent({
|
||||
selectedSpan,
|
||||
traceStartTime,
|
||||
traceEndTime,
|
||||
}: {
|
||||
selectedSpan: SpanV3;
|
||||
traceStartTime?: number;
|
||||
traceEndTime?: number;
|
||||
}): JSX.Element {
|
||||
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
|
||||
const spanAttributeActions = useSpanAttributeActions();
|
||||
const percentile = useSpanPercentile(selectedSpan);
|
||||
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
|
||||
|
||||
// Map span attribute actions to PrettyView actions format.
|
||||
// Use the last key in fieldKeyPath (the actual attribute key), not the full display path.
|
||||
const prettyViewCustomActions = useMemo(
|
||||
() =>
|
||||
spanAttributeActions.map((action) => ({
|
||||
key: action.value,
|
||||
label: action.label,
|
||||
icon: action.icon,
|
||||
shouldHide: action.shouldHide,
|
||||
onClick: (context: {
|
||||
fieldKey: string;
|
||||
fieldKeyPath: (string | number)[];
|
||||
fieldValue: unknown;
|
||||
}): void => {
|
||||
const leafKey = getLeafKeyFromPath(context.fieldKeyPath, context.fieldKey);
|
||||
action.callback({
|
||||
key: leafKey,
|
||||
value: String(context.fieldValue),
|
||||
});
|
||||
},
|
||||
})),
|
||||
[spanAttributeActions],
|
||||
);
|
||||
|
||||
// const [, setCopy] = useCopyToClipboard();
|
||||
|
||||
// Key attributes section — commented out as pinning in PrettyView covers this use case.
|
||||
// Uncomment when key attributes need to be shown separately.
|
||||
// const buildKeyAttrMenu = useCallback(
|
||||
// (key: string, value: string): ActionMenuItem[] => {
|
||||
// const items: ActionMenuItem[] = [
|
||||
// {
|
||||
// key: 'copy-value',
|
||||
// label: 'Copy Value',
|
||||
// icon: <Copy size={12} />,
|
||||
// onClick: (): void => {
|
||||
// setCopy(value);
|
||||
// toast.success('Copied to clipboard', {
|
||||
// richColors: true,
|
||||
// position: 'top-right',
|
||||
// });
|
||||
// },
|
||||
// },
|
||||
// ];
|
||||
// spanAttributeActions.forEach((action) => {
|
||||
// if (action.shouldHide && action.shouldHide(key)) {
|
||||
// return;
|
||||
// }
|
||||
// items.push({
|
||||
// key: action.value,
|
||||
// label: action.label,
|
||||
// icon: action.icon,
|
||||
// onClick: (): void => {
|
||||
// action.callback({ key, value });
|
||||
// },
|
||||
// });
|
||||
// });
|
||||
// return items;
|
||||
// },
|
||||
// [spanAttributeActions, setCopy],
|
||||
// );
|
||||
|
||||
const {
|
||||
logs,
|
||||
isLoading: isLogsLoading,
|
||||
isError: isLogsError,
|
||||
isFetching: isLogsFetching,
|
||||
isLogSpanRelated,
|
||||
hasTraceIdLogs,
|
||||
} = useSpanContextLogs({
|
||||
traceId: selectedSpan.trace_id,
|
||||
spanId: selectedSpan.span_id,
|
||||
timeRange: {
|
||||
startTime: (traceStartTime || 0) - FIVE_MINUTES_IN_MS,
|
||||
endTime: (traceEndTime || 0) + FIVE_MINUTES_IN_MS,
|
||||
},
|
||||
isDrawerOpen: true,
|
||||
});
|
||||
|
||||
const infraMetadata = useMemo(() => {
|
||||
if (!hasInfraMetadata(selectedSpan)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
clusterName: getSpanAttribute(selectedSpan, 'k8s.cluster.name') || '',
|
||||
podName: getSpanAttribute(selectedSpan, 'k8s.pod.name') || '',
|
||||
nodeName: getSpanAttribute(selectedSpan, 'k8s.node.name') || '',
|
||||
hostName: getSpanAttribute(selectedSpan, 'host.name') || '',
|
||||
spanTimestamp: dayjs(selectedSpan.timestamp).format(),
|
||||
};
|
||||
}, [selectedSpan]);
|
||||
|
||||
const handleExplorerPageRedirect = useCallback((): void => {
|
||||
const startTimeMs = (traceStartTime || 0) - FIVE_MINUTES_IN_MS;
|
||||
const endTimeMs = (traceEndTime || 0) + FIVE_MINUTES_IN_MS;
|
||||
|
||||
const traceIdFilter = {
|
||||
op: 'AND',
|
||||
items: [
|
||||
{
|
||||
id: 'trace-id-filter',
|
||||
key: {
|
||||
key: 'trace_id',
|
||||
id: 'trace-id-key',
|
||||
dataType: 'string' as const,
|
||||
isColumn: true,
|
||||
type: '',
|
||||
isJSON: false,
|
||||
} as BaseAutocompleteData,
|
||||
op: '=',
|
||||
value: selectedSpan.trace_id,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const compositeQuery = {
|
||||
...initialQueryState,
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
...initialQueryState.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueryBuilderFormValuesMap.logs,
|
||||
aggregateOperator: LogsAggregatorOperator.NOOP,
|
||||
filters: traceIdFilter,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set(QueryParams.compositeQuery, JSON.stringify(compositeQuery));
|
||||
searchParams.set(QueryParams.startTime, startTimeMs.toString());
|
||||
searchParams.set(QueryParams.endTime, endTimeMs.toString());
|
||||
|
||||
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${searchParams.toString()}`);
|
||||
}, [selectedSpan.trace_id, traceStartTime, traceEndTime]);
|
||||
|
||||
const emptyLogsStateConfig = useMemo(
|
||||
() => ({
|
||||
...getEmptyLogsListConfig(() => {}),
|
||||
showClearFiltersButton: false,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// const keyAttributes = useMemo(() => {
|
||||
// const keys = KEY_ATTRIBUTE_KEYS.traces || [];
|
||||
// const allAttrs: Record<string, string> = {};
|
||||
// Object.entries(selectedSpan.resource || {}).forEach(([k, v]) => {
|
||||
// allAttrs[k] = String(v);
|
||||
// });
|
||||
// Object.entries(selectedSpan.attributes || {}).forEach(([k, v]) => {
|
||||
// allAttrs[k] = String(v);
|
||||
// });
|
||||
// const span = (selectedSpan as unknown) as Record<string, unknown>;
|
||||
// keys.forEach((key) => {
|
||||
// if (!(key in allAttrs) && span[key] != null && span[key] !== '') {
|
||||
// allAttrs[key] = String(span[key]);
|
||||
// }
|
||||
// });
|
||||
// return keys
|
||||
// .filter((key) => allAttrs[key])
|
||||
// .map((key) => ({ key, value: allAttrs[key] }));
|
||||
// }, [selectedSpan]);
|
||||
|
||||
return (
|
||||
<div className="span-details-panel__body">
|
||||
<div className="span-details-panel__details-section">
|
||||
<div className="span-details-panel__span-row">
|
||||
<KeyValueLabel
|
||||
badgeKey="Span name"
|
||||
badgeValue={selectedSpan.name}
|
||||
maxCharacters={50}
|
||||
/>
|
||||
<SpanPercentileBadge
|
||||
loading={percentile.loading}
|
||||
percentileValue={percentile.percentileValue}
|
||||
duration={percentile.duration}
|
||||
spanPercentileData={percentile.spanPercentileData}
|
||||
isOpen={percentile.isOpen}
|
||||
toggleOpen={percentile.toggleOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SpanPercentilePanel selectedSpan={selectedSpan} percentile={percentile} />
|
||||
|
||||
{/* Span info: exec time + start time */}
|
||||
<div className="span-details-panel__span-info">
|
||||
<div className="span-details-panel__span-info-item">
|
||||
<Timer size={14} />
|
||||
<span>
|
||||
{getYAxisFormattedValue(`${selectedSpan.duration_nano / 1000000}`, 'ms')}
|
||||
{traceStartTime && traceEndTime && traceEndTime > traceStartTime && (
|
||||
<>
|
||||
{' — '}
|
||||
<strong>
|
||||
{(
|
||||
(selectedSpan.duration_nano * 100) /
|
||||
((traceEndTime - traceStartTime) * 1e6)
|
||||
).toFixed(2)}
|
||||
%
|
||||
</strong>
|
||||
{' of total exec time'}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="span-details-panel__span-info-item">
|
||||
<CalendarClock size={14} />
|
||||
<span>
|
||||
{dayjs(selectedSpan.timestamp).format('HH:mm:ss — MMM D, YYYY')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="span-details-panel__span-info-item">
|
||||
<Link2 size={14} />
|
||||
<LinkedSpansToggle
|
||||
count={linkedSpans.count}
|
||||
isOpen={linkedSpans.isOpen}
|
||||
toggleOpen={linkedSpans.toggleOpen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LinkedSpansPanel
|
||||
linkedSpans={linkedSpans.linkedSpans}
|
||||
isOpen={linkedSpans.isOpen}
|
||||
/>
|
||||
|
||||
{/* Step 6: HighlightedOptions */}
|
||||
<div className="span-details-panel__highlighted-options">
|
||||
{HIGHLIGHTED_OPTIONS.map((option) => {
|
||||
const rendered = option.render(selectedSpan);
|
||||
if (!rendered) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<KeyValueLabel
|
||||
key={option.key}
|
||||
badgeKey={option.label}
|
||||
badgeValue={rendered}
|
||||
direction="column"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Step 7: KeyAttributes — commented out, pinning in PrettyView covers this.
|
||||
{keyAttributes.length > 0 && (
|
||||
<div className="span-details-panel__key-attributes">
|
||||
<div className="span-details-panel__key-attributes-label">
|
||||
KEY ATTRIBUTES
|
||||
</div>
|
||||
<div className="span-details-panel__key-attributes-chips">
|
||||
{keyAttributes.map(({ key, value }) => (
|
||||
<ActionMenu
|
||||
key={key}
|
||||
items={buildKeyAttrMenu(key, value)}
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<div>
|
||||
<KeyValueLabel badgeKey={key} badgeValue={value} />
|
||||
</div>
|
||||
</ActionMenu>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
*/}
|
||||
|
||||
{/* Step 8: MiniTraceContext */}
|
||||
</div>
|
||||
|
||||
<div className="span-details-panel__tabs-section">
|
||||
{/* Step 9: ContentTabs */}
|
||||
<TabsRoot defaultValue="overview">
|
||||
<TabsList variant="secondary">
|
||||
<TabsTrigger value="overview" variant="secondary">
|
||||
<Bookmark size={14} /> Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="events" variant="secondary">
|
||||
<ScrollText size={14} /> Events ({selectedSpan.events?.length || 0})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="logs" variant="secondary">
|
||||
<Logs size={14} /> Logs
|
||||
</TabsTrigger>
|
||||
{infraMetadata && (
|
||||
<TabsTrigger value="metrics" variant="secondary">
|
||||
<ChartColumnBig size={14} /> Metrics
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<div className="span-details-panel__tabs-scroll">
|
||||
<TabsContent value="overview">
|
||||
<DataViewer
|
||||
data={selectedSpan}
|
||||
drawerKey="trace-details"
|
||||
prettyViewProps={{
|
||||
showPinned: true,
|
||||
actions: prettyViewCustomActions,
|
||||
visibleActions: VISIBLE_ACTIONS,
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="events">
|
||||
{/* V2 Events component expects span.event (singular), V3 has span.events (plural) */}
|
||||
<Events
|
||||
span={{ ...selectedSpan, event: selectedSpan.events } as any}
|
||||
startTime={traceStartTime || 0}
|
||||
isSearchVisible
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="logs">
|
||||
<SpanLogs
|
||||
traceId={selectedSpan.trace_id}
|
||||
spanId={selectedSpan.span_id}
|
||||
timeRange={{
|
||||
startTime: (traceStartTime || 0) - FIVE_MINUTES_IN_MS,
|
||||
endTime: (traceEndTime || 0) + FIVE_MINUTES_IN_MS,
|
||||
}}
|
||||
logs={logs}
|
||||
isLoading={isLogsLoading}
|
||||
isError={isLogsError}
|
||||
isFetching={isLogsFetching}
|
||||
isLogSpanRelated={isLogSpanRelated}
|
||||
handleExplorerPageRedirect={handleExplorerPageRedirect}
|
||||
emptyStateConfig={!hasTraceIdLogs ? emptyLogsStateConfig : undefined}
|
||||
/>
|
||||
</TabsContent>
|
||||
{infraMetadata && (
|
||||
<TabsContent value="metrics">
|
||||
<InfraMetrics
|
||||
clusterName={infraMetadata.clusterName}
|
||||
podName={infraMetadata.podName}
|
||||
nodeName={infraMetadata.nodeName}
|
||||
hostName={infraMetadata.hostName}
|
||||
timestamp={infraMetadata.spanTimestamp}
|
||||
dataSource={DataSource.TRACES}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
</div>
|
||||
</TabsRoot>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpanDetailsPanel({
|
||||
panelState,
|
||||
selectedSpan,
|
||||
variant = SpanDetailVariant.DIALOG,
|
||||
onVariantChange,
|
||||
traceStartTime,
|
||||
traceEndTime,
|
||||
serviceExecTime,
|
||||
}: SpanDetailsPanelProps): JSX.Element {
|
||||
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
|
||||
|
||||
const headerActions = useMemo((): HeaderAction[] => {
|
||||
const actions: HeaderAction[] = [
|
||||
{
|
||||
key: 'analytics',
|
||||
component: (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
prefix={<ChartBar size={14} />}
|
||||
onClick={(): void => setIsAnalyticsOpen((prev) => !prev)}
|
||||
>
|
||||
Analytics
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
// TODO: Add back when driven through separate config for different pages
|
||||
// {
|
||||
// key: 'view-full-trace',
|
||||
// component: (
|
||||
// <Button variant="ghost" size="sm" color="secondary" prefixIcon={<ExternalLink size={14} />} onClick={noop}>
|
||||
// View full trace
|
||||
// </Button>
|
||||
// ),
|
||||
// },
|
||||
// TODO: Add back when used in trace explorer page
|
||||
// {
|
||||
// key: 'nav',
|
||||
// component: (
|
||||
// <div className="span-details-panel__header-nav">
|
||||
// <Button variant="ghost" size="icon" color="secondary" onClick={noop}><ChevronUp size={14} /></Button>
|
||||
// <Button variant="ghost" size="icon" color="secondary" onClick={noop}><ChevronDown size={14} /></Button>
|
||||
// </div>
|
||||
// ),
|
||||
// },
|
||||
];
|
||||
|
||||
if (onVariantChange) {
|
||||
const isDocked = variant === SpanDetailVariant.DOCKED;
|
||||
actions.push({
|
||||
key: 'dock-toggle',
|
||||
component: (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={(): void =>
|
||||
onVariantChange(
|
||||
isDocked ? SpanDetailVariant.DIALOG : SpanDetailVariant.DOCKED,
|
||||
)
|
||||
}
|
||||
>
|
||||
{isDocked ? <Dock size={14} /> : <PanelBottom size={14} />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="dock-toggle-tooltip">
|
||||
{isDocked ? 'Open as floating panel' : 'Dock at the bottom'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}, [variant, onVariantChange]);
|
||||
|
||||
const PANEL_WIDTH = 500;
|
||||
const PANEL_MARGIN_RIGHT = 20;
|
||||
const PANEL_MARGIN_TOP = 25;
|
||||
const PANEL_MARGIN_BOTTOM = 25;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<DetailsHeader
|
||||
title="Span details"
|
||||
onClose={panelState.close}
|
||||
actions={headerActions}
|
||||
className={
|
||||
variant === SpanDetailVariant.DIALOG ? 'floating-panel__drag-handle' : ''
|
||||
}
|
||||
/>
|
||||
{selectedSpan ? (
|
||||
<SpanDetailsContent
|
||||
selectedSpan={selectedSpan}
|
||||
traceStartTime={traceStartTime}
|
||||
traceEndTime={traceEndTime}
|
||||
/>
|
||||
) : (
|
||||
<div className="span-details-panel__body">
|
||||
<Skeleton active paragraph={{ rows: 6 }} title={{ width: '60%' }} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const analyticsPanel = (
|
||||
<AnalyticsPanel
|
||||
isOpen={isAnalyticsOpen}
|
||||
onClose={(): void => setIsAnalyticsOpen(false)}
|
||||
serviceExecTime={serviceExecTime}
|
||||
traceStartTime={traceStartTime}
|
||||
traceEndTime={traceEndTime}
|
||||
/>
|
||||
);
|
||||
|
||||
if (variant === SpanDetailVariant.DOCKED) {
|
||||
return (
|
||||
<>
|
||||
<div className="span-details-panel">{content}</div>
|
||||
{analyticsPanel}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === SpanDetailVariant.DRAWER) {
|
||||
return (
|
||||
<>
|
||||
<DetailsPanelDrawer
|
||||
isOpen={panelState.isOpen}
|
||||
onClose={panelState.close}
|
||||
className="span-details-panel"
|
||||
>
|
||||
{content}
|
||||
</DetailsPanelDrawer>
|
||||
{analyticsPanel}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FloatingPanel
|
||||
isOpen={panelState.isOpen}
|
||||
className="span-details-panel"
|
||||
width={PANEL_WIDTH}
|
||||
height={window.innerHeight - PANEL_MARGIN_TOP - PANEL_MARGIN_BOTTOM}
|
||||
defaultPosition={{
|
||||
x: window.innerWidth - PANEL_WIDTH - PANEL_MARGIN_RIGHT,
|
||||
y: PANEL_MARGIN_TOP,
|
||||
}}
|
||||
enableResizing={{
|
||||
top: true,
|
||||
right: true,
|
||||
bottom: true,
|
||||
left: true,
|
||||
topRight: false,
|
||||
bottomRight: false,
|
||||
bottomLeft: false,
|
||||
topLeft: false,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</FloatingPanel>
|
||||
{analyticsPanel}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanDetailsPanel;
|
||||
@@ -1,258 +0,0 @@
|
||||
// Badge — wraps a KeyValueLabel, clickable to toggle panel
|
||||
.span-percentile-badge {
|
||||
cursor: pointer;
|
||||
|
||||
// Override key color for the percentile value (p99)
|
||||
.key-value-label__key {
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
&__loader {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 4px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
&__value {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
// Panel — collapsible, renders below the row
|
||||
.span-percentile-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
filter: drop-shadow(2px 4px 16px rgba(0, 0, 0, 0.2));
|
||||
backdrop-filter: blur(20px);
|
||||
margin: 8px 16px;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
&-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&-icon {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
|
||||
&-title {
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-20);
|
||||
}
|
||||
|
||||
&-highlight {
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
&-loader {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
margin: 0 4px;
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
&__timerange {
|
||||
width: 100%;
|
||||
|
||||
&-select {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.ant-select-selector {
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
color: var(--l1-foreground);
|
||||
font-size: 12px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__table {
|
||||
&-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
&-text {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
&-rows {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&-skeleton {
|
||||
.ant-skeleton-title {
|
||||
width: 100% !important;
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.ant-skeleton-paragraph {
|
||||
margin-top: 8px;
|
||||
|
||||
& > li + li {
|
||||
margin-top: 10px;
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0 4px;
|
||||
|
||||
&-key {
|
||||
flex: 0 0 auto;
|
||||
color: var(--l1-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&-value {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&-dash {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
margin: 0 8px;
|
||||
border-top: 1px solid transparent;
|
||||
border-image: repeating-linear-gradient(
|
||||
to right,
|
||||
var(--l1-border) 0,
|
||||
var(--l1-border) 10px,
|
||||
transparent 10px,
|
||||
transparent 20px
|
||||
)
|
||||
1 stretch;
|
||||
}
|
||||
|
||||
&--current {
|
||||
border-radius: 2px;
|
||||
background: rgba(78, 116, 248, 0.2);
|
||||
|
||||
.span-percentile-panel__table-row-key {
|
||||
color: var(--text-robin-300);
|
||||
}
|
||||
|
||||
.span-percentile-panel__table-row-dash {
|
||||
border-image: repeating-linear-gradient(
|
||||
to right,
|
||||
#abbdff 0,
|
||||
#abbdff 10px,
|
||||
transparent 10px,
|
||||
transparent 20px
|
||||
)
|
||||
1 stretch;
|
||||
}
|
||||
|
||||
.span-percentile-panel__table-row-value {
|
||||
color: var(--text-robin-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__resource-selector {
|
||||
overflow: hidden;
|
||||
width: calc(100% + 16px);
|
||||
position: absolute;
|
||||
top: 32px;
|
||||
left: -8px;
|
||||
z-index: 1000;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
&-header {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
&-input {
|
||||
border-radius: 0;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
&-items {
|
||||
height: 200px;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
height: 0.3rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
|
||||
&-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
|
||||
&-value {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { ChevronDown, ChevronUp, Loader2 } from 'lucide-react';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
|
||||
import { UseSpanPercentileReturn } from './useSpanPercentile';
|
||||
|
||||
import './SpanPercentile.styles.scss';
|
||||
|
||||
type SpanPercentileBadgeProps = Pick<
|
||||
UseSpanPercentileReturn,
|
||||
| 'loading'
|
||||
| 'percentileValue'
|
||||
| 'duration'
|
||||
| 'spanPercentileData'
|
||||
| 'isOpen'
|
||||
| 'toggleOpen'
|
||||
>;
|
||||
|
||||
function SpanPercentileBadge({
|
||||
loading,
|
||||
percentileValue,
|
||||
duration,
|
||||
spanPercentileData,
|
||||
isOpen,
|
||||
toggleOpen,
|
||||
}: SpanPercentileBadgeProps): JSX.Element | null {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="span-percentile-badge__loader">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!spanPercentileData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="span-percentile-badge"
|
||||
onClick={toggleOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
toggleOpen();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<KeyValueLabel
|
||||
badgeKey={`p${percentileValue}`}
|
||||
badgeValue={
|
||||
<span className="span-percentile-badge__value">
|
||||
{duration}
|
||||
{isOpen ? (
|
||||
<ChevronUp size={14} className="span-percentile-badge__icon" />
|
||||
) : (
|
||||
<ChevronDown size={14} className="span-percentile-badge__icon" />
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanPercentileBadge;
|
||||
@@ -1,224 +0,0 @@
|
||||
import { Checkbox, Input, Select, Skeleton, Typography } from 'antd';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { Check, ChevronDown, Loader2, PlusIcon } from 'lucide-react';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { UseSpanPercentileReturn } from './useSpanPercentile';
|
||||
|
||||
import './SpanPercentile.styles.scss';
|
||||
|
||||
const DEFAULT_RESOURCE_ATTRIBUTES = {
|
||||
serviceName: 'service.name',
|
||||
name: 'name',
|
||||
};
|
||||
|
||||
const timerangeOptions = [1, 2, 4, 6, 12, 24].map((hours) => ({
|
||||
label: `${hours}h`,
|
||||
value: hours,
|
||||
}));
|
||||
|
||||
interface SpanPercentilePanelProps {
|
||||
selectedSpan: SpanV3;
|
||||
percentile: UseSpanPercentileReturn;
|
||||
}
|
||||
|
||||
function SpanPercentilePanel({
|
||||
selectedSpan,
|
||||
percentile,
|
||||
}: SpanPercentilePanelProps): JSX.Element | null {
|
||||
const {
|
||||
isOpen,
|
||||
toggleOpen,
|
||||
isError,
|
||||
loading,
|
||||
spanPercentileData,
|
||||
selectedTimeRange,
|
||||
setSelectedTimeRange,
|
||||
showResourceAttributesSelector,
|
||||
setShowResourceAttributesSelector,
|
||||
resourceAttributesSearchQuery,
|
||||
setResourceAttributesSearchQuery,
|
||||
spanResourceAttributes,
|
||||
handleResourceAttributeChange,
|
||||
resourceAttributesSelectorRef,
|
||||
isLoadingData,
|
||||
isFetchingData,
|
||||
} = percentile;
|
||||
|
||||
if (!isOpen || isError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="span-percentile-panel">
|
||||
<div className="span-percentile-panel__header">
|
||||
<Typography.Text
|
||||
className="span-percentile-panel__header-text"
|
||||
onClick={toggleOpen}
|
||||
>
|
||||
<ChevronDown size={16} /> Span Percentile
|
||||
</Typography.Text>
|
||||
|
||||
{showResourceAttributesSelector ? (
|
||||
<Check
|
||||
size={16}
|
||||
className="cursor-pointer span-percentile-panel__header-icon"
|
||||
onClick={(): void => setShowResourceAttributesSelector(false)}
|
||||
/>
|
||||
) : (
|
||||
<PlusIcon
|
||||
size={16}
|
||||
className="cursor-pointer span-percentile-panel__header-icon"
|
||||
onClick={(): void => setShowResourceAttributesSelector(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showResourceAttributesSelector && (
|
||||
<div
|
||||
className="span-percentile-panel__resource-selector"
|
||||
ref={resourceAttributesSelectorRef}
|
||||
>
|
||||
<div className="span-percentile-panel__resource-selector-header">
|
||||
<Input
|
||||
placeholder="Search resource attributes"
|
||||
className="span-percentile-panel__resource-selector-input"
|
||||
value={resourceAttributesSearchQuery}
|
||||
onChange={(e): void =>
|
||||
setResourceAttributesSearchQuery(e.target.value as string)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="span-percentile-panel__resource-selector-items">
|
||||
{spanResourceAttributes
|
||||
.filter((attr) =>
|
||||
attr.key
|
||||
.toLowerCase()
|
||||
.includes(resourceAttributesSearchQuery.toLowerCase()),
|
||||
)
|
||||
.map((attr) => (
|
||||
<div
|
||||
className="span-percentile-panel__resource-selector-item"
|
||||
key={attr.key}
|
||||
>
|
||||
<Checkbox
|
||||
checked={attr.isSelected}
|
||||
onChange={(e): void => {
|
||||
handleResourceAttributeChange(
|
||||
attr.key,
|
||||
attr.value,
|
||||
e.target.checked,
|
||||
);
|
||||
}}
|
||||
disabled={
|
||||
attr.key === DEFAULT_RESOURCE_ATTRIBUTES.serviceName ||
|
||||
attr.key === DEFAULT_RESOURCE_ATTRIBUTES.name
|
||||
}
|
||||
>
|
||||
<div className="span-percentile-panel__resource-selector-item-value">
|
||||
{attr.key}
|
||||
</div>
|
||||
</Checkbox>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="span-percentile-panel__content">
|
||||
<Typography.Text className="span-percentile-panel__content-title">
|
||||
This span duration is{' '}
|
||||
{!loading && spanPercentileData ? (
|
||||
<span className="span-percentile-panel__content-highlight">
|
||||
p{Math.floor(spanPercentileData.percentile || 0)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="span-percentile-panel__content-loader">
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
</span>
|
||||
)}{' '}
|
||||
out of the distribution for this resource evaluated for {selectedTimeRange}{' '}
|
||||
hour(s) since the span start time.
|
||||
</Typography.Text>
|
||||
|
||||
<div className="span-percentile-panel__timerange">
|
||||
<Select
|
||||
labelInValue
|
||||
placeholder="Select timerange"
|
||||
className="span-percentile-panel__timerange-select"
|
||||
getPopupContainer={(trigger): HTMLElement =>
|
||||
trigger.parentElement || document.body
|
||||
}
|
||||
value={{
|
||||
label: `${selectedTimeRange}h : ${dayjs(selectedSpan.timestamp)
|
||||
.subtract(selectedTimeRange, 'hour')
|
||||
.format(DATE_TIME_FORMATS.TIME_SPAN_PERCENTILE)} - ${dayjs(
|
||||
selectedSpan.timestamp,
|
||||
).format(DATE_TIME_FORMATS.TIME_SPAN_PERCENTILE)}`,
|
||||
value: selectedTimeRange,
|
||||
}}
|
||||
onChange={(value): void => {
|
||||
setSelectedTimeRange(Number(value.value));
|
||||
}}
|
||||
options={timerangeOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="span-percentile-panel__table">
|
||||
<div className="span-percentile-panel__table-header">
|
||||
<Typography.Text className="span-percentile-panel__table-header-text">
|
||||
Percentile
|
||||
</Typography.Text>
|
||||
<Typography.Text className="span-percentile-panel__table-header-text">
|
||||
Duration
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className="span-percentile-panel__table-rows">
|
||||
{isLoadingData || isFetchingData ? (
|
||||
<Skeleton
|
||||
active
|
||||
paragraph={{ rows: 3 }}
|
||||
className="span-percentile-panel__table-skeleton"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{Object.entries(spanPercentileData?.percentiles || {}).map(
|
||||
([pKey, pDuration]) => (
|
||||
<div className="span-percentile-panel__table-row" key={pKey}>
|
||||
<Typography.Text className="span-percentile-panel__table-row-key">
|
||||
{pKey}
|
||||
</Typography.Text>
|
||||
<div className="span-percentile-panel__table-row-dash" />
|
||||
<Typography.Text className="span-percentile-panel__table-row-value">
|
||||
{getYAxisFormattedValue(`${pDuration / 1000000}`, 'ms')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
|
||||
<div className="span-percentile-panel__table-row span-percentile-panel__table-row--current">
|
||||
<Typography.Text className="span-percentile-panel__table-row-key">
|
||||
p{Math.floor(spanPercentileData?.percentile || 0)}
|
||||
</Typography.Text>
|
||||
<div className="span-percentile-panel__table-row-dash" />
|
||||
<Typography.Text className="span-percentile-panel__table-row-value">
|
||||
(this span){' '}
|
||||
{getYAxisFormattedValue(
|
||||
`${selectedSpan.duration_nano / 1000000}`,
|
||||
'ms',
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanPercentilePanel;
|
||||
@@ -1,326 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import getSpanPercentiles from 'api/trace/getSpanPercentiles';
|
||||
import getUserPreference from 'api/v1/user/preferences/name/get';
|
||||
import updateUserPreference from 'api/v1/user/preferences/name/update';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import dayjs from 'dayjs';
|
||||
import useClickOutside from 'hooks/useClickOutside';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
export interface IResourceAttribute {
|
||||
key: string;
|
||||
value: string;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_RESOURCE_ATTRIBUTES = {
|
||||
serviceName: 'service.name',
|
||||
name: 'name',
|
||||
};
|
||||
|
||||
export interface UseSpanPercentileReturn {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
toggleOpen: () => void;
|
||||
loading: boolean;
|
||||
percentileValue: number;
|
||||
duration: string;
|
||||
spanPercentileData: {
|
||||
percentile: number;
|
||||
description: string;
|
||||
percentiles: Record<string, number>;
|
||||
} | null;
|
||||
isError: boolean;
|
||||
selectedTimeRange: number;
|
||||
setSelectedTimeRange: (range: number) => void;
|
||||
showResourceAttributesSelector: boolean;
|
||||
setShowResourceAttributesSelector: (show: boolean) => void;
|
||||
resourceAttributesSearchQuery: string;
|
||||
setResourceAttributesSearchQuery: (query: string) => void;
|
||||
spanResourceAttributes: IResourceAttribute[];
|
||||
handleResourceAttributeChange: (
|
||||
key: string,
|
||||
value: string,
|
||||
isSelected: boolean,
|
||||
) => void;
|
||||
resourceAttributesSelectorRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
isLoadingData: boolean;
|
||||
isFetchingData: boolean;
|
||||
}
|
||||
|
||||
function useSpanPercentile(selectedSpan: SpanV3): UseSpanPercentileReturn {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedTimeRange, setSelectedTimeRange] = useState(1);
|
||||
const [resourceAttributesSearchQuery, setResourceAttributesSearchQuery] =
|
||||
useState('');
|
||||
const [spanPercentileData, setSpanPercentileData] = useState<{
|
||||
percentile: number;
|
||||
description: string;
|
||||
percentiles: Record<string, number>;
|
||||
} | null>(null);
|
||||
const [showResourceAttributesSelector, setShowResourceAttributesSelector] =
|
||||
useState(false);
|
||||
const [selectedResourceAttributes, setSelectedResourceAttributes] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [spanResourceAttributes, updateSpanResourceAttributes] = useState<
|
||||
IResourceAttribute[]
|
||||
>([]);
|
||||
const [initialWaitCompleted, setInitialWaitCompleted] = useState(false);
|
||||
const [shouldFetchData, setShouldFetchData] = useState(false);
|
||||
const [shouldUpdateUserPreference, setShouldUpdateUserPreference] =
|
||||
useState(false);
|
||||
|
||||
const resourceAttributesSelectorRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useClickOutside({
|
||||
ref: resourceAttributesSelectorRef,
|
||||
onClickOutside: () => {
|
||||
if (resourceAttributesSelectorRef.current) {
|
||||
setShowResourceAttributesSelector(false);
|
||||
}
|
||||
},
|
||||
eventType: 'mousedown',
|
||||
});
|
||||
|
||||
const endTime = useMemo(
|
||||
() => Math.floor(Number(selectedSpan.timestamp) / 1000) * 1000,
|
||||
[selectedSpan.timestamp],
|
||||
);
|
||||
|
||||
const startTime = useMemo(
|
||||
() =>
|
||||
dayjs(selectedSpan.timestamp)
|
||||
.subtract(Number(selectedTimeRange), 'hour')
|
||||
.unix() * 1000,
|
||||
[selectedSpan.timestamp, selectedTimeRange],
|
||||
);
|
||||
|
||||
const { mutate: updateUserPreferenceMutation } =
|
||||
useMutation(updateUserPreference);
|
||||
|
||||
const {
|
||||
data: userSelectedResourceAttributes,
|
||||
isError: isErrorUserSelectedResourceAttributes,
|
||||
} = useQuery({
|
||||
queryFn: () =>
|
||||
getUserPreference({
|
||||
name: USER_PREFERENCES.SPAN_PERCENTILE_RESOURCE_ATTRIBUTES,
|
||||
}),
|
||||
queryKey: [
|
||||
'getUserPreferenceByPreferenceName',
|
||||
USER_PREFERENCES.SPAN_PERCENTILE_RESOURCE_ATTRIBUTES,
|
||||
selectedSpan.span_id,
|
||||
],
|
||||
enabled: selectedSpan.attributes !== undefined,
|
||||
});
|
||||
|
||||
const {
|
||||
isLoading: isLoadingData,
|
||||
isFetching: isFetchingData,
|
||||
data,
|
||||
refetch: refetchData,
|
||||
isError: isErrorData,
|
||||
} = useQuery({
|
||||
queryFn: () =>
|
||||
getSpanPercentiles({
|
||||
start: startTime || 0,
|
||||
end: endTime || 0,
|
||||
spanDuration: selectedSpan.duration_nano || 0,
|
||||
serviceName: selectedSpan['service.name'] || '',
|
||||
name: selectedSpan.name || '',
|
||||
resourceAttributes: selectedResourceAttributes,
|
||||
}),
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_SPAN_PERCENTILES,
|
||||
selectedSpan.span_id,
|
||||
startTime,
|
||||
endTime,
|
||||
],
|
||||
enabled:
|
||||
shouldFetchData && !showResourceAttributesSelector && initialWaitCompleted,
|
||||
onSuccess: (response) => {
|
||||
if (response.httpStatusCode !== 200) {
|
||||
return;
|
||||
}
|
||||
if (shouldUpdateUserPreference) {
|
||||
updateUserPreferenceMutation({
|
||||
name: USER_PREFERENCES.SPAN_PERCENTILE_RESOURCE_ATTRIBUTES,
|
||||
value: [...Object.keys(selectedResourceAttributes)],
|
||||
});
|
||||
setShouldUpdateUserPreference(false);
|
||||
}
|
||||
},
|
||||
keepPreviousData: false,
|
||||
cacheTime: 0,
|
||||
});
|
||||
|
||||
// 2-second delay before initial fetch
|
||||
useEffect(() => {
|
||||
setSpanPercentileData(null);
|
||||
setIsOpen(false);
|
||||
setInitialWaitCompleted(false);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setInitialWaitCompleted(true);
|
||||
}, 2000);
|
||||
|
||||
return (): void => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [selectedSpan.span_id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.httpStatusCode !== 200) {
|
||||
setSpanPercentileData(null);
|
||||
return;
|
||||
}
|
||||
if (data) {
|
||||
setSpanPercentileData({
|
||||
percentile: data.data?.position?.percentile || 0,
|
||||
description: data.data?.position?.description || '',
|
||||
percentiles: data.data?.percentiles || {},
|
||||
});
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// Merge resource + attributes to get all span attributes (equivalent to V2 tagMap).
|
||||
// Stringify all values since the backend expects map[string]string.
|
||||
const allSpanAttributes = useMemo(() => {
|
||||
const merged: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(selectedSpan.resource || {})) {
|
||||
merged[k] = String(v);
|
||||
}
|
||||
for (const [k, v] of Object.entries(selectedSpan.attributes || {})) {
|
||||
merged[k] = String(v);
|
||||
}
|
||||
return merged;
|
||||
}, [selectedSpan.resource, selectedSpan.attributes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userSelectedResourceAttributes) {
|
||||
const userList = (
|
||||
userSelectedResourceAttributes?.data?.value as string[]
|
||||
).map((attr: string) => attr);
|
||||
let selectedMap: Record<string, string> = {};
|
||||
userList.forEach((attr: string) => {
|
||||
selectedMap[attr] = allSpanAttributes[attr] || '';
|
||||
});
|
||||
selectedMap = Object.fromEntries(
|
||||
Object.entries(selectedMap).filter(
|
||||
([key]) => allSpanAttributes[key] !== undefined,
|
||||
),
|
||||
);
|
||||
|
||||
const resourceAttrs = Object.entries(allSpanAttributes).map(
|
||||
([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
isSelected:
|
||||
key === DEFAULT_RESOURCE_ATTRIBUTES.serviceName ||
|
||||
key === DEFAULT_RESOURCE_ATTRIBUTES.name ||
|
||||
(key in selectedMap &&
|
||||
selectedMap[key] !== '' &&
|
||||
selectedMap[key] !== undefined),
|
||||
}),
|
||||
);
|
||||
|
||||
const selected = resourceAttrs.filter((a) => a.isSelected);
|
||||
const unselected = resourceAttrs.filter((a) => !a.isSelected);
|
||||
updateSpanResourceAttributes([...selected, ...unselected]);
|
||||
setSelectedResourceAttributes(selectedMap);
|
||||
setShouldFetchData(true);
|
||||
}
|
||||
|
||||
if (isErrorUserSelectedResourceAttributes) {
|
||||
const resourceAttrs = Object.entries(allSpanAttributes).map(
|
||||
([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
isSelected:
|
||||
key === DEFAULT_RESOURCE_ATTRIBUTES.serviceName ||
|
||||
key === DEFAULT_RESOURCE_ATTRIBUTES.name,
|
||||
}),
|
||||
);
|
||||
updateSpanResourceAttributes(resourceAttrs);
|
||||
setShouldFetchData(true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
userSelectedResourceAttributes,
|
||||
isErrorUserSelectedResourceAttributes,
|
||||
allSpanAttributes,
|
||||
]);
|
||||
|
||||
const handleResourceAttributeChange = useCallback(
|
||||
(key: string, value: string, isSelected: boolean): void => {
|
||||
updateSpanResourceAttributes((prev) =>
|
||||
prev.map((attr) => (attr.key === key ? { ...attr, isSelected } : attr)),
|
||||
);
|
||||
|
||||
const newSelected = { ...selectedResourceAttributes };
|
||||
if (isSelected) {
|
||||
newSelected[key] = value;
|
||||
} else {
|
||||
delete newSelected[key];
|
||||
}
|
||||
setSelectedResourceAttributes(newSelected);
|
||||
setShouldFetchData(true);
|
||||
setShouldUpdateUserPreference(true);
|
||||
},
|
||||
[selectedResourceAttributes],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
shouldFetchData &&
|
||||
!showResourceAttributesSelector &&
|
||||
initialWaitCompleted
|
||||
) {
|
||||
refetchData();
|
||||
setShouldFetchData(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [shouldFetchData, showResourceAttributesSelector, initialWaitCompleted]);
|
||||
|
||||
const loading = isLoadingData || isFetchingData;
|
||||
const percentileValue = Math.floor(spanPercentileData?.percentile || 0);
|
||||
const duration = getYAxisFormattedValue(
|
||||
`${selectedSpan.duration_nano / 1000000}`,
|
||||
'ms',
|
||||
);
|
||||
|
||||
const toggleOpen = useCallback(() => setIsOpen((prev) => !prev), []);
|
||||
|
||||
const handleTimeRangeChange = useCallback((range: number): void => {
|
||||
setShouldFetchData(true);
|
||||
setSelectedTimeRange(range);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
toggleOpen,
|
||||
loading,
|
||||
percentileValue,
|
||||
duration,
|
||||
spanPercentileData,
|
||||
isError: isErrorData,
|
||||
selectedTimeRange,
|
||||
setSelectedTimeRange: handleTimeRangeChange,
|
||||
showResourceAttributesSelector,
|
||||
setShowResourceAttributesSelector,
|
||||
resourceAttributesSearchQuery,
|
||||
setResourceAttributesSearchQuery,
|
||||
spanResourceAttributes,
|
||||
handleResourceAttributeChange,
|
||||
resourceAttributesSelectorRef,
|
||||
isLoadingData,
|
||||
isFetchingData,
|
||||
};
|
||||
}
|
||||
|
||||
export default useSpanPercentile;
|
||||
@@ -1,54 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Badge } from '@signozhq/ui';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
interface HighlightedOption {
|
||||
key: string;
|
||||
label: string;
|
||||
render: (span: SpanV3) => ReactNode | null;
|
||||
}
|
||||
|
||||
export const HIGHLIGHTED_OPTIONS: HighlightedOption[] = [
|
||||
{
|
||||
key: 'service',
|
||||
label: 'SERVICE',
|
||||
render: (span): ReactNode | null =>
|
||||
span['service.name'] ? (
|
||||
<Badge color="vanilla">
|
||||
<span className="span-details-panel__service-dot" />
|
||||
{span['service.name']}
|
||||
</Badge>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
key: 'statusCodeString',
|
||||
label: 'STATUS CODE STRING',
|
||||
render: (span): ReactNode | null =>
|
||||
span.status_code_string ? (
|
||||
<Badge color="vanilla">{span.status_code_string}</Badge>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
key: 'traceId',
|
||||
label: 'TRACE ID',
|
||||
render: (span): ReactNode | null =>
|
||||
span.trace_id ? (
|
||||
<Link
|
||||
to={{
|
||||
pathname: `/trace/${span.trace_id}`,
|
||||
search: window.location.search,
|
||||
}}
|
||||
className="span-details-panel__trace-id"
|
||||
>
|
||||
{span.trace_id}
|
||||
</Link>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
key: 'spanKind',
|
||||
label: 'SPAN KIND',
|
||||
render: (span): ReactNode | null =>
|
||||
span.kind_string ? <Badge color="vanilla">{span.kind_string}</Badge> : null,
|
||||
},
|
||||
];
|
||||
@@ -1,45 +0,0 @@
|
||||
import { SPAN_ACTION } from './hooks/useSpanAttributeActions';
|
||||
|
||||
// Action identifiers for built-in PrettyView actions (copy, pin)
|
||||
export const PRETTY_VIEW_ACTION = {
|
||||
COPY: 'copy',
|
||||
PIN: 'pin',
|
||||
} as const;
|
||||
|
||||
// Which actions are visible per node type — drives the entire menu
|
||||
export const VISIBLE_ACTIONS = {
|
||||
leaf: [
|
||||
PRETTY_VIEW_ACTION.COPY,
|
||||
PRETTY_VIEW_ACTION.PIN,
|
||||
SPAN_ACTION.FILTER_IN,
|
||||
SPAN_ACTION.FILTER_OUT,
|
||||
SPAN_ACTION.GROUP_BY,
|
||||
],
|
||||
nested: [PRETTY_VIEW_ACTION.COPY],
|
||||
} as const;
|
||||
|
||||
export enum SpanDetailVariant {
|
||||
DRAWER = 'drawer',
|
||||
DIALOG = 'dialog',
|
||||
DOCKED = 'docked',
|
||||
}
|
||||
|
||||
export const KEY_ATTRIBUTE_KEYS: Record<string, string[]> = {
|
||||
traces: [
|
||||
'service.name',
|
||||
'service.namespace',
|
||||
'deployment.environment',
|
||||
'timestamp',
|
||||
'duration_nano',
|
||||
'kind_string',
|
||||
'status_code_string',
|
||||
'http_method',
|
||||
'http_url',
|
||||
'http_host',
|
||||
'db_name',
|
||||
'db_operation',
|
||||
'external_http_method',
|
||||
'external_http_url',
|
||||
'response_status_code',
|
||||
],
|
||||
};
|
||||
@@ -1,208 +0,0 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { toast } from '@signozhq/ui';
|
||||
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
|
||||
import GroupByIcon from 'assets/CustomIcons/GroupByIcon';
|
||||
import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBuilderV2/utils';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { PANEL_TYPES, QueryBuilderKeys } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
|
||||
import { ArrowDownToDot, ArrowUpFromDot } from 'lucide-react';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
export interface SpanAttributeAction {
|
||||
label: string;
|
||||
value: string;
|
||||
icon?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
hidden?: boolean;
|
||||
callback: (args: { key: string; value: string; dataType?: string }) => void;
|
||||
/** Returns true if this action should be hidden for the given field key */
|
||||
shouldHide: (key: string) => boolean;
|
||||
}
|
||||
|
||||
// Keys that should NOT support filter/group-by actions.
|
||||
// These are system/internal/computed fields, not actual queryable attributes.
|
||||
export const NON_FILTERABLE_KEYS = new Set([
|
||||
'datetime',
|
||||
'duration',
|
||||
'parent_span_id',
|
||||
'has_children',
|
||||
'has_sibling',
|
||||
'sub_tree_node_count',
|
||||
'flags',
|
||||
'trace_state',
|
||||
'timestamp',
|
||||
]);
|
||||
|
||||
const shouldHideForKey = (key: string): boolean => NON_FILTERABLE_KEYS.has(key);
|
||||
|
||||
// Action identifiers
|
||||
export const SPAN_ACTION = {
|
||||
FILTER_IN: 'filter-in',
|
||||
FILTER_OUT: 'filter-out',
|
||||
GROUP_BY: 'group-by',
|
||||
} as const;
|
||||
|
||||
export function useSpanAttributeActions(): SpanAttributeAction[] {
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const getAutocompleteKey = useCallback(
|
||||
async (fieldKey: string): Promise<BaseAutocompleteData> => {
|
||||
const response = await queryClient.fetchQuery(
|
||||
[QueryBuilderKeys.GET_AGGREGATE_KEYS, fieldKey],
|
||||
async () =>
|
||||
getAggregateKeys({
|
||||
searchText: fieldKey,
|
||||
aggregateOperator:
|
||||
currentQuery.builder.queryData[0].aggregateOperator || '',
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateAttribute:
|
||||
currentQuery.builder.queryData[0].aggregateAttribute?.key || '',
|
||||
}),
|
||||
);
|
||||
|
||||
return chooseAutocompleteFromCustomValue(
|
||||
response.payload?.attributeKeys || [],
|
||||
fieldKey,
|
||||
DataTypes.String,
|
||||
);
|
||||
},
|
||||
[queryClient, currentQuery.builder.queryData],
|
||||
);
|
||||
|
||||
const handleFilter = useCallback(
|
||||
async (
|
||||
{ key, value }: { key: string; value: string },
|
||||
operator: string,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const autocompleteKey = await getAutocompleteKey(key);
|
||||
const resolvedOperator = getOperatorValue(operator);
|
||||
|
||||
const nextQuery: Query = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item) => {
|
||||
const cleanedFilters = (item.filters?.items || []).filter(
|
||||
(f) => f.key?.key !== autocompleteKey.key,
|
||||
);
|
||||
const newFilters = [
|
||||
...cleanedFilters,
|
||||
{
|
||||
id: uuid(),
|
||||
key: autocompleteKey,
|
||||
op: resolvedOperator,
|
||||
value,
|
||||
},
|
||||
];
|
||||
const converted = convertFiltersToExpressionWithExistingQuery(
|
||||
{ items: newFilters, op: item.filters?.op || 'AND' },
|
||||
item.filter?.expression || '',
|
||||
);
|
||||
return {
|
||||
...item,
|
||||
dataSource: DataSource.TRACES,
|
||||
filters: converted.filters,
|
||||
filter: converted.filter,
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
redirectWithQueryBuilderData(
|
||||
nextQuery,
|
||||
{ panelTypes: PANEL_TYPES.LIST },
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
);
|
||||
} catch {
|
||||
toast.error(SOMETHING_WENT_WRONG, { position: 'top-right' });
|
||||
}
|
||||
},
|
||||
[currentQuery, getAutocompleteKey, redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
const handleFilterIn = useCallback(
|
||||
(args: { key: string; value: string }): void => {
|
||||
handleFilter(args, '=');
|
||||
},
|
||||
[handleFilter],
|
||||
);
|
||||
|
||||
const handleFilterOut = useCallback(
|
||||
(args: { key: string; value: string }): void => {
|
||||
handleFilter(args, '!=');
|
||||
},
|
||||
[handleFilter],
|
||||
);
|
||||
|
||||
const handleGroupBy = useCallback(
|
||||
async ({ key }: { key: string }): Promise<void> => {
|
||||
try {
|
||||
const autocompleteKey = await getAutocompleteKey(key);
|
||||
|
||||
const nextQuery: Query = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item) => ({
|
||||
...item,
|
||||
dataSource: DataSource.TRACES,
|
||||
groupBy: [...item.groupBy, autocompleteKey],
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
redirectWithQueryBuilderData(
|
||||
nextQuery,
|
||||
{ panelTypes: PANEL_TYPES.TIME_SERIES },
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
);
|
||||
} catch {
|
||||
toast.error(SOMETHING_WENT_WRONG, { position: 'top-right' });
|
||||
}
|
||||
},
|
||||
[currentQuery, getAutocompleteKey, redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Filter for value',
|
||||
value: SPAN_ACTION.FILTER_IN,
|
||||
icon: React.createElement(ArrowDownToDot, {
|
||||
size: 14,
|
||||
style: { transform: 'rotate(90deg)' },
|
||||
}),
|
||||
callback: handleFilterIn,
|
||||
shouldHide: shouldHideForKey,
|
||||
},
|
||||
{
|
||||
label: 'Filter out value',
|
||||
value: SPAN_ACTION.FILTER_OUT,
|
||||
icon: React.createElement(ArrowUpFromDot, {
|
||||
size: 14,
|
||||
style: { transform: 'rotate(90deg)' },
|
||||
}),
|
||||
callback: handleFilterOut,
|
||||
shouldHide: shouldHideForKey,
|
||||
},
|
||||
{
|
||||
label: 'Group by attribute',
|
||||
value: SPAN_ACTION.GROUP_BY,
|
||||
icon: React.createElement(GroupByIcon),
|
||||
callback: handleGroupBy,
|
||||
shouldHide: shouldHideForKey,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
.event-tooltip-content {
|
||||
font-size: 12px;
|
||||
color: var(--l1-foreground);
|
||||
max-width: 300px;
|
||||
|
||||
&__header {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--l2-foreground);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
color: var(--text-robin-400);
|
||||
|
||||
&.error {
|
||||
color: var(--destructive);
|
||||
}
|
||||
}
|
||||
|
||||
&__time {
|
||||
font-size: 11px;
|
||||
color: var(--l2-foreground);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&__divider {
|
||||
border-top: 1px solid var(--l2-border);
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
&__attributes {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
&__kv {
|
||||
margin-bottom: 2px;
|
||||
line-height: 1.4;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
&__key {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
&__value {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import { Diamond } from 'lucide-react';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import './EventTooltipContent.styles.scss';
|
||||
|
||||
export interface EventTooltipContentProps {
|
||||
eventName: string;
|
||||
timeOffsetMs: number;
|
||||
isError: boolean;
|
||||
attributeMap: Record<string, string>;
|
||||
}
|
||||
|
||||
export function EventTooltipContent({
|
||||
eventName,
|
||||
timeOffsetMs,
|
||||
isError,
|
||||
attributeMap,
|
||||
}: EventTooltipContentProps): JSX.Element {
|
||||
const { time, timeUnitName } = convertTimeToRelevantUnit(timeOffsetMs);
|
||||
|
||||
return (
|
||||
<div className="event-tooltip-content">
|
||||
<div className="event-tooltip-content__header">
|
||||
<Diamond size={10} />
|
||||
<span>EVENT DETAILS</span>
|
||||
</div>
|
||||
<div className={`event-tooltip-content__name ${isError ? 'error' : ''}`}>
|
||||
{eventName}
|
||||
</div>
|
||||
<div className="event-tooltip-content__time">
|
||||
{toFixed(time, 2)} {timeUnitName} from start
|
||||
</div>
|
||||
{Object.keys(attributeMap).length > 0 && (
|
||||
<>
|
||||
<div className="event-tooltip-content__divider" />
|
||||
<div className="event-tooltip-content__attributes">
|
||||
{Object.entries(attributeMap).map(([key, value]) => (
|
||||
<div key={key} className="event-tooltip-content__kv">
|
||||
<span className="event-tooltip-content__key">{key}:</span>{' '}
|
||||
<span className="event-tooltip-content__value">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
.span-hover-card-wrapper {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.span-hover-card-popover {
|
||||
// Event-dot tooltip is rendered while the SpanDetailsPanel may be docked as
|
||||
// a FloatingPanel (z-index 999); bump above the default tooltip z-index 50.
|
||||
--tooltip-z-index: 1000;
|
||||
|
||||
.ant-popover-inner {
|
||||
background-color: var(--l1-background);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.ant-popover-inner-content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Flamegraph tooltip — rendered as a portal, uses same semantic tokens.
|
||||
// Position is set inline on the element (left/top track the cursor); the
|
||||
// static layout/decoration lives here.
|
||||
.flamegraph-tooltip {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
background-color: var(--l1-background);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.span-hover-card-content {
|
||||
font-size: 12px;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&__name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&__row {
|
||||
line-height: 1.5;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import { memo, ReactNode, useCallback, useRef, useState } from 'react';
|
||||
import { Popover } from 'antd';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import './SpanHoverCard.styles.scss';
|
||||
|
||||
interface ITraceMetadata {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
export interface SpanTooltipContentProps {
|
||||
spanName: string;
|
||||
color: string;
|
||||
hasError: boolean;
|
||||
relativeStartMs: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export function SpanTooltipContent({
|
||||
spanName,
|
||||
color,
|
||||
hasError,
|
||||
relativeStartMs,
|
||||
durationMs,
|
||||
}: SpanTooltipContentProps): JSX.Element {
|
||||
const { time: formattedDuration, timeUnitName } =
|
||||
convertTimeToRelevantUnit(durationMs);
|
||||
|
||||
return (
|
||||
<div className="span-hover-card-content">
|
||||
<div className="span-hover-card-content__name" style={{ color }}>
|
||||
{spanName}
|
||||
</div>
|
||||
<div className="span-hover-card-content__row">
|
||||
Status: {hasError ? 'error' : 'ok'}
|
||||
</div>
|
||||
<div className="span-hover-card-content__row">
|
||||
Start: {toFixed(relativeStartMs, 2)} ms
|
||||
</div>
|
||||
<div className="span-hover-card-content__row">
|
||||
Duration: {toFixed(formattedDuration, 2)} {timeUnitName}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SpanHoverCardProps {
|
||||
span: SpanV3;
|
||||
traceMetadata: ITraceMetadata;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy hover card — only mounts the expensive antd Popover when the user
|
||||
* actually hovers over the element (after a short delay). During fast scrolling,
|
||||
* rows mount and unmount without ever creating a Popover instance, avoiding
|
||||
* expensive DOM/effect overhead from antd Tooltip/Trigger internals.
|
||||
*/
|
||||
const SpanHoverCard = memo(function SpanHoverCard({
|
||||
span,
|
||||
traceMetadata,
|
||||
children,
|
||||
}: SpanHoverCardProps): JSX.Element {
|
||||
const [showPopover, setShowPopover] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleMouseEnter = useCallback((): void => {
|
||||
timerRef.current = setTimeout(() => {
|
||||
setShowPopover(true);
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback((): void => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
setShowPopover(false);
|
||||
}, []);
|
||||
|
||||
if (!showPopover) {
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/mouse-events-have-key-events
|
||||
<span
|
||||
className="span-hover-card-wrapper"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const durationMs = span.duration_nano / 1e6;
|
||||
const relativeStartMs = span.timestamp - traceMetadata.startTime;
|
||||
|
||||
let color = generateColor(
|
||||
span['service.name'],
|
||||
themeColors.traceDetailColorsV3,
|
||||
);
|
||||
if (span.has_error) {
|
||||
color = 'var(--bg-cherry-500)';
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open
|
||||
content={
|
||||
<SpanTooltipContent
|
||||
spanName={span.name}
|
||||
color={color}
|
||||
hasError={span.has_error}
|
||||
relativeStartMs={relativeStartMs}
|
||||
durationMs={durationMs}
|
||||
/>
|
||||
}
|
||||
trigger="hover"
|
||||
rootClassName="span-hover-card-popover"
|
||||
autoAdjustOverflow
|
||||
arrow={false}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
setShowPopover(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
|
||||
<span className="span-hover-card-wrapper" onMouseLeave={handleMouseLeave}>
|
||||
{children}
|
||||
</span>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
|
||||
export default SpanHoverCard;
|
||||
@@ -1,28 +0,0 @@
|
||||
.trace-details-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
gap: 8px;
|
||||
|
||||
&__back-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.key-value-label {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__filter {
|
||||
&.trace-v3-filter-row {
|
||||
padding: 0;
|
||||
}
|
||||
max-width: 850px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__old-view-btn {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
|
||||
|
||||
import './TraceDetailsHeader.styles.scss';
|
||||
|
||||
interface FilterMetadata {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
traceId: string;
|
||||
}
|
||||
|
||||
interface TraceDetailsHeaderProps {
|
||||
filterMetadata: FilterMetadata;
|
||||
onFilteredSpansChange: (spanIds: string[], isFilterActive: boolean) => void;
|
||||
noData?: boolean;
|
||||
}
|
||||
|
||||
function TraceDetailsHeader({
|
||||
filterMetadata,
|
||||
onFilteredSpansChange,
|
||||
noData,
|
||||
}: TraceDetailsHeaderProps): JSX.Element {
|
||||
const { id: traceID } = useParams<TraceDetailV2URLProps>();
|
||||
|
||||
const handleSwitchToOldView = useCallback((): void => {
|
||||
const oldUrl = `/trace-old/${traceID}${window.location.search}`;
|
||||
history.replace(oldUrl);
|
||||
}, [traceID]);
|
||||
|
||||
const handlePreviousBtnClick = useCallback((): void => {
|
||||
const isSpaNavigate =
|
||||
document.referrer &&
|
||||
// oxlint-disable-next-line signoz/no-raw-absolute-path
|
||||
new URL(document.referrer).origin === window.location.origin;
|
||||
const hasBackHistory = window.history.length > 1;
|
||||
|
||||
if (isSpaNavigate && hasBackHistory) {
|
||||
history.goBack();
|
||||
} else {
|
||||
history.push(ROUTES.TRACES_EXPLORER);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="trace-details-header">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className="trace-details-header__back-btn"
|
||||
onClick={handlePreviousBtnClick}
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
</Button>
|
||||
<KeyValueLabel
|
||||
badgeKey="Trace ID"
|
||||
badgeValue={traceID || ''}
|
||||
maxCharacters={100}
|
||||
/>
|
||||
{!noData && (
|
||||
<>
|
||||
<div className="trace-details-header__filter">
|
||||
<Filters
|
||||
startTime={filterMetadata.startTime}
|
||||
endTime={filterMetadata.endTime}
|
||||
traceID={filterMetadata.traceId}
|
||||
onFilteredSpansChange={onFilteredSpansChange}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className="trace-details-header__old-view-btn"
|
||||
onClick={handleSwitchToOldView}
|
||||
>
|
||||
Old View
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDetailsHeader;
|
||||
@@ -1,107 +0,0 @@
|
||||
.trace-details-v3 {
|
||||
height: calc(100vh);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__flame-collapse,
|
||||
&__waterfall-collapse {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
|
||||
.ant-collapse-item {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.ant-collapse-header {
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.ant-collapse-content {
|
||||
background: transparent;
|
||||
border-top: none;
|
||||
// Disable collapse animation — virtualizer and canvas flicker during height transitions
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__collapse-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__collapse-count {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
&__flame-collapse {
|
||||
flex-shrink: 0;
|
||||
|
||||
.ant-collapse-content-box {
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__waterfall-collapse {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
.ant-collapse-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ant-collapse-content.ant-collapse-content-active {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ant-collapse-content-box {
|
||||
padding: 0 !important;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&--docked {
|
||||
flex: none;
|
||||
|
||||
.ant-collapse-item {
|
||||
flex: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ant-collapse-content-box {
|
||||
flex: none;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__docked-span-details {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
.flamegraph-canvas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.flamegraph-canvas__viewport {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.flamegraph-canvas__main {
|
||||
display: block;
|
||||
width: 100%;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.flamegraph-canvas__overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import TimelineV3 from 'components/TimelineV3/TimelineV3';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import { useCrosshair } from '../hooks/useCrosshair';
|
||||
import { EventTooltipContent } from '../SpanHoverCard/EventTooltipContent';
|
||||
import { SpanTooltipContent } from '../SpanHoverCard/SpanHoverCard';
|
||||
import { DEFAULT_ROW_HEIGHT } from './constants';
|
||||
import { useCanvasSetup } from './hooks/useCanvasSetup';
|
||||
import { useFlamegraphCrosshair } from './hooks/useFlamegraphCrosshair';
|
||||
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 { EventRect, FlamegraphCanvasProps, SpanRect } from './types';
|
||||
|
||||
import './FlamegraphCanvas.styles.scss';
|
||||
|
||||
function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
|
||||
const {
|
||||
layout,
|
||||
traceMetadata,
|
||||
firstSpanAtFetchLevel,
|
||||
onSpanClick,
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
} = props;
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const overlayCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const spanRectsRef = useRef<SpanRect[]>([]);
|
||||
const eventRectsRef = useRef<EventRect[]>([]);
|
||||
|
||||
const [viewStartTs, setViewStartTs] = useState<number>(
|
||||
traceMetadata.startTime,
|
||||
);
|
||||
const [viewEndTs, setViewEndTs] = useState<number>(traceMetadata.endTime);
|
||||
const [scrollTop, setScrollTop] = useState<number>(0);
|
||||
const [rowHeight, setRowHeight] = useState<number>(DEFAULT_ROW_HEIGHT);
|
||||
|
||||
// Mutable refs for zoom and drag hooks to read during rAF / mouse callbacks
|
||||
const viewStartRef = useRef(viewStartTs);
|
||||
const viewEndRef = useRef(viewEndTs);
|
||||
const rowHeightRef = useRef(rowHeight);
|
||||
const scrollTopRef = useRef(scrollTop);
|
||||
|
||||
useEffect(() => {
|
||||
viewStartRef.current = viewStartTs;
|
||||
}, [viewStartTs]);
|
||||
|
||||
useEffect(() => {
|
||||
viewEndRef.current = viewEndTs;
|
||||
}, [viewEndTs]);
|
||||
|
||||
useEffect(() => {
|
||||
rowHeightRef.current = rowHeight;
|
||||
}, [rowHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollTopRef.current = scrollTop;
|
||||
}, [scrollTop]);
|
||||
|
||||
useEffect(() => {
|
||||
//TODO: see if this can be removed as once loaded the view start and end ts will not change
|
||||
setViewStartTs(traceMetadata.startTime);
|
||||
setViewEndTs(traceMetadata.endTime);
|
||||
viewStartRef.current = traceMetadata.startTime;
|
||||
viewEndRef.current = traceMetadata.endTime;
|
||||
}, [traceMetadata.startTime, traceMetadata.endTime]);
|
||||
|
||||
const totalHeight = layout.totalVisualRows * rowHeight;
|
||||
|
||||
const { isOverFlamegraphRef } = useFlamegraphZoom({
|
||||
canvasRef,
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
rowHeightRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setRowHeight,
|
||||
});
|
||||
|
||||
const {
|
||||
handleMouseDown,
|
||||
handleMouseMove: handleDragMouseMove,
|
||||
handleMouseUp,
|
||||
handleDragMouseLeave,
|
||||
isDraggingRef,
|
||||
} = useFlamegraphDrag({
|
||||
canvasRef,
|
||||
containerRef,
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
scrollTopRef,
|
||||
setScrollTop,
|
||||
totalHeight,
|
||||
});
|
||||
|
||||
const {
|
||||
hoveredSpanId,
|
||||
hoveredEventKey,
|
||||
handleHoverMouseMove,
|
||||
handleHoverMouseLeave,
|
||||
handleMouseDownForClick,
|
||||
handleClick,
|
||||
tooltipContent,
|
||||
} = useFlamegraphHover({
|
||||
canvasRef,
|
||||
spanRectsRef,
|
||||
eventRectsRef,
|
||||
traceMetadata,
|
||||
viewStartTs,
|
||||
viewEndTs,
|
||||
isDraggingRef,
|
||||
onSpanClick,
|
||||
isDarkMode,
|
||||
});
|
||||
|
||||
const { drawFlamegraph } = useFlamegraphDraw({
|
||||
canvasRef,
|
||||
containerRef,
|
||||
spans: layout.visualRows,
|
||||
connectors: layout.connectors,
|
||||
viewStartTs,
|
||||
viewEndTs,
|
||||
scrollTop,
|
||||
rowHeight,
|
||||
selectedSpanId: firstSpanAtFetchLevel || undefined,
|
||||
hoveredSpanId: hoveredSpanId ?? '',
|
||||
isDarkMode,
|
||||
spanRectsRef,
|
||||
eventRectsRef,
|
||||
hoveredEventKey,
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
});
|
||||
|
||||
useScrollToSpan({
|
||||
firstSpanAtFetchLevel,
|
||||
spans: layout.visualRows,
|
||||
traceMetadata,
|
||||
containerRef,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
scrollTopRef,
|
||||
rowHeight,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setScrollTop,
|
||||
});
|
||||
|
||||
useCanvasSetup(canvasRef, containerRef, drawFlamegraph, overlayCanvasRef);
|
||||
|
||||
const {
|
||||
cursorXPercent,
|
||||
cursorX,
|
||||
onMouseMove: onCrosshairMove,
|
||||
onMouseLeave: onCrosshairLeave,
|
||||
} = useCrosshair({ containerRef });
|
||||
|
||||
useFlamegraphCrosshair({ overlayCanvasRef, cursorX });
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent): void => {
|
||||
handleDragMouseMove(e);
|
||||
handleHoverMouseMove(e);
|
||||
if (!isDraggingRef.current) {
|
||||
onCrosshairMove(e);
|
||||
}
|
||||
},
|
||||
[handleDragMouseMove, handleHoverMouseMove, onCrosshairMove, isDraggingRef],
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback((): void => {
|
||||
isOverFlamegraphRef.current = false;
|
||||
onCrosshairLeave();
|
||||
handleDragMouseLeave();
|
||||
handleHoverMouseLeave();
|
||||
}, [
|
||||
isOverFlamegraphRef,
|
||||
onCrosshairLeave,
|
||||
handleDragMouseLeave,
|
||||
handleHoverMouseLeave,
|
||||
]);
|
||||
|
||||
const tooltipElement = tooltipContent
|
||||
? createPortal(
|
||||
<div
|
||||
className="span-hover-card-popover flamegraph-tooltip"
|
||||
style={{
|
||||
left: Math.min(tooltipContent.clientX + 15, window.innerWidth - 220),
|
||||
top: Math.min(tooltipContent.clientY + 15, window.innerHeight - 100),
|
||||
}}
|
||||
>
|
||||
{tooltipContent.event ? (
|
||||
<EventTooltipContent
|
||||
eventName={tooltipContent.event.name}
|
||||
timeOffsetMs={tooltipContent.event.timeOffsetMs}
|
||||
isError={tooltipContent.event.isError}
|
||||
attributeMap={tooltipContent.event.attributeMap}
|
||||
/>
|
||||
) : (
|
||||
<SpanTooltipContent
|
||||
spanName={tooltipContent.spanName}
|
||||
color={tooltipContent.spanColor}
|
||||
hasError={tooltipContent.status === 'error'}
|
||||
relativeStartMs={tooltipContent.startMs}
|
||||
durationMs={tooltipContent.durationMs}
|
||||
/>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flamegraph-canvas">
|
||||
{tooltipElement}
|
||||
<TimelineV3
|
||||
startTimestamp={viewStartTs}
|
||||
endTimestamp={viewEndTs}
|
||||
offsetTimestamp={viewStartTs - traceMetadata.startTime}
|
||||
timelineHeight={10}
|
||||
cursorXPercent={cursorXPercent}
|
||||
/>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flamegraph-canvas__viewport"
|
||||
onMouseEnter={(): void => {
|
||||
isOverFlamegraphRef.current = true;
|
||||
}}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="flamegraph-canvas__main"
|
||||
onMouseDown={(e): void => {
|
||||
handleMouseDown(e);
|
||||
handleMouseDownForClick(e);
|
||||
}}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
<canvas ref={overlayCanvasRef} className="flamegraph-canvas__overlay" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FlamegraphCanvas;
|
||||
@@ -1,140 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useHistory, useLocation, useParams } from 'react-router-dom';
|
||||
import { Skeleton } from 'antd';
|
||||
import useGetTraceFlamegraph from 'hooks/trace/useGetTraceFlamegraph';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { TraceDetailFlamegraphURLProps } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
import Error from '../TraceWaterfall/TraceWaterfallStates/Error/Error';
|
||||
import { FLAMEGRAPH_SPAN_LIMIT } from './constants';
|
||||
import FlamegraphCanvas from './FlamegraphCanvas';
|
||||
import { useVisualLayoutWorker } from './hooks/useVisualLayoutWorker';
|
||||
|
||||
interface TraceFlamegraphProps {
|
||||
filteredSpanIds: string[];
|
||||
isFilterActive: boolean;
|
||||
}
|
||||
|
||||
function TraceFlamegraph({
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
}: TraceFlamegraphProps): 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: fetchError,
|
||||
} = useGetTraceFlamegraph({
|
||||
traceId,
|
||||
// selectedSpanId: firstSpanAtFetchLevel,
|
||||
limit: FLAMEGRAPH_SPAN_LIMIT,
|
||||
});
|
||||
|
||||
const spans = useMemo(
|
||||
() => data?.payload?.spans || [],
|
||||
[data?.payload?.spans],
|
||||
);
|
||||
|
||||
const {
|
||||
layout,
|
||||
isComputing,
|
||||
error: workerError,
|
||||
} = useVisualLayoutWorker(spans);
|
||||
|
||||
const content = useMemo(() => {
|
||||
// Loading: fetching data or worker computing layout
|
||||
if (isFetching || isComputing) {
|
||||
if (layout.totalVisualRows > 0) {
|
||||
return (
|
||||
<FlamegraphCanvas
|
||||
layout={layout}
|
||||
firstSpanAtFetchLevel={firstSpanAtFetchLevel}
|
||||
setFirstSpanAtFetchLevel={setFirstSpanAtFetchLevel}
|
||||
onSpanClick={handleSpanClick}
|
||||
traceMetadata={{
|
||||
startTime: data?.payload?.startTimestampMillis || 0,
|
||||
endTime: data?.payload?.endTimestampMillis || 0,
|
||||
}}
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', padding: '8px 12px' }}>
|
||||
<Skeleton
|
||||
active
|
||||
paragraph={{
|
||||
rows: 8,
|
||||
width: ['100%', '95%', '85%', '70%', '50%', '35%', '20%', '10%'],
|
||||
}}
|
||||
title={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Error: network or worker failure
|
||||
if (fetchError || workerError) {
|
||||
return <Error error={(fetchError || workerError) as any} />;
|
||||
}
|
||||
if (data?.payload?.spans && data.payload.spans.length === 0) {
|
||||
return <div>No data found for trace {traceId}</div>;
|
||||
}
|
||||
return (
|
||||
<FlamegraphCanvas
|
||||
layout={layout}
|
||||
firstSpanAtFetchLevel={firstSpanAtFetchLevel}
|
||||
setFirstSpanAtFetchLevel={setFirstSpanAtFetchLevel}
|
||||
onSpanClick={handleSpanClick}
|
||||
traceMetadata={{
|
||||
startTime: data?.payload?.startTimestampMillis || 0,
|
||||
endTime: data?.payload?.endTimestampMillis || 0,
|
||||
}}
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
data?.payload?.endTimestampMillis,
|
||||
data?.payload?.startTimestampMillis,
|
||||
data?.payload?.spans,
|
||||
fetchError,
|
||||
filteredSpanIds,
|
||||
firstSpanAtFetchLevel,
|
||||
handleSpanClick,
|
||||
isComputing,
|
||||
isFilterActive,
|
||||
isFetching,
|
||||
layout,
|
||||
traceId,
|
||||
workerError,
|
||||
]);
|
||||
|
||||
return <>{content}</>;
|
||||
}
|
||||
|
||||
export default TraceFlamegraph;
|
||||
@@ -1,475 +0,0 @@
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
import { computeVisualLayout } from '../computeVisualLayout';
|
||||
|
||||
function makeSpan(
|
||||
overrides: Partial<FlamegraphSpan> & {
|
||||
spanId: string;
|
||||
timestamp: number;
|
||||
durationNano: number;
|
||||
},
|
||||
): FlamegraphSpan {
|
||||
return {
|
||||
parentSpanId: '',
|
||||
traceId: 'trace-1',
|
||||
hasError: false,
|
||||
serviceName: 'svc',
|
||||
name: 'op',
|
||||
level: 0,
|
||||
event: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('computeVisualLayout', () => {
|
||||
it('should handle empty input', () => {
|
||||
const layout = computeVisualLayout([]);
|
||||
expect(layout.totalVisualRows).toBe(0);
|
||||
expect(layout.visualRows).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle single root, no children — 1 visual row', () => {
|
||||
const root = makeSpan({
|
||||
spanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 100e6,
|
||||
});
|
||||
const layout = computeVisualLayout([[root]]);
|
||||
expect(layout.totalVisualRows).toBe(1);
|
||||
expect(layout.visualRows[0]).toEqual([root]);
|
||||
expect(layout.spanToVisualRow['root']).toBe(0);
|
||||
});
|
||||
|
||||
it('should keep non-overlapping siblings on the same row (compact)', () => {
|
||||
const root = makeSpan({
|
||||
spanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 500e6,
|
||||
});
|
||||
const a = makeSpan({
|
||||
spanId: 'a',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 100e6,
|
||||
});
|
||||
const b = makeSpan({
|
||||
spanId: 'b',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 200,
|
||||
durationNano: 100e6,
|
||||
});
|
||||
const c = makeSpan({
|
||||
spanId: 'c',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 400,
|
||||
durationNano: 100e6,
|
||||
});
|
||||
|
||||
const layout = computeVisualLayout([[root], [a, b, c]]);
|
||||
|
||||
// root on row 0, all children on row 1
|
||||
expect(layout.totalVisualRows).toBe(2);
|
||||
expect(layout.spanToVisualRow['root']).toBe(0);
|
||||
expect(layout.spanToVisualRow['a']).toBe(1);
|
||||
expect(layout.spanToVisualRow['b']).toBe(1);
|
||||
expect(layout.spanToVisualRow['c']).toBe(1);
|
||||
});
|
||||
|
||||
it('should pack non-overlapping siblings into shared lanes (greedy packing)', () => {
|
||||
const root = makeSpan({
|
||||
spanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 300e6,
|
||||
});
|
||||
// A and B overlap; C does not overlap with either
|
||||
const a = makeSpan({
|
||||
spanId: 'a',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 100e6, // ends at 100ms
|
||||
});
|
||||
const b = makeSpan({
|
||||
spanId: 'b',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 50,
|
||||
durationNano: 100e6, // starts at 50ms < 100ms end of A → overlap → lane 1
|
||||
});
|
||||
const c = makeSpan({
|
||||
spanId: 'c',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 200,
|
||||
durationNano: 100e6, // 200 >= 100, fits lane 0 with A
|
||||
});
|
||||
|
||||
const layout = computeVisualLayout([[root], [a, b, c]]);
|
||||
|
||||
// root on row 0, C placed first (latest) → row 1, B doesn't overlap C → row 1, A overlaps B → row 2
|
||||
expect(layout.totalVisualRows).toBe(3);
|
||||
expect(layout.spanToVisualRow['root']).toBe(0);
|
||||
expect(layout.spanToVisualRow['c']).toBe(1);
|
||||
expect(layout.spanToVisualRow['b']).toBe(1);
|
||||
expect(layout.spanToVisualRow['a']).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle full overlap — all siblings get own row', () => {
|
||||
const root = makeSpan({
|
||||
spanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
const a = makeSpan({
|
||||
spanId: 'a',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
const b = makeSpan({
|
||||
spanId: 'b',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
|
||||
const layout = computeVisualLayout([[root], [a, b]]);
|
||||
|
||||
expect(layout.totalVisualRows).toBe(3);
|
||||
expect(layout.spanToVisualRow['a']).toBe(1);
|
||||
expect(layout.spanToVisualRow['b']).toBe(2);
|
||||
});
|
||||
|
||||
it('should stack children correctly below overlapping parents', () => {
|
||||
const root = makeSpan({
|
||||
spanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 300e6,
|
||||
});
|
||||
const a = makeSpan({
|
||||
spanId: 'a',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
const b = makeSpan({
|
||||
spanId: 'b',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 50,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
// Child of A
|
||||
const childA = makeSpan({
|
||||
spanId: 'childA',
|
||||
parentSpanId: 'a',
|
||||
timestamp: 10,
|
||||
durationNano: 50e6,
|
||||
});
|
||||
// Child of B
|
||||
const childB = makeSpan({
|
||||
spanId: 'childB',
|
||||
parentSpanId: 'b',
|
||||
timestamp: 60,
|
||||
durationNano: 50e6,
|
||||
});
|
||||
|
||||
const layout = computeVisualLayout([[root], [a, b], [childA, childB]]);
|
||||
|
||||
// DFS processes b's subtree first (latest):
|
||||
// root → row 0
|
||||
// b → row 1 (parentRow 0 + 1)
|
||||
// childB → row 2 (parentRow 1 + 1)
|
||||
// a → try row 1 (parentRow 0 + 1), overlaps b → try row 2, overlaps childB → row 3
|
||||
// childA → row 4 (parentRow 3 + 1)
|
||||
expect(layout.spanToVisualRow['root']).toBe(0);
|
||||
expect(layout.spanToVisualRow['b']).toBe(1);
|
||||
expect(layout.spanToVisualRow['childB']).toBe(2);
|
||||
expect(layout.spanToVisualRow['a']).toBe(3);
|
||||
expect(layout.spanToVisualRow['childA']).toBe(4);
|
||||
expect(layout.totalVisualRows).toBe(5);
|
||||
});
|
||||
|
||||
it('should handle multiple roots as a sibling group', () => {
|
||||
// Two overlapping roots
|
||||
const r1 = makeSpan({
|
||||
spanId: 'r1',
|
||||
timestamp: 0,
|
||||
durationNano: 100e6,
|
||||
});
|
||||
const r2 = makeSpan({
|
||||
spanId: 'r2',
|
||||
timestamp: 50,
|
||||
durationNano: 100e6,
|
||||
});
|
||||
|
||||
const layout = computeVisualLayout([[r1, r2]]);
|
||||
|
||||
expect(layout.spanToVisualRow['r1']).toBe(0);
|
||||
expect(layout.spanToVisualRow['r2']).toBe(1);
|
||||
expect(layout.totalVisualRows).toBe(2);
|
||||
});
|
||||
|
||||
it('should produce compact layout for deep nesting without overlap', () => {
|
||||
const root = makeSpan({
|
||||
spanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 1000e6,
|
||||
});
|
||||
const child = makeSpan({
|
||||
spanId: 'child',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 10,
|
||||
durationNano: 500e6,
|
||||
});
|
||||
const grandchild = makeSpan({
|
||||
spanId: 'grandchild',
|
||||
parentSpanId: 'child',
|
||||
timestamp: 20,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
|
||||
const layout = computeVisualLayout([[root], [child], [grandchild]]);
|
||||
|
||||
// No overlap at any level → visual rows == tree depth
|
||||
expect(layout.totalVisualRows).toBe(3);
|
||||
expect(layout.spanToVisualRow['root']).toBe(0);
|
||||
expect(layout.spanToVisualRow['child']).toBe(1);
|
||||
expect(layout.spanToVisualRow['grandchild']).toBe(2);
|
||||
});
|
||||
|
||||
it('should pack many sequential siblings into 1 row (no diagonal staircase)', () => {
|
||||
const root = makeSpan({
|
||||
spanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 500e6,
|
||||
});
|
||||
// 6 sequential children — like checkoutservice/PlaceOrder scenario
|
||||
const spans = [
|
||||
makeSpan({
|
||||
spanId: 's1',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 3,
|
||||
durationNano: 30e6,
|
||||
}),
|
||||
makeSpan({
|
||||
spanId: 's2',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 35,
|
||||
durationNano: 4e6,
|
||||
}),
|
||||
makeSpan({
|
||||
spanId: 's3',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 39,
|
||||
durationNano: 1e6,
|
||||
}),
|
||||
makeSpan({
|
||||
spanId: 's4',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 40,
|
||||
durationNano: 4e6,
|
||||
}),
|
||||
makeSpan({
|
||||
spanId: 's5',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 44,
|
||||
durationNano: 5e6,
|
||||
}),
|
||||
makeSpan({
|
||||
spanId: 's6',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 49,
|
||||
durationNano: 1e6,
|
||||
}),
|
||||
];
|
||||
|
||||
const layout = computeVisualLayout([[root], spans]);
|
||||
|
||||
// All 6 sequential siblings should share 1 row
|
||||
expect(layout.totalVisualRows).toBe(2);
|
||||
expect(layout.spanToVisualRow['root']).toBe(0);
|
||||
for (const span of spans) {
|
||||
expect(layout.spanToVisualRow[span.spanId]).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should keep children below parents even with misparented spans', () => {
|
||||
// Simulates the dd_sig2 bug: /route spans have parentSpanId pointing
|
||||
// to the wrong ancestor, but they are at level 2 in the spans[][] input.
|
||||
// Level-based packing must place them below level 1 regardless.
|
||||
const httpGet = makeSpan({
|
||||
spanId: 'http-get',
|
||||
timestamp: 0,
|
||||
durationNano: 500e6,
|
||||
});
|
||||
const route = makeSpan({
|
||||
spanId: 'route',
|
||||
parentSpanId: 'some-wrong-ancestor', // misparented!
|
||||
timestamp: 10,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
|
||||
const layout = computeVisualLayout([[httpGet], [route]]);
|
||||
|
||||
// httpGet at level 0 → row 0, route at level 1 → row 1
|
||||
expect(layout.spanToVisualRow['http-get']).toBe(0);
|
||||
expect(layout.spanToVisualRow['route']).toBe(1);
|
||||
expect(layout.totalVisualRows).toBe(2);
|
||||
});
|
||||
|
||||
it('should keep parent-child pairs adjacent when sibling subtrees overlap', () => {
|
||||
// Multiple overlapping parents each with a child — the subtree-unit
|
||||
// guarantee means every parent→child gap should be exactly 1.
|
||||
const root = makeSpan({
|
||||
spanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 500e6,
|
||||
});
|
||||
// Three overlapping HTTP GET children of root, each with its own /route child
|
||||
const get1 = makeSpan({
|
||||
spanId: 'get1',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
const route1 = makeSpan({
|
||||
spanId: 'route1',
|
||||
parentSpanId: 'get1',
|
||||
timestamp: 10,
|
||||
durationNano: 180e6,
|
||||
});
|
||||
const get2 = makeSpan({
|
||||
spanId: 'get2',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 50,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
const route2 = makeSpan({
|
||||
spanId: 'route2',
|
||||
parentSpanId: 'get2',
|
||||
timestamp: 60,
|
||||
durationNano: 180e6,
|
||||
});
|
||||
const get3 = makeSpan({
|
||||
spanId: 'get3',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 100,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
const route3 = makeSpan({
|
||||
spanId: 'route3',
|
||||
parentSpanId: 'get3',
|
||||
timestamp: 110,
|
||||
durationNano: 180e6,
|
||||
});
|
||||
|
||||
const layout = computeVisualLayout([
|
||||
[root],
|
||||
[get1, get2, get3],
|
||||
[route1, route2, route3],
|
||||
]);
|
||||
|
||||
// Each parent-child pair should have a gap of exactly 1
|
||||
const get1Row = layout.spanToVisualRow['get1'];
|
||||
const route1Row = layout.spanToVisualRow['route1'];
|
||||
const get2Row = layout.spanToVisualRow['get2'];
|
||||
const route2Row = layout.spanToVisualRow['route2'];
|
||||
const get3Row = layout.spanToVisualRow['get3'];
|
||||
const route3Row = layout.spanToVisualRow['route3'];
|
||||
|
||||
expect(route1Row - get1Row).toBe(1);
|
||||
expect(route2Row - get2Row).toBe(1);
|
||||
expect(route3Row - get3Row).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle mixed levels — overlap at level 2 but not level 1', () => {
|
||||
const root = makeSpan({
|
||||
spanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 1000e6,
|
||||
});
|
||||
// Non-overlapping children
|
||||
const a = makeSpan({
|
||||
spanId: 'a',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 400e6,
|
||||
});
|
||||
const b = makeSpan({
|
||||
spanId: 'b',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 500,
|
||||
durationNano: 400e6,
|
||||
});
|
||||
// Overlapping grandchildren under A
|
||||
const ga1 = makeSpan({
|
||||
spanId: 'ga1',
|
||||
parentSpanId: 'a',
|
||||
timestamp: 0,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
const ga2 = makeSpan({
|
||||
spanId: 'ga2',
|
||||
parentSpanId: 'a',
|
||||
timestamp: 100,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
|
||||
const layout = computeVisualLayout([[root], [a, b], [ga1, ga2]]);
|
||||
|
||||
// root → row 0
|
||||
// a, b → row 1 (no overlap, share row)
|
||||
// ga1 → row 2, ga2 → row 3 (overlap, expanded)
|
||||
// b has no children, so nothing after ga2
|
||||
expect(layout.spanToVisualRow['root']).toBe(0);
|
||||
expect(layout.spanToVisualRow['a']).toBe(1);
|
||||
expect(layout.spanToVisualRow['b']).toBe(1);
|
||||
expect(layout.spanToVisualRow['ga2']).toBe(2);
|
||||
expect(layout.spanToVisualRow['ga1']).toBe(3);
|
||||
expect(layout.totalVisualRows).toBe(4);
|
||||
});
|
||||
|
||||
it('should not place a span where it covers an existing connector point (Check 2)', () => {
|
||||
// Scenario: root has 3 leaf children. Sorted latest-first: C(200), B(100), A(80).
|
||||
//
|
||||
// C placed at row 1 [200, 400].
|
||||
// B overlaps C → placed at row 2 [100, 300]. Connector from row 0→2 at x=100
|
||||
// passes through row 1, recording connector point at (row 1, x=100).
|
||||
// A [80, 110] does NOT overlap C's span [200, 400] at row 1 (110 < 200),
|
||||
// so without connector reservation A would fit at row 1.
|
||||
// But A's span [80, 110) contains the connector point x=100 at row 1.
|
||||
// Check 2 prevents this placement, pushing A further down.
|
||||
const root = makeSpan({
|
||||
spanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 500e6,
|
||||
});
|
||||
const c = makeSpan({
|
||||
spanId: 'c',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 200,
|
||||
durationNano: 200e6, // [200, 400]
|
||||
});
|
||||
const b = makeSpan({
|
||||
spanId: 'b',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 100,
|
||||
durationNano: 200e6, // [100, 300]
|
||||
});
|
||||
const a = makeSpan({
|
||||
spanId: 'a',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 80,
|
||||
durationNano: 30e6, // [80, 110]
|
||||
});
|
||||
|
||||
const layout = computeVisualLayout([[root], [a, b, c]]);
|
||||
|
||||
expect(layout.spanToVisualRow['root']).toBe(0);
|
||||
expect(layout.spanToVisualRow['c']).toBe(1); // latest, placed first
|
||||
expect(layout.spanToVisualRow['b']).toBe(2); // overlaps C → row 2
|
||||
|
||||
// A would fit at row 1 by span overlap alone, but connector point at
|
||||
// (row 1, x=100) falls within A's span [80, 110). Check 2 pushes A down.
|
||||
const aRow = layout.spanToVisualRow['a']!;
|
||||
expect(aRow).toBeGreaterThan(1); // must NOT be at row 1
|
||||
expect(aRow).toBe(3); // next free row after B at row 2 (A overlaps B)
|
||||
});
|
||||
});
|
||||
@@ -1,536 +0,0 @@
|
||||
import { DASHED_BORDER_LINE_DASH, MIN_WIDTH_FOR_NAME } from '../constants';
|
||||
import type { FlamegraphRowMetrics } from '../utils';
|
||||
import { getFlamegraphRowMetrics } from '../utils';
|
||||
import { drawEventDot, drawSpanBar, getEventDotColor } from '../utils';
|
||||
import { MOCK_SPAN } from './testUtils';
|
||||
|
||||
jest.mock('container/TraceDetail/utils', () => ({
|
||||
convertTimeToRelevantUnit: (): { time: number; timeUnitName: string } => ({
|
||||
time: 50,
|
||||
timeUnitName: 'ms',
|
||||
}),
|
||||
}));
|
||||
|
||||
/** Minimal 2D context for createStripePattern's internal canvas (jsdom getContext often returns null) */
|
||||
const mockPatternCanvasCtx = {
|
||||
beginPath: jest.fn(),
|
||||
moveTo: jest.fn(),
|
||||
lineTo: jest.fn(),
|
||||
stroke: jest.fn(),
|
||||
globalAlpha: 1,
|
||||
};
|
||||
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
document.createElement = function (
|
||||
tagName: string,
|
||||
): ReturnType<typeof originalCreateElement> {
|
||||
const el = originalCreateElement(tagName);
|
||||
if (tagName.toLowerCase() === 'canvas') {
|
||||
(el as HTMLCanvasElement).getContext = (() =>
|
||||
mockPatternCanvasCtx as unknown) as HTMLCanvasElement['getContext'];
|
||||
}
|
||||
return el;
|
||||
};
|
||||
|
||||
function createMockCtx(): jest.Mocked<CanvasRenderingContext2D> {
|
||||
return {
|
||||
beginPath: jest.fn(),
|
||||
roundRect: jest.fn(),
|
||||
fill: jest.fn(),
|
||||
stroke: jest.fn(),
|
||||
save: jest.fn(),
|
||||
restore: jest.fn(),
|
||||
translate: jest.fn(),
|
||||
rotate: jest.fn(),
|
||||
fillRect: jest.fn(),
|
||||
strokeRect: jest.fn(),
|
||||
setLineDash: jest.fn(),
|
||||
measureText: jest.fn(
|
||||
(text: string) => ({ width: text.length * 6 }) as TextMetrics,
|
||||
),
|
||||
createPattern: jest.fn(() => ({}) as CanvasPattern),
|
||||
clip: jest.fn(),
|
||||
rect: jest.fn(),
|
||||
fillText: jest.fn(),
|
||||
font: '',
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
textAlign: '',
|
||||
textBaseline: '',
|
||||
lineWidth: 0,
|
||||
globalAlpha: 1,
|
||||
} as unknown as jest.Mocked<CanvasRenderingContext2D>;
|
||||
}
|
||||
|
||||
const METRICS: FlamegraphRowMetrics = getFlamegraphRowMetrics(24);
|
||||
|
||||
describe('Canvas Draw Utils', () => {
|
||||
describe('drawSpanBar', () => {
|
||||
it('draws rect + fill for normal span (no selected/hovered)', () => {
|
||||
const ctx = createMockCtx();
|
||||
const spanRectsArray: {
|
||||
span: typeof MOCK_SPAN;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
}[] = [];
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, event: [] },
|
||||
x: 10,
|
||||
y: 0,
|
||||
width: 100,
|
||||
levelIndex: 0,
|
||||
spanRectsArray,
|
||||
eventRectsArray: [],
|
||||
color: '#1890ff',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(ctx.beginPath).toHaveBeenCalled();
|
||||
expect(ctx.roundRect).toHaveBeenCalledWith(10, 1, 100, 22, 2);
|
||||
expect(ctx.fill).toHaveBeenCalled();
|
||||
expect(ctx.stroke).not.toHaveBeenCalled();
|
||||
expect(spanRectsArray).toHaveLength(1);
|
||||
expect(spanRectsArray[0]).toMatchObject({
|
||||
x: 10,
|
||||
y: 1,
|
||||
width: 100,
|
||||
height: 22,
|
||||
level: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses stripe pattern + dashed stroke + 2px when selected', () => {
|
||||
const ctx = createMockCtx();
|
||||
const spanRectsArray: {
|
||||
span: typeof MOCK_SPAN;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
}[] = [];
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, spanId: 'sel', event: [] },
|
||||
x: 20,
|
||||
y: 0,
|
||||
width: 80,
|
||||
levelIndex: 1,
|
||||
spanRectsArray,
|
||||
eventRectsArray: [],
|
||||
color: '#2F80ED',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
selectedSpanId: 'sel',
|
||||
});
|
||||
|
||||
// Selected spans get solid l2-background fill + dashed border
|
||||
expect(ctx.fill).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 solid l2-background fill + solid stroke + 1px when hovered (not selected)', () => {
|
||||
const ctx = createMockCtx();
|
||||
const spanRectsArray: {
|
||||
span: typeof MOCK_SPAN;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
}[] = [];
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, spanId: 'hov', event: [] },
|
||||
x: 30,
|
||||
y: 0,
|
||||
width: 60,
|
||||
levelIndex: 0,
|
||||
spanRectsArray,
|
||||
eventRectsArray: [],
|
||||
color: '#2F80ED',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
hoveredSpanId: 'hov',
|
||||
});
|
||||
|
||||
expect(ctx.fill).toHaveBeenCalled();
|
||||
expect(ctx.setLineDash).not.toHaveBeenCalled();
|
||||
expect(ctx.lineWidth).toBe(1);
|
||||
expect(ctx.stroke).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pushes spanRectsArray with correct dimensions', () => {
|
||||
const ctx = createMockCtx();
|
||||
const spanRectsArray: {
|
||||
span: typeof MOCK_SPAN;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
}[] = [];
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, spanId: 'rect-test', event: [] },
|
||||
x: 5,
|
||||
y: 24,
|
||||
width: 200,
|
||||
levelIndex: 2,
|
||||
spanRectsArray,
|
||||
eventRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(spanRectsArray[0]).toMatchObject({
|
||||
x: 5,
|
||||
y: 25,
|
||||
width: 200,
|
||||
height: 22,
|
||||
level: 2,
|
||||
});
|
||||
expect(spanRectsArray[0].span.spanId).toBe('rect-test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('drawSpanLabel (via drawSpanBar)', () => {
|
||||
it('skips label when width < MIN_WIDTH_FOR_NAME', () => {
|
||||
const ctx = createMockCtx();
|
||||
const spanRectsArray: {
|
||||
span: typeof MOCK_SPAN;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
}[] = [];
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, name: 'long-span-name', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: MIN_WIDTH_FOR_NAME - 1,
|
||||
levelIndex: 0,
|
||||
spanRectsArray,
|
||||
eventRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(ctx.clip).not.toHaveBeenCalled();
|
||||
expect(ctx.fillText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('draws name only when width >= MIN_WIDTH_FOR_NAME but < MIN_WIDTH_FOR_NAME_AND_DURATION', () => {
|
||||
const ctx = createMockCtx();
|
||||
ctx.measureText = jest.fn(
|
||||
(t: string) => ({ width: t.length * 6 }) as TextMetrics,
|
||||
);
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, name: 'foo', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 50,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
eventRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(ctx.clip).toHaveBeenCalled();
|
||||
expect(ctx.fillText).toHaveBeenCalled();
|
||||
expect(ctx.textAlign).toBe('left');
|
||||
});
|
||||
|
||||
it('draws name + duration when width >= MIN_WIDTH_FOR_NAME_AND_DURATION', () => {
|
||||
const ctx = createMockCtx();
|
||||
ctx.measureText = jest.fn(
|
||||
(t: string) => ({ width: t.length * 6 }) as TextMetrics,
|
||||
);
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, name: 'my-span', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
eventRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(ctx.fillText).toHaveBeenCalledTimes(2);
|
||||
expect(ctx.fillText).toHaveBeenCalledWith(
|
||||
'50ms',
|
||||
expect.any(Number),
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(ctx.fillText).toHaveBeenCalledWith(
|
||||
'my-span',
|
||||
expect.any(Number),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncateText (via drawSpanBar)', () => {
|
||||
it('uses full text when it fits', () => {
|
||||
const ctx = createMockCtx();
|
||||
ctx.measureText = jest.fn(
|
||||
(t: string) => ({ width: t.length * 4 }) as TextMetrics,
|
||||
);
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, name: 'short', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
eventRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(ctx.fillText).toHaveBeenCalledWith(
|
||||
'short',
|
||||
expect.any(Number),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('truncates text when it exceeds available width', () => {
|
||||
const ctx = createMockCtx();
|
||||
ctx.measureText = jest.fn(
|
||||
(t: string) =>
|
||||
({
|
||||
width: t.includes('...') ? 24 : t.length * 10,
|
||||
}) as TextMetrics,
|
||||
);
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, name: 'very-long-span-name', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 50,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
eventRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
const fillTextCalls = (ctx.fillText as jest.Mock).mock.calls;
|
||||
const nameArg = fillTextCalls.find((c) => c[0] !== '50ms')?.[0];
|
||||
expect(nameArg).toBeDefined();
|
||||
expect(nameArg).toMatch(/\.\.\.$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('drawEventDot', () => {
|
||||
it('uses error styling when isError is true', () => {
|
||||
const ctx = createMockCtx();
|
||||
const color = getEventDotColor('#000', true, false);
|
||||
|
||||
drawEventDot({
|
||||
ctx,
|
||||
x: 50,
|
||||
y: 11,
|
||||
color,
|
||||
eventDotSize: 6,
|
||||
});
|
||||
|
||||
expect(ctx.save).toHaveBeenCalled();
|
||||
expect(ctx.translate).toHaveBeenCalledWith(50, 11);
|
||||
expect(ctx.rotate).toHaveBeenCalledWith(Math.PI / 4);
|
||||
expect(ctx.fillStyle).toBe('rgb(220, 38, 38)');
|
||||
expect(ctx.strokeStyle).toBe('rgb(153, 27, 27)');
|
||||
expect(ctx.fillRect).toHaveBeenCalledWith(-3, -3, 6, 6);
|
||||
expect(ctx.strokeRect).toHaveBeenCalledWith(-3, -3, 6, 6);
|
||||
expect(ctx.restore).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('derives color from span color when isError is false', () => {
|
||||
const ctx = createMockCtx();
|
||||
const color = getEventDotColor('rgb(100, 200, 150)', false, false);
|
||||
|
||||
drawEventDot({
|
||||
ctx,
|
||||
x: 0,
|
||||
y: 0,
|
||||
color,
|
||||
eventDotSize: 6,
|
||||
});
|
||||
|
||||
// Darkened by 20% for fill
|
||||
expect(ctx.fillStyle).toBe('rgb(80, 160, 120)');
|
||||
// Darkened by 40% for stroke
|
||||
expect(ctx.strokeStyle).toBe('rgb(60, 120, 90)');
|
||||
});
|
||||
|
||||
it('uses dark mode colors for error', () => {
|
||||
const ctx = createMockCtx();
|
||||
const color = getEventDotColor('#000', true, true);
|
||||
|
||||
drawEventDot({
|
||||
ctx,
|
||||
x: 0,
|
||||
y: 0,
|
||||
color,
|
||||
eventDotSize: 6,
|
||||
});
|
||||
|
||||
expect(ctx.fillStyle).toBe('rgb(239, 68, 68)');
|
||||
expect(ctx.strokeStyle).toBe('rgb(185, 28, 28)');
|
||||
});
|
||||
|
||||
it('falls back to cyan/blue for unparseable span colors', () => {
|
||||
const ctx = createMockCtx();
|
||||
const color = getEventDotColor('hsl(200, 50%, 50%)', false, false);
|
||||
|
||||
drawEventDot({
|
||||
ctx,
|
||||
x: 0,
|
||||
y: 0,
|
||||
color,
|
||||
eventDotSize: 6,
|
||||
});
|
||||
|
||||
expect(ctx.fillStyle).toBe('rgb(6, 182, 212)');
|
||||
expect(ctx.strokeStyle).toBe('rgb(8, 145, 178)');
|
||||
});
|
||||
|
||||
it('calls save, translate, rotate, restore', () => {
|
||||
const ctx = createMockCtx();
|
||||
const color = getEventDotColor('#000', false, false);
|
||||
|
||||
drawEventDot({
|
||||
ctx,
|
||||
x: 10,
|
||||
y: 20,
|
||||
color,
|
||||
eventDotSize: 4,
|
||||
});
|
||||
|
||||
expect(ctx.save).toHaveBeenCalled();
|
||||
expect(ctx.translate).toHaveBeenCalledWith(10, 20);
|
||||
expect(ctx.rotate).toHaveBeenCalledWith(Math.PI / 4);
|
||||
expect(ctx.restore).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('solid l2-background fill for selected/hovered spans', () => {
|
||||
it('uses solid fill for hovered span', () => {
|
||||
const ctx = createMockCtx();
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, spanId: 'p', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: MIN_WIDTH_FOR_NAME - 1,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
eventRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
hoveredSpanId: 'p',
|
||||
});
|
||||
|
||||
expect(ctx.fill).toHaveBeenCalled();
|
||||
expect(ctx.stroke).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses solid fill + dashed border for selected span', () => {
|
||||
const ctx = createMockCtx();
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, spanId: 'p', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: MIN_WIDTH_FOR_NAME - 1,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
eventRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
selectedSpanId: 'p',
|
||||
});
|
||||
|
||||
expect(ctx.fill).toHaveBeenCalled();
|
||||
expect(ctx.stroke).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('drawSpanBar with events', () => {
|
||||
it('draws event dots for each span event', () => {
|
||||
const ctx = createMockCtx();
|
||||
const spanWithEvents = {
|
||||
...MOCK_SPAN,
|
||||
event: [
|
||||
{
|
||||
name: 'e1',
|
||||
timeUnixNano: 1_010_000_000,
|
||||
attributeMap: {},
|
||||
isError: false,
|
||||
},
|
||||
{
|
||||
name: 'e2',
|
||||
timeUnixNano: 1_025_000_000,
|
||||
attributeMap: {},
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: spanWithEvents,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
eventRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(ctx.save).toHaveBeenCalledTimes(3);
|
||||
expect(ctx.translate).toHaveBeenCalledTimes(2);
|
||||
expect(ctx.fillRect).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
/** Minimal FlamegraphSpan for unit tests */
|
||||
export const MOCK_SPAN: FlamegraphSpan = {
|
||||
timestamp: 1000,
|
||||
durationNano: 50_000_000, // 50ms
|
||||
spanId: 'span-1',
|
||||
parentSpanId: '',
|
||||
traceId: 'trace-1',
|
||||
hasError: false,
|
||||
serviceName: 'test-service',
|
||||
name: 'test-span',
|
||||
level: 0,
|
||||
event: [],
|
||||
};
|
||||
|
||||
/** Nested spans structure for findSpanById tests */
|
||||
export const MOCK_SPANS: FlamegraphSpan[][] = [
|
||||
[
|
||||
{
|
||||
...MOCK_SPAN,
|
||||
spanId: 'root',
|
||||
parentSpanId: '',
|
||||
level: 0,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
...MOCK_SPAN,
|
||||
spanId: 'child-a',
|
||||
parentSpanId: 'root',
|
||||
level: 1,
|
||||
},
|
||||
{
|
||||
...MOCK_SPAN,
|
||||
spanId: 'child-b',
|
||||
parentSpanId: 'root',
|
||||
level: 1,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
...MOCK_SPAN,
|
||||
spanId: 'grandchild',
|
||||
parentSpanId: 'child-a',
|
||||
level: 2,
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
export const MOCK_TRACE_METADATA = {
|
||||
startTime: 0,
|
||||
endTime: 1000,
|
||||
};
|
||||
@@ -1,144 +0,0 @@
|
||||
import React from 'react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { useFlamegraphDrag } from '../hooks/useFlamegraphDrag';
|
||||
import { MOCK_TRACE_METADATA } from './testUtils';
|
||||
|
||||
function createMockCanvas(): HTMLCanvasElement {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.getBoundingClientRect = jest.fn(
|
||||
(): DOMRect =>
|
||||
({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 400,
|
||||
x: 0,
|
||||
y: 0,
|
||||
bottom: 400,
|
||||
right: 800,
|
||||
toJSON: (): Record<string, unknown> => ({}),
|
||||
}) as DOMRect,
|
||||
);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function createMockContainer(): HTMLDivElement {
|
||||
const div = document.createElement('div');
|
||||
Object.defineProperty(div, 'clientHeight', { value: 400 });
|
||||
return div;
|
||||
}
|
||||
|
||||
const defaultArgs = {
|
||||
canvasRef: { current: createMockCanvas() },
|
||||
containerRef: { current: createMockContainer() },
|
||||
traceMetadata: MOCK_TRACE_METADATA,
|
||||
viewStartRef: { current: 0 },
|
||||
viewEndRef: { current: 1000 },
|
||||
setViewStartTs: jest.fn(),
|
||||
setViewEndTs: jest.fn(),
|
||||
scrollTopRef: { current: 0 },
|
||||
setScrollTop: jest.fn(),
|
||||
totalHeight: 1000,
|
||||
};
|
||||
|
||||
describe('useFlamegraphDrag', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
defaultArgs.viewStartRef.current = 0;
|
||||
defaultArgs.viewEndRef.current = 1000;
|
||||
defaultArgs.scrollTopRef.current = 0;
|
||||
});
|
||||
|
||||
it('starts drag state on mousedown', () => {
|
||||
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseDown({
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 50,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(result.current.isDraggingRef.current).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores non-left button mousedown', () => {
|
||||
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseDown({
|
||||
button: 1,
|
||||
clientX: 100,
|
||||
clientY: 50,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(result.current.isDraggingRef.current).toBe(false);
|
||||
});
|
||||
|
||||
it('updates pan/scroll on mousemove', () => {
|
||||
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseDown({
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 50,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown as React.MouseEvent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseMove({
|
||||
clientX: 150,
|
||||
clientY: 100,
|
||||
} as unknown as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(defaultArgs.setViewStartTs).toHaveBeenCalled();
|
||||
expect(defaultArgs.setViewEndTs).toHaveBeenCalled();
|
||||
expect(defaultArgs.setScrollTop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resets drag state on mouseup', () => {
|
||||
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseDown({
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 50,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown as React.MouseEvent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseUp();
|
||||
});
|
||||
|
||||
expect(result.current.isDraggingRef.current).toBe(false);
|
||||
});
|
||||
|
||||
it('cancels drag on mouseleave', () => {
|
||||
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseDown({
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 50,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown as React.MouseEvent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragMouseLeave();
|
||||
});
|
||||
|
||||
expect(result.current.isDraggingRef.current).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,179 +0,0 @@
|
||||
import type React from 'react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { useFlamegraphHover } from '../hooks/useFlamegraphHover';
|
||||
import type { SpanRect } from '../types';
|
||||
import { MOCK_SPAN, MOCK_TRACE_METADATA } from './testUtils';
|
||||
|
||||
function createMockCanvas(): HTMLCanvasElement {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 800;
|
||||
canvas.height = 400;
|
||||
canvas.getBoundingClientRect = jest.fn(
|
||||
(): DOMRect =>
|
||||
({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 400,
|
||||
x: 0,
|
||||
y: 0,
|
||||
bottom: 400,
|
||||
right: 800,
|
||||
toJSON: (): Record<string, unknown> => ({}),
|
||||
}) as DOMRect,
|
||||
);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
const spanRect: SpanRect = {
|
||||
span: { ...MOCK_SPAN, spanId: 'hover-span', name: 'test-span' },
|
||||
x: 100,
|
||||
y: 50,
|
||||
width: 200,
|
||||
height: 22,
|
||||
level: 0,
|
||||
};
|
||||
|
||||
const defaultArgs = {
|
||||
canvasRef: { current: createMockCanvas() },
|
||||
spanRectsRef: { current: [spanRect] },
|
||||
eventRectsRef: { current: [] as any[] },
|
||||
traceMetadata: MOCK_TRACE_METADATA,
|
||||
viewStartTs: MOCK_TRACE_METADATA.startTime,
|
||||
viewEndTs: MOCK_TRACE_METADATA.endTime,
|
||||
isDraggingRef: { current: false },
|
||||
onSpanClick: jest.fn(),
|
||||
isDarkMode: false,
|
||||
};
|
||||
|
||||
describe('useFlamegraphHover', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'devicePixelRatio', {
|
||||
configurable: true,
|
||||
value: 1,
|
||||
});
|
||||
jest.clearAllMocks();
|
||||
defaultArgs.spanRectsRef.current = [spanRect];
|
||||
defaultArgs.isDraggingRef.current = false;
|
||||
});
|
||||
|
||||
it('sets hoveredSpanId and tooltipContent when hovering on span', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseMove({
|
||||
clientX: 150,
|
||||
clientY: 61,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(result.current.hoveredSpanId).toBe('hover-span');
|
||||
expect(result.current.tooltipContent).not.toBeNull();
|
||||
expect(result.current.tooltipContent?.spanName).toBe('test-span');
|
||||
expect(result.current.tooltipContent?.clientX).toBe(150);
|
||||
expect(result.current.tooltipContent?.clientY).toBe(61);
|
||||
});
|
||||
|
||||
it('clears hover when moving to empty area', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseMove({
|
||||
clientX: 150,
|
||||
clientY: 61,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(result.current.hoveredSpanId).toBe('hover-span');
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseMove({
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(result.current.hoveredSpanId).toBeNull();
|
||||
expect(result.current.tooltipContent).toBeNull();
|
||||
});
|
||||
|
||||
it('clears hover on mouse leave', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseMove({
|
||||
clientX: 150,
|
||||
clientY: 61,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseLeave();
|
||||
});
|
||||
|
||||
expect(result.current.hoveredSpanId).toBeNull();
|
||||
expect(result.current.tooltipContent).toBeNull();
|
||||
});
|
||||
|
||||
it('suppresses click when drag distance exceeds threshold', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseDownForClick({
|
||||
clientX: 100,
|
||||
clientY: 50,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleClick({
|
||||
clientX: 150,
|
||||
clientY: 100,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(defaultArgs.onSpanClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onSpanClick when clicking on span', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleClick({
|
||||
clientX: 150,
|
||||
clientY: 61,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(defaultArgs.onSpanClick).toHaveBeenCalledWith('hover-span');
|
||||
});
|
||||
|
||||
it('uses clientX/clientY for tooltip positioning', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseMove({
|
||||
clientX: 200,
|
||||
clientY: 60,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(result.current.tooltipContent?.clientX).toBe(200);
|
||||
expect(result.current.tooltipContent?.clientY).toBe(60);
|
||||
});
|
||||
|
||||
it('does not update hover during drag', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
defaultArgs.isDraggingRef.current = true;
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseMove({
|
||||
clientX: 150,
|
||||
clientY: 61,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(result.current.hoveredSpanId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,279 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { DEFAULT_ROW_HEIGHT, MIN_VISIBLE_SPAN_MS } from '../constants';
|
||||
import { useFlamegraphZoom } from '../hooks/useFlamegraphZoom';
|
||||
import { MOCK_TRACE_METADATA } from './testUtils';
|
||||
|
||||
function createMockCanvas(): HTMLCanvasElement {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 800;
|
||||
canvas.height = 400;
|
||||
canvas.getBoundingClientRect = jest.fn(
|
||||
(): DOMRect =>
|
||||
({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 400,
|
||||
x: 0,
|
||||
y: 0,
|
||||
bottom: 400,
|
||||
right: 800,
|
||||
toJSON: (): Record<string, unknown> => ({}),
|
||||
}) as DOMRect,
|
||||
);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
describe('useFlamegraphZoom', () => {
|
||||
const traceMetadata = { ...MOCK_TRACE_METADATA };
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'devicePixelRatio', {
|
||||
configurable: true,
|
||||
value: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('handleResetZoom restores traceMetadata.startTime/endTime', () => {
|
||||
const setViewStartTs = jest.fn();
|
||||
const setViewEndTs = jest.fn();
|
||||
const setRowHeight = jest.fn();
|
||||
const viewStartRef = { current: 100 };
|
||||
const viewEndRef = { current: 500 };
|
||||
const rowHeightRef = { current: 30 };
|
||||
const canvasRef = { current: createMockCanvas() };
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFlamegraphZoom({
|
||||
canvasRef,
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
rowHeightRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setRowHeight,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleResetZoom();
|
||||
});
|
||||
|
||||
expect(setViewStartTs).toHaveBeenCalledWith(traceMetadata.startTime);
|
||||
expect(setViewEndTs).toHaveBeenCalledWith(traceMetadata.endTime);
|
||||
expect(setRowHeight).toHaveBeenCalledWith(DEFAULT_ROW_HEIGHT);
|
||||
expect(viewStartRef.current).toBe(traceMetadata.startTime);
|
||||
expect(viewEndRef.current).toBe(traceMetadata.endTime);
|
||||
expect(rowHeightRef.current).toBe(DEFAULT_ROW_HEIGHT);
|
||||
});
|
||||
|
||||
it('wheel zoom in decreases visible time range', async () => {
|
||||
const setViewStartTs = jest.fn();
|
||||
const setViewEndTs = jest.fn();
|
||||
const setRowHeight = jest.fn();
|
||||
const viewStartRef = { current: traceMetadata.startTime };
|
||||
const viewEndRef = { current: traceMetadata.endTime };
|
||||
const rowHeightRef = { current: DEFAULT_ROW_HEIGHT };
|
||||
const canvas = createMockCanvas();
|
||||
const canvasRef = { current: canvas };
|
||||
|
||||
renderHook(() =>
|
||||
useFlamegraphZoom({
|
||||
canvasRef,
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
rowHeightRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setRowHeight,
|
||||
}),
|
||||
);
|
||||
|
||||
const initialSpan = viewEndRef.current - viewStartRef.current;
|
||||
|
||||
await act(async () => {
|
||||
canvas.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
clientX: 400,
|
||||
deltaY: -100,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
});
|
||||
|
||||
expect(setViewStartTs).toHaveBeenCalled();
|
||||
expect(setViewEndTs).toHaveBeenCalled();
|
||||
const [newStart] = setViewStartTs.mock.calls[0] ?? [];
|
||||
const [newEnd] = setViewEndTs.mock.calls[0] ?? [];
|
||||
if (newStart != null && newEnd != null) {
|
||||
const newSpan = newEnd - newStart;
|
||||
expect(newSpan).toBeLessThan(initialSpan);
|
||||
}
|
||||
});
|
||||
|
||||
it('wheel zoom out increases visible time range', async () => {
|
||||
const setViewStartTs = jest.fn();
|
||||
const setViewEndTs = jest.fn();
|
||||
const setRowHeight = jest.fn();
|
||||
const halfSpan = (traceMetadata.endTime - traceMetadata.startTime) / 2;
|
||||
const viewStartRef = { current: traceMetadata.startTime + halfSpan * 0.25 };
|
||||
const viewEndRef = { current: traceMetadata.startTime + halfSpan * 0.75 };
|
||||
const rowHeightRef = { current: DEFAULT_ROW_HEIGHT };
|
||||
const canvas = createMockCanvas();
|
||||
const canvasRef = { current: canvas };
|
||||
|
||||
renderHook(() =>
|
||||
useFlamegraphZoom({
|
||||
canvasRef,
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
rowHeightRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setRowHeight,
|
||||
}),
|
||||
);
|
||||
|
||||
const initialSpan = viewEndRef.current - viewStartRef.current;
|
||||
|
||||
await act(async () => {
|
||||
canvas.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
clientX: 400,
|
||||
deltaY: 100,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
});
|
||||
|
||||
expect(setViewStartTs).toHaveBeenCalled();
|
||||
expect(setViewEndTs).toHaveBeenCalled();
|
||||
const [newStart] = setViewStartTs.mock.calls[0] ?? [];
|
||||
const [newEnd] = setViewEndTs.mock.calls[0] ?? [];
|
||||
if (newStart != null && newEnd != null) {
|
||||
const newSpan = newEnd - newStart;
|
||||
expect(newSpan).toBeGreaterThanOrEqual(initialSpan);
|
||||
}
|
||||
});
|
||||
|
||||
it('clamps zoom to MIN_VISIBLE_SPAN_MS', async () => {
|
||||
const setViewStartTs = jest.fn();
|
||||
const setViewEndTs = jest.fn();
|
||||
const setRowHeight = jest.fn();
|
||||
const viewStartRef = { current: traceMetadata.startTime };
|
||||
const viewEndRef = { current: traceMetadata.startTime + 100 };
|
||||
const rowHeightRef = { current: DEFAULT_ROW_HEIGHT };
|
||||
const canvas = createMockCanvas();
|
||||
const canvasRef = { current: canvas };
|
||||
|
||||
renderHook(() =>
|
||||
useFlamegraphZoom({
|
||||
canvasRef,
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
rowHeightRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setRowHeight,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
canvas.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
clientX: 400,
|
||||
deltaY: 10000,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
});
|
||||
|
||||
const [newStart] = setViewStartTs.mock.calls[0] ?? [];
|
||||
const [newEnd] = setViewEndTs.mock.calls[0] ?? [];
|
||||
if (newStart != null && newEnd != null) {
|
||||
const newSpan = newEnd - newStart;
|
||||
expect(newSpan).toBeGreaterThanOrEqual(MIN_VISIBLE_SPAN_MS);
|
||||
}
|
||||
});
|
||||
|
||||
it('clamps viewStart/viewEnd to trace bounds', async () => {
|
||||
const setViewStartTs = jest.fn();
|
||||
const setViewEndTs = jest.fn();
|
||||
const setRowHeight = jest.fn();
|
||||
const viewStartRef = { current: traceMetadata.startTime };
|
||||
const viewEndRef = { current: traceMetadata.endTime };
|
||||
const rowHeightRef = { current: DEFAULT_ROW_HEIGHT };
|
||||
const canvas = createMockCanvas();
|
||||
const canvasRef = { current: canvas };
|
||||
|
||||
renderHook(() =>
|
||||
useFlamegraphZoom({
|
||||
canvasRef,
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
rowHeightRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setRowHeight,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
canvas.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
clientX: 400,
|
||||
deltaY: -5000,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
});
|
||||
|
||||
const [newStart] = setViewStartTs.mock.calls[0] ?? [];
|
||||
const [newEnd] = setViewEndTs.mock.calls[0] ?? [];
|
||||
if (newStart != null && newEnd != null) {
|
||||
expect(newStart).toBeGreaterThanOrEqual(traceMetadata.startTime);
|
||||
expect(newEnd).toBeLessThanOrEqual(traceMetadata.endTime);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns isOverFlamegraphRef', () => {
|
||||
const canvasRef = { current: createMockCanvas() };
|
||||
const { result } = renderHook(() =>
|
||||
useFlamegraphZoom({
|
||||
canvasRef,
|
||||
traceMetadata,
|
||||
viewStartRef: { current: 0 },
|
||||
viewEndRef: { current: 1000 },
|
||||
rowHeightRef: { current: 24 },
|
||||
setViewStartTs: jest.fn(),
|
||||
setViewEndTs: jest.fn(),
|
||||
setRowHeight: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.isOverFlamegraphRef).toBeDefined();
|
||||
expect(result.current.isOverFlamegraphRef.current).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,212 +0,0 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useScrollToSpan } from '../hooks/useScrollToSpan';
|
||||
import { MOCK_SPANS, MOCK_TRACE_METADATA } from './testUtils';
|
||||
|
||||
function TestWrapper({
|
||||
firstSpanAtFetchLevel,
|
||||
spans,
|
||||
traceMetadata,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setScrollTop,
|
||||
}: {
|
||||
firstSpanAtFetchLevel: string;
|
||||
spans: typeof MOCK_SPANS;
|
||||
traceMetadata: typeof MOCK_TRACE_METADATA;
|
||||
setViewStartTs: Dispatch<SetStateAction<number>>;
|
||||
setViewEndTs: Dispatch<SetStateAction<number>>;
|
||||
setScrollTop: Dispatch<SetStateAction<number>>;
|
||||
}): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewStartRef = useRef(traceMetadata.startTime);
|
||||
const viewEndRef = useRef(traceMetadata.endTime);
|
||||
const scrollTopRef = useRef(0);
|
||||
|
||||
useScrollToSpan({
|
||||
firstSpanAtFetchLevel,
|
||||
spans,
|
||||
traceMetadata,
|
||||
containerRef,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
scrollTopRef,
|
||||
rowHeight: 24,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setScrollTop,
|
||||
});
|
||||
|
||||
return <div ref={containerRef} data-testid="container" />;
|
||||
}
|
||||
|
||||
describe('useScrollToSpan', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
|
||||
configurable: true,
|
||||
value: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not update when firstSpanAtFetchLevel is empty', async () => {
|
||||
const setViewStartTs = jest.fn();
|
||||
const setViewEndTs = jest.fn();
|
||||
const setScrollTop = jest.fn();
|
||||
|
||||
render(
|
||||
<TestWrapper
|
||||
firstSpanAtFetchLevel=""
|
||||
spans={MOCK_SPANS}
|
||||
traceMetadata={MOCK_TRACE_METADATA}
|
||||
setViewStartTs={setViewStartTs}
|
||||
setViewEndTs={setViewEndTs}
|
||||
setScrollTop={setScrollTop}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setViewStartTs).not.toHaveBeenCalled();
|
||||
expect(setViewEndTs).not.toHaveBeenCalled();
|
||||
expect(setScrollTop).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not update when spans are empty', async () => {
|
||||
const setViewStartTs = jest.fn();
|
||||
const setViewEndTs = jest.fn();
|
||||
const setScrollTop = jest.fn();
|
||||
|
||||
render(
|
||||
<TestWrapper
|
||||
firstSpanAtFetchLevel="root"
|
||||
spans={[]}
|
||||
traceMetadata={MOCK_TRACE_METADATA}
|
||||
setViewStartTs={setViewStartTs}
|
||||
setViewEndTs={setViewEndTs}
|
||||
setScrollTop={setScrollTop}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setViewStartTs).not.toHaveBeenCalled();
|
||||
expect(setViewEndTs).not.toHaveBeenCalled();
|
||||
expect(setScrollTop).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not update when target span not found', async () => {
|
||||
const setViewStartTs = jest.fn();
|
||||
const setViewEndTs = jest.fn();
|
||||
const setScrollTop = jest.fn();
|
||||
|
||||
render(
|
||||
<TestWrapper
|
||||
firstSpanAtFetchLevel="nonexistent"
|
||||
spans={MOCK_SPANS}
|
||||
traceMetadata={MOCK_TRACE_METADATA}
|
||||
setViewStartTs={setViewStartTs}
|
||||
setViewEndTs={setViewEndTs}
|
||||
setScrollTop={setScrollTop}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setViewStartTs).not.toHaveBeenCalled();
|
||||
expect(setViewEndTs).not.toHaveBeenCalled();
|
||||
expect(setScrollTop).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls setters when target span found', async () => {
|
||||
const setViewStartTs = jest.fn();
|
||||
const setViewEndTs = jest.fn();
|
||||
const setScrollTop = jest.fn();
|
||||
|
||||
const { getByTestId } = render(
|
||||
<TestWrapper
|
||||
firstSpanAtFetchLevel="grandchild"
|
||||
spans={MOCK_SPANS}
|
||||
traceMetadata={MOCK_TRACE_METADATA}
|
||||
setViewStartTs={setViewStartTs}
|
||||
setViewEndTs={setViewEndTs}
|
||||
setScrollTop={setScrollTop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByTestId('container')).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setViewStartTs).toHaveBeenCalled();
|
||||
expect(setViewEndTs).toHaveBeenCalled();
|
||||
expect(setScrollTop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const [viewStart] = setViewStartTs.mock.calls[0];
|
||||
const [viewEnd] = setViewEndTs.mock.calls[0];
|
||||
const [scrollTop] = setScrollTop.mock.calls[0];
|
||||
|
||||
expect(viewEnd - viewStart).toBeGreaterThan(0);
|
||||
expect(viewStart).toBeGreaterThanOrEqual(MOCK_TRACE_METADATA.startTime);
|
||||
expect(viewEnd).toBeLessThanOrEqual(MOCK_TRACE_METADATA.endTime);
|
||||
expect(scrollTop).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('centers span vertically (scrollTop centers span row)', async () => {
|
||||
const setScrollTop = jest.fn();
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestWrapper
|
||||
firstSpanAtFetchLevel="grandchild"
|
||||
spans={MOCK_SPANS}
|
||||
traceMetadata={MOCK_TRACE_METADATA}
|
||||
setViewStartTs={jest.fn()}
|
||||
setViewEndTs={jest.fn()}
|
||||
setScrollTop={setScrollTop}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(setScrollTop).toHaveBeenCalled());
|
||||
|
||||
const [scrollTop] = setScrollTop.mock.calls[0];
|
||||
const levelIndex = 2;
|
||||
const rowHeight = 24;
|
||||
const viewportHeight = 400;
|
||||
const expectedCenter =
|
||||
levelIndex * rowHeight - viewportHeight / 2 + rowHeight / 2;
|
||||
expect(scrollTop).toBeCloseTo(Math.max(0, expectedCenter), -1);
|
||||
});
|
||||
|
||||
it('zooms horizontally to span with 2x duration padding', async () => {
|
||||
const setViewStartTs = jest.fn();
|
||||
const setViewEndTs = jest.fn();
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestWrapper
|
||||
firstSpanAtFetchLevel="root"
|
||||
spans={MOCK_SPANS}
|
||||
traceMetadata={MOCK_TRACE_METADATA}
|
||||
setViewStartTs={setViewStartTs}
|
||||
setViewEndTs={setViewEndTs}
|
||||
setScrollTop={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setViewStartTs).toHaveBeenCalled();
|
||||
expect(setViewEndTs).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const [viewStart] = setViewStartTs.mock.calls[0];
|
||||
const [viewEnd] = setViewEndTs.mock.calls[0];
|
||||
const visibleWindow = viewEnd - viewStart;
|
||||
const rootSpan = MOCK_SPANS[0][0];
|
||||
const spanDurationMs = rootSpan.durationNano / 1e6;
|
||||
expect(visibleWindow).toBeGreaterThanOrEqual(Math.max(spanDurationMs * 2, 5));
|
||||
});
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
import {
|
||||
clamp,
|
||||
findSpanById,
|
||||
formatDuration,
|
||||
getFlamegraphRowMetrics,
|
||||
} from '../utils';
|
||||
import { MOCK_SPANS } from './testUtils';
|
||||
|
||||
jest.mock('container/TraceDetail/utils', () => ({
|
||||
convertTimeToRelevantUnit: (
|
||||
valueMs: number,
|
||||
): { time: number; timeUnitName: string } => {
|
||||
if (valueMs === 0) {
|
||||
return { time: 0, timeUnitName: 'ms' };
|
||||
}
|
||||
if (valueMs < 1) {
|
||||
return { time: valueMs, timeUnitName: 'ms' };
|
||||
}
|
||||
if (valueMs < 1000) {
|
||||
return { time: valueMs, timeUnitName: 'ms' };
|
||||
}
|
||||
if (valueMs < 60_000) {
|
||||
return { time: valueMs / 1000, timeUnitName: 's' };
|
||||
}
|
||||
if (valueMs < 3_600_000) {
|
||||
return { time: valueMs / 60_000, timeUnitName: 'm' };
|
||||
}
|
||||
return { time: valueMs / 3_600_000, timeUnitName: 'hr' };
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Pure Math and Data Utils', () => {
|
||||
describe('clamp', () => {
|
||||
it('returns value when within range', () => {
|
||||
expect(clamp(5, 0, 10)).toBe(5);
|
||||
expect(clamp(-3, -5, 5)).toBe(-3);
|
||||
});
|
||||
|
||||
it('returns min when value is below min', () => {
|
||||
expect(clamp(-1, 0, 10)).toBe(0);
|
||||
expect(clamp(2, 5, 10)).toBe(5);
|
||||
});
|
||||
|
||||
it('returns max when value is above max', () => {
|
||||
expect(clamp(11, 0, 10)).toBe(10);
|
||||
expect(clamp(100, 0, 50)).toBe(50);
|
||||
});
|
||||
|
||||
it('handles min === max', () => {
|
||||
expect(clamp(5, 7, 7)).toBe(7);
|
||||
expect(clamp(7, 7, 7)).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSpanById', () => {
|
||||
it('finds span in first level', () => {
|
||||
const result = findSpanById(MOCK_SPANS, 'root');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.span.spanId).toBe('root');
|
||||
expect(result?.levelIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('finds span in nested level', () => {
|
||||
const result = findSpanById(MOCK_SPANS, 'grandchild');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.span.spanId).toBe('grandchild');
|
||||
expect(result?.levelIndex).toBe(2);
|
||||
});
|
||||
|
||||
it('returns null when span not found', () => {
|
||||
expect(findSpanById(MOCK_SPANS, 'nonexistent')).toBeNull();
|
||||
});
|
||||
|
||||
it('handles empty spans', () => {
|
||||
expect(findSpanById([], 'root')).toBeNull();
|
||||
expect(findSpanById([[], []], 'root')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFlamegraphRowMetrics', () => {
|
||||
it('computes normal row height metrics (24px)', () => {
|
||||
const m = getFlamegraphRowMetrics(24);
|
||||
expect(m.ROW_HEIGHT).toBe(24);
|
||||
expect(m.SPAN_BAR_HEIGHT).toBe(22);
|
||||
expect(m.SPAN_BAR_Y_OFFSET).toBe(1);
|
||||
expect(m.EVENT_DOT_SIZE).toBe(6);
|
||||
});
|
||||
|
||||
it('clamps span bar height to max for large row heights', () => {
|
||||
const m = getFlamegraphRowMetrics(100);
|
||||
expect(m.SPAN_BAR_HEIGHT).toBe(22);
|
||||
expect(m.SPAN_BAR_Y_OFFSET).toBe(39);
|
||||
});
|
||||
|
||||
it('clamps span bar height to min for small row heights', () => {
|
||||
const m = getFlamegraphRowMetrics(6);
|
||||
expect(m.SPAN_BAR_HEIGHT).toBe(8);
|
||||
// spanBarYOffset = floor((6-8)/2) = -1 when bar exceeds row height
|
||||
expect(m.SPAN_BAR_Y_OFFSET).toBe(-1);
|
||||
});
|
||||
|
||||
it('clamps event dot size within min/max', () => {
|
||||
const mSmall = getFlamegraphRowMetrics(6);
|
||||
expect(mSmall.EVENT_DOT_SIZE).toBe(4);
|
||||
|
||||
const mLarge = getFlamegraphRowMetrics(24);
|
||||
expect(mLarge.EVENT_DOT_SIZE).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDuration', () => {
|
||||
it('formats nanos as ms', () => {
|
||||
// 1e6 nanos = 1ms
|
||||
expect(formatDuration(1_000_000)).toBe('1ms');
|
||||
});
|
||||
|
||||
it('formats larger durations as s/m/hr', () => {
|
||||
// 2e9 nanos = 2000ms = 2s
|
||||
expect(formatDuration(2_000_000_000)).toBe('2s');
|
||||
});
|
||||
|
||||
it('formats zero duration', () => {
|
||||
expect(formatDuration(0)).toBe('0ms');
|
||||
});
|
||||
|
||||
it('formats very small values', () => {
|
||||
// 1000 nanos = 0.001ms → mock returns { time: 0.001, timeUnitName: 'ms' }
|
||||
expect(formatDuration(1000)).toBe('0ms');
|
||||
});
|
||||
|
||||
it('formats decimal seconds correctly', () => {
|
||||
expect(formatDuration(1_500_000_000)).toBe('1.5s');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import { getSpanColor } from '../utils';
|
||||
import { MOCK_SPAN } from './testUtils';
|
||||
|
||||
const mockGenerateColor = jest.fn();
|
||||
|
||||
jest.mock('lib/uPlotLib/utils/generateColor', () => ({
|
||||
generateColor: (key: string, colorMap: Record<string, string>): string =>
|
||||
mockGenerateColor(key, colorMap),
|
||||
}));
|
||||
|
||||
describe('Presentation / Styling Utils', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGenerateColor.mockReturnValue('#2F80ED');
|
||||
});
|
||||
|
||||
describe('getSpanColor', () => {
|
||||
it('uses generated service color for normal span', () => {
|
||||
mockGenerateColor.mockReturnValue('#1890ff');
|
||||
|
||||
const color = getSpanColor({
|
||||
span: { ...MOCK_SPAN, hasError: false },
|
||||
isDarkMode: false,
|
||||
});
|
||||
|
||||
expect(mockGenerateColor).toHaveBeenCalledWith(
|
||||
MOCK_SPAN.serviceName,
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(color).toBe('#1890ff');
|
||||
});
|
||||
|
||||
it('overrides with error color in light mode when span has error', () => {
|
||||
mockGenerateColor.mockReturnValue('#1890ff');
|
||||
|
||||
const color = getSpanColor({
|
||||
span: { ...MOCK_SPAN, hasError: true },
|
||||
isDarkMode: false,
|
||||
});
|
||||
|
||||
expect(color).toBe('rgb(220, 38, 38)');
|
||||
});
|
||||
|
||||
it('overrides with error color in dark mode when span has error', () => {
|
||||
mockGenerateColor.mockReturnValue('#1890ff');
|
||||
|
||||
const color = getSpanColor({
|
||||
span: { ...MOCK_SPAN, hasError: true },
|
||||
isDarkMode: true,
|
||||
});
|
||||
|
||||
expect(color).toBe('rgb(239, 68, 68)');
|
||||
});
|
||||
|
||||
it('passes serviceName to generateColor', () => {
|
||||
getSpanColor({
|
||||
span: { ...MOCK_SPAN, serviceName: 'my-service' },
|
||||
isDarkMode: false,
|
||||
});
|
||||
|
||||
expect(mockGenerateColor).toHaveBeenCalledWith(
|
||||
'my-service',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,370 +0,0 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
export interface ConnectorLine {
|
||||
parentRow: number;
|
||||
childRow: number;
|
||||
timestampMs: number;
|
||||
serviceName: string;
|
||||
}
|
||||
|
||||
export interface VisualLayout {
|
||||
visualRows: FlamegraphSpan[][];
|
||||
spanToVisualRow: Record<string, number>;
|
||||
connectors: ConnectorLine[];
|
||||
totalVisualRows: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes an overlap-safe visual layout for flamegraph spans using DFS ordering.
|
||||
*
|
||||
* Builds a parent→children tree from parentSpanId, then traverses in DFS pre-order.
|
||||
* Each span is placed at parentRow+1 if free, otherwise scans upward row-by-row
|
||||
* until finding a non-overlapping row. This keeps children visually close to their
|
||||
* parents and avoids the BFS problem where distant siblings push children far down.
|
||||
*/
|
||||
export function computeVisualLayout(spans: FlamegraphSpan[][]): VisualLayout {
|
||||
const spanToVisualRow = new Map<string, number>();
|
||||
const visualRowsMap = new Map<number, FlamegraphSpan[]>();
|
||||
let maxRow = -1;
|
||||
|
||||
// Per-row interval list for overlap detection
|
||||
// Each entry: [startTime, endTime]
|
||||
const rowIntervals = new Map<number, Array<[number, number]>>();
|
||||
|
||||
// function hasOverlap(row: number, startTime: number, endTime: number): boolean {
|
||||
// const intervals = rowIntervals.get(row);
|
||||
// if (!intervals) {
|
||||
// return false;
|
||||
// }
|
||||
// for (const [s, e] of intervals) {
|
||||
// if (startTime < e && endTime > s) {
|
||||
// return true;
|
||||
// }
|
||||
// }
|
||||
// return false;
|
||||
// }
|
||||
|
||||
function addToRow(row: number, span: FlamegraphSpan): void {
|
||||
spanToVisualRow.set(span.spanId, row);
|
||||
let arr = visualRowsMap.get(row);
|
||||
if (!arr) {
|
||||
arr = [];
|
||||
visualRowsMap.set(row, arr);
|
||||
}
|
||||
arr.push(span);
|
||||
|
||||
const startTime = span.timestamp;
|
||||
const endTime = span.timestamp + span.durationNano / 1e6;
|
||||
let intervals = rowIntervals.get(row);
|
||||
if (!intervals) {
|
||||
intervals = [];
|
||||
rowIntervals.set(row, intervals);
|
||||
}
|
||||
intervals.push([startTime, endTime]);
|
||||
|
||||
if (row > maxRow) {
|
||||
maxRow = row;
|
||||
}
|
||||
}
|
||||
|
||||
// Flatten all spans and build lookup + children map
|
||||
const spanMap = new Map<string, FlamegraphSpan>();
|
||||
const childrenMap = new Map<string, FlamegraphSpan[]>();
|
||||
const allSpans: FlamegraphSpan[] = [];
|
||||
|
||||
for (const level of spans) {
|
||||
for (const span of level) {
|
||||
allSpans.push(span);
|
||||
spanMap.set(span.spanId, span);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract parentSpanId — the field may be missing at runtime when the API
|
||||
// returns `references` instead. Fall back to the first CHILD_OF reference.
|
||||
function getParentId(span: FlamegraphSpan): string {
|
||||
if (span.parentSpanId) {
|
||||
return span.parentSpanId;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const refs = (span as any).references as
|
||||
| Array<{ spanId?: string; refType?: string }>
|
||||
| undefined;
|
||||
if (refs) {
|
||||
for (const ref of refs) {
|
||||
if (ref.refType === 'CHILD_OF' && ref.spanId) {
|
||||
return ref.spanId;
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Build children map and identify roots
|
||||
const roots: FlamegraphSpan[] = [];
|
||||
|
||||
for (const span of allSpans) {
|
||||
const parentId = getParentId(span);
|
||||
if (!parentId || !spanMap.has(parentId)) {
|
||||
roots.push(span);
|
||||
} else {
|
||||
let children = childrenMap.get(parentId);
|
||||
if (!children) {
|
||||
children = [];
|
||||
childrenMap.set(parentId, children);
|
||||
}
|
||||
children.push(span);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort children by timestamp for deterministic ordering
|
||||
for (const [, children] of childrenMap) {
|
||||
children.sort((a, b) => b.timestamp - a.timestamp);
|
||||
}
|
||||
|
||||
// --- Subtree-unit placement ---
|
||||
// Compute each subtree's layout in isolation, then place as a unit
|
||||
// to guarantee parent-child adjacency within subtrees.
|
||||
|
||||
interface ShapeEntry {
|
||||
span: FlamegraphSpan;
|
||||
relativeRow: number;
|
||||
}
|
||||
|
||||
function hasOverlapIn(
|
||||
intervals: Map<number, Array<[number, number]>>,
|
||||
row: number,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
): boolean {
|
||||
const rowIntervals = intervals.get(row);
|
||||
if (!rowIntervals) {
|
||||
return false;
|
||||
}
|
||||
for (const [s, e] of rowIntervals) {
|
||||
if (startTime < e && endTime > s) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function addIntervalTo(
|
||||
intervals: Map<number, Array<[number, number]>>,
|
||||
row: number,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
): void {
|
||||
let arr = intervals.get(row);
|
||||
if (!arr) {
|
||||
arr = [];
|
||||
intervals.set(row, arr);
|
||||
}
|
||||
arr.push([startTime, endTime]);
|
||||
}
|
||||
|
||||
function hasConnectorConflict(
|
||||
intervals: Map<number, Array<[number, number]>>,
|
||||
row: number,
|
||||
point: number,
|
||||
): boolean {
|
||||
const rowIntervals = intervals.get(row);
|
||||
if (!rowIntervals) {
|
||||
return false;
|
||||
}
|
||||
for (const [s, e] of rowIntervals) {
|
||||
if (point >= s && point < e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasPointInSpan(
|
||||
connectorPoints: Map<number, number[]>,
|
||||
row: number,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
): boolean {
|
||||
const points = connectorPoints.get(row);
|
||||
if (!points) {
|
||||
return false;
|
||||
}
|
||||
for (const p of points) {
|
||||
if (p >= startTime && p < endTime) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function addConnectorPoint(
|
||||
connectorPoints: Map<number, number[]>,
|
||||
row: number,
|
||||
point: number,
|
||||
): void {
|
||||
let arr = connectorPoints.get(row);
|
||||
if (!arr) {
|
||||
arr = [];
|
||||
connectorPoints.set(row, arr);
|
||||
}
|
||||
arr.push(point);
|
||||
}
|
||||
|
||||
function computeSubtreeShape(rootSpan: FlamegraphSpan): ShapeEntry[] {
|
||||
const localIntervals = new Map<number, Array<[number, number]>>();
|
||||
const localConnectorPoints = new Map<number, number[]>();
|
||||
const shape: ShapeEntry[] = [];
|
||||
|
||||
// Place root span at relative row 0
|
||||
const rootStart = rootSpan.timestamp;
|
||||
const rootEnd = rootSpan.timestamp + rootSpan.durationNano / 1e6;
|
||||
shape.push({ span: rootSpan, relativeRow: 0 });
|
||||
addIntervalTo(localIntervals, 0, rootStart, rootEnd);
|
||||
|
||||
const children = childrenMap.get(rootSpan.spanId);
|
||||
if (children) {
|
||||
for (const child of children) {
|
||||
const childShape = computeSubtreeShape(child);
|
||||
const connectorX = child.timestamp;
|
||||
const offset = findPlacement(
|
||||
childShape,
|
||||
1,
|
||||
localIntervals,
|
||||
localConnectorPoints,
|
||||
connectorX,
|
||||
);
|
||||
|
||||
// Record connector points for intermediate rows (1 to offset-1)
|
||||
for (let r = 1; r < offset; r++) {
|
||||
addConnectorPoint(localConnectorPoints, r, connectorX);
|
||||
}
|
||||
|
||||
// Place child shape into local state at offset
|
||||
for (const entry of childShape) {
|
||||
const actualRow = entry.relativeRow + offset;
|
||||
shape.push({ span: entry.span, relativeRow: actualRow });
|
||||
const s = entry.span.timestamp;
|
||||
const e = entry.span.timestamp + entry.span.durationNano / 1e6;
|
||||
addIntervalTo(localIntervals, actualRow, s, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return shape;
|
||||
}
|
||||
|
||||
function findPlacement(
|
||||
shape: ShapeEntry[],
|
||||
minOffset: number,
|
||||
intervals: Map<number, Array<[number, number]>>,
|
||||
connectorPoints?: Map<number, number[]>,
|
||||
connectorX?: number,
|
||||
): number {
|
||||
// Track the first offset that passes Checks 1 & 2 as a fallback.
|
||||
// Check 3 (connector vs span) is monotonically failing: once it fails
|
||||
// at offset K, all offsets > K also fail (more intermediate rows).
|
||||
// If we can't satisfy Check 3, fall back to the best offset without it.
|
||||
let fallbackOffset = -1;
|
||||
|
||||
for (let offset = minOffset; ; offset++) {
|
||||
let passesSpanChecks = true;
|
||||
|
||||
// Check 1: span vs span (existing)
|
||||
for (const entry of shape) {
|
||||
const targetRow = entry.relativeRow + offset;
|
||||
const s = entry.span.timestamp;
|
||||
const e = entry.span.timestamp + entry.span.durationNano / 1e6;
|
||||
if (hasOverlapIn(intervals, targetRow, s, e)) {
|
||||
passesSpanChecks = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check 2: span vs existing connector points
|
||||
if (passesSpanChecks && connectorPoints) {
|
||||
for (const entry of shape) {
|
||||
const targetRow = entry.relativeRow + offset;
|
||||
const s = entry.span.timestamp;
|
||||
const e = entry.span.timestamp + entry.span.durationNano / 1e6;
|
||||
if (hasPointInSpan(connectorPoints, targetRow, s, e)) {
|
||||
passesSpanChecks = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!passesSpanChecks) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// This offset passes Checks 1 & 2 — record as fallback
|
||||
if (fallbackOffset === -1) {
|
||||
fallbackOffset = offset;
|
||||
}
|
||||
|
||||
// Check 3: new connector vs existing spans
|
||||
if (connectorX !== undefined) {
|
||||
let connectorClear = true;
|
||||
for (let r = 1; r < offset; r++) {
|
||||
if (hasConnectorConflict(intervals, r, connectorX)) {
|
||||
connectorClear = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!connectorClear) {
|
||||
// Check 3 will fail for all larger offsets too.
|
||||
// Fall back to the first offset that passed Checks 1 & 2.
|
||||
return fallbackOffset;
|
||||
}
|
||||
}
|
||||
|
||||
return offset;
|
||||
}
|
||||
}
|
||||
|
||||
// Process roots sorted by timestamp
|
||||
roots.sort((a, b) => a.timestamp - b.timestamp);
|
||||
for (const root of roots) {
|
||||
const shape = computeSubtreeShape(root);
|
||||
const offset = findPlacement(shape, 0, rowIntervals);
|
||||
for (const entry of shape) {
|
||||
addToRow(entry.relativeRow + offset, entry.span);
|
||||
}
|
||||
}
|
||||
|
||||
// Build the visualRows array
|
||||
const totalVisualRows = maxRow + 1;
|
||||
const visualRows: FlamegraphSpan[][] = [];
|
||||
for (let i = 0; i < totalVisualRows; i++) {
|
||||
visualRows.push(visualRowsMap.get(i) || []);
|
||||
}
|
||||
|
||||
// Build connector lines for parent-child pairs with row gap > 1
|
||||
const connectors: ConnectorLine[] = [];
|
||||
for (const [parentId, children] of childrenMap) {
|
||||
const parentRow = spanToVisualRow.get(parentId);
|
||||
if (parentRow === undefined) {
|
||||
continue;
|
||||
}
|
||||
for (const child of children) {
|
||||
const childRow = spanToVisualRow.get(child.spanId);
|
||||
if (childRow === undefined || childRow - parentRow <= 1) {
|
||||
continue;
|
||||
}
|
||||
connectors.push({
|
||||
parentRow,
|
||||
childRow,
|
||||
timestampMs: child.timestamp,
|
||||
serviceName: child.serviceName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
visualRows,
|
||||
spanToVisualRow: Object.fromEntries(spanToVisualRow),
|
||||
connectors,
|
||||
totalVisualRows,
|
||||
};
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
export const ROW_HEIGHT = 24;
|
||||
export const SPAN_BAR_HEIGHT = 22;
|
||||
export const SPAN_BAR_Y_OFFSET = Math.floor((ROW_HEIGHT - SPAN_BAR_HEIGHT) / 2);
|
||||
export const EVENT_DOT_SIZE = 6;
|
||||
|
||||
// Span bar sizing relative to row height (used by getFlamegraphRowMetrics)
|
||||
export const SPAN_BAR_HEIGHT_RATIO = SPAN_BAR_HEIGHT / ROW_HEIGHT;
|
||||
export const MIN_SPAN_BAR_HEIGHT = 8;
|
||||
export const MAX_SPAN_BAR_HEIGHT = SPAN_BAR_HEIGHT;
|
||||
|
||||
// Event dot sizing relative to span bar height
|
||||
export const EVENT_DOT_SIZE_RATIO = EVENT_DOT_SIZE / SPAN_BAR_HEIGHT;
|
||||
export const MIN_EVENT_DOT_SIZE = 4;
|
||||
export const MAX_EVENT_DOT_SIZE = EVENT_DOT_SIZE;
|
||||
|
||||
export const LABEL_FONT = '11px Inter, sans-serif';
|
||||
export const LABEL_PADDING_X = 8;
|
||||
export const MIN_WIDTH_FOR_NAME = 30;
|
||||
export const MIN_WIDTH_FOR_NAME_AND_DURATION = 80;
|
||||
|
||||
// Dynamic row height (vertical zoom) -- disabled for now (MIN === MAX)
|
||||
export const MIN_ROW_HEIGHT = 24;
|
||||
export const MAX_ROW_HEIGHT = 24;
|
||||
export const DEFAULT_ROW_HEIGHT = MIN_ROW_HEIGHT;
|
||||
|
||||
// Zoom intensity -- how fast zoom reacts to wheel/pinch delta
|
||||
export const PINCH_ZOOM_INTENSITY_H = 0.01;
|
||||
export const SCROLL_ZOOM_INTENSITY_H = 0.0015;
|
||||
export const PINCH_ZOOM_INTENSITY_V = 0.008;
|
||||
export const SCROLL_ZOOM_INTENSITY_V = 0.001;
|
||||
|
||||
// Minimum visible time span in ms (prevents zooming to sub-pixel)
|
||||
export const MIN_VISIBLE_SPAN_MS = 5;
|
||||
|
||||
// Selected span style (dashed border)
|
||||
export const DASHED_BORDER_LINE_DASH = [4, 2];
|
||||
|
||||
// Max spans fetched for flamegraph visualization
|
||||
export const FLAMEGRAPH_SPAN_LIMIT = 100002;
|
||||
@@ -1,66 +0,0 @@
|
||||
import { RefObject, useCallback, useEffect } from 'react';
|
||||
|
||||
export function useCanvasSetup(
|
||||
canvasRef: RefObject<HTMLCanvasElement>,
|
||||
containerRef: RefObject<HTMLDivElement>,
|
||||
onDraw: () => void,
|
||||
overlayCanvasRef?: RefObject<HTMLCanvasElement>,
|
||||
): 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;
|
||||
|
||||
// Sync overlay canvas size with main canvas
|
||||
const overlay = overlayCanvasRef?.current;
|
||||
if (overlay) {
|
||||
overlay.width = newWidth;
|
||||
overlay.height = newHeight;
|
||||
overlay.style.width = `${rect.width}px`;
|
||||
overlay.style.height = `${viewportHeight}px`;
|
||||
}
|
||||
|
||||
onDraw();
|
||||
}
|
||||
}, [canvasRef, containerRef, onDraw, overlayCanvasRef]);
|
||||
|
||||
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]);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { RefObject, useCallback, useEffect } from 'react';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
interface UseFlamegraphCrosshairArgs {
|
||||
overlayCanvasRef: RefObject<HTMLCanvasElement>;
|
||||
cursorX: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the crosshair vertical line on the flamegraph overlay canvas.
|
||||
* Separated from useFlamegraphDraw (which handles the main canvas)
|
||||
* to keep span rendering and crosshair rendering independent.
|
||||
*/
|
||||
export function useFlamegraphCrosshair({
|
||||
overlayCanvasRef,
|
||||
cursorX,
|
||||
}: UseFlamegraphCrosshairArgs): void {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const drawCrosshair = useCallback((): void => {
|
||||
const overlay = overlayCanvasRef.current;
|
||||
if (!overlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const ctx = overlay.getContext('2d');
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cssWidth = overlay.width / dpr;
|
||||
const cssHeight = overlay.height / dpr;
|
||||
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, cssWidth, cssHeight);
|
||||
|
||||
if (cursorX === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Vertical solid line — matches --l3-background token used by the time badge
|
||||
ctx.strokeStyle = isDarkMode ? '#2d313a' : '#e8e8ec';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cursorX, 0);
|
||||
ctx.lineTo(cursorX, cssHeight);
|
||||
ctx.stroke();
|
||||
}, [overlayCanvasRef, cursorX, isDarkMode]);
|
||||
|
||||
// Redraw whenever cursorX or dependencies change
|
||||
useEffect(() => {
|
||||
drawCrosshair();
|
||||
}, [drawCrosshair]);
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
import {
|
||||
Dispatch,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
MutableRefObject,
|
||||
RefObject,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
import { ITraceMetadata } from '../types';
|
||||
import { clamp } from '../utils';
|
||||
|
||||
interface UseFlamegraphDragArgs {
|
||||
canvasRef: RefObject<HTMLCanvasElement>;
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
traceMetadata: ITraceMetadata;
|
||||
viewStartRef: MutableRefObject<number>;
|
||||
viewEndRef: MutableRefObject<number>;
|
||||
setViewStartTs: Dispatch<SetStateAction<number>>;
|
||||
setViewEndTs: Dispatch<SetStateAction<number>>;
|
||||
scrollTopRef: MutableRefObject<number>;
|
||||
setScrollTop: Dispatch<SetStateAction<number>>;
|
||||
totalHeight: number;
|
||||
}
|
||||
|
||||
interface UseFlamegraphDragResult {
|
||||
handleMouseDown: (e: ReactMouseEvent) => void;
|
||||
handleMouseMove: (e: ReactMouseEvent) => void;
|
||||
handleMouseUp: () => void;
|
||||
handleDragMouseLeave: () => void;
|
||||
isDraggingRef: MutableRefObject<boolean>;
|
||||
}
|
||||
|
||||
export function useFlamegraphDrag(
|
||||
args: UseFlamegraphDragArgs,
|
||||
): UseFlamegraphDragResult {
|
||||
const {
|
||||
canvasRef,
|
||||
containerRef,
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
scrollTopRef,
|
||||
setScrollTop,
|
||||
totalHeight,
|
||||
} = args;
|
||||
|
||||
const isDraggingRef = useRef(false);
|
||||
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const dragDistanceRef = useRef(0);
|
||||
|
||||
const clampScrollTop = useCallback(
|
||||
(next: number): number => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return 0;
|
||||
}
|
||||
const viewportHeight = container.clientHeight;
|
||||
const maxScroll = Math.max(0, totalHeight - viewportHeight);
|
||||
return clamp(next, 0, maxScroll);
|
||||
},
|
||||
[containerRef, totalHeight],
|
||||
);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(event: ReactMouseEvent): void => {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
|
||||
isDraggingRef.current = true;
|
||||
dragStartRef.current = { x: event.clientX, y: event.clientY };
|
||||
dragDistanceRef.current = 0;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
canvas.style.cursor = 'grabbing';
|
||||
}
|
||||
},
|
||||
[canvasRef],
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(event: ReactMouseEvent): void => {
|
||||
if (!isDraggingRef.current || !dragStartRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const deltaX = event.clientX - dragStartRef.current.x;
|
||||
const deltaY = event.clientY - dragStartRef.current.y;
|
||||
|
||||
dragDistanceRef.current = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||
|
||||
// --- Horizontal pan ---
|
||||
const timeSpan = viewEndRef.current - viewStartRef.current;
|
||||
const deltaTime = (deltaX / rect.width) * timeSpan;
|
||||
|
||||
const newStart = viewStartRef.current - deltaTime;
|
||||
const clampedStart = clamp(
|
||||
newStart,
|
||||
traceMetadata.startTime,
|
||||
traceMetadata.endTime - timeSpan,
|
||||
);
|
||||
const clampedEnd = clampedStart + timeSpan;
|
||||
|
||||
viewStartRef.current = clampedStart;
|
||||
viewEndRef.current = clampedEnd;
|
||||
setViewStartTs(clampedStart);
|
||||
setViewEndTs(clampedEnd);
|
||||
|
||||
// --- Vertical scroll pan ---
|
||||
const nextScrollTop = clampScrollTop(scrollTopRef.current - deltaY);
|
||||
scrollTopRef.current = nextScrollTop;
|
||||
setScrollTop(nextScrollTop);
|
||||
|
||||
dragStartRef.current = { x: event.clientX, y: event.clientY };
|
||||
},
|
||||
[
|
||||
canvasRef,
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
scrollTopRef,
|
||||
setScrollTop,
|
||||
clampScrollTop,
|
||||
],
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback((): void => {
|
||||
isDraggingRef.current = false;
|
||||
dragStartRef.current = null;
|
||||
dragDistanceRef.current = 0;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
canvas.style.cursor = 'grab';
|
||||
}
|
||||
}, [canvasRef]);
|
||||
|
||||
// const handleDragMouseLeave = useCallback((): void => {
|
||||
// isDraggingRef.current = false;
|
||||
// dragStartRef.current = null;
|
||||
// dragDistanceRef.current = 0;
|
||||
|
||||
// const canvas = canvasRef.current;
|
||||
// if (canvas) {
|
||||
// canvas.style.cursor = 'grab';
|
||||
// }
|
||||
// }, [canvasRef]);
|
||||
|
||||
return {
|
||||
handleMouseDown,
|
||||
handleMouseMove,
|
||||
handleMouseUp,
|
||||
handleDragMouseLeave: handleMouseUp, // Same logic for mouse up and leaving the canvas
|
||||
isDraggingRef,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user