Compare commits

..

14 Commits

Author SHA1 Message Date
SagarRajput-7
06e521ad97 Merge branch 'base-path-config-setup-1' into base-path-task-phase-2 2026-04-21 11:04:29 +05:30
SagarRajput-7
a9dcad863b Merge branch 'main' into base-path-config-setup-1 2026-04-21 11:04:19 +05:30
Piyush Singariya
50b452080f chore: Removal of JSONDataType field (#10925)
* fix: tons of changes

* chore: remove redundent comparison

* ci: tests fixed

* fix: upgraded collector version

* fix: qbtoexpr tests

* fix: go sum

* chore: upgrade collector version v0.144.3-rc.4

* fix: tests

* ci: test fix

* revert: remove db binaries

* test: selectField tests added

* fix: added safeguards in plan generation

* fix: name changed to field_map

* fix: json access plan remval of AvailableTypes

* fix: invalid index usage on terminal condition

* fix: branches should tell missing array types

* fix: comment removed

* fix: issue with FuzzyMatching and API failing

* fix: int64 mapping

* ci: test and lint fix

* fix: test VisitKey

* test: running test for sku

* fix: buildFieldForJSON works

* fix: few minor changes

* fix: refactor tag vs field_key table

* fix: minor changes based on review

* revert: minor variable change

* fix: added more membership testcases

* revert: minor var names reverted

* ci: tests aligned

* fix: indexed expressions
2026-04-21 05:11:50 +00:00
SagarRajput-7
72db77b068 feat(base-path): migrate remaining pattern for window.location.origin + path 2026-04-21 10:33:24 +05:30
SagarRajput-7
4f84f07494 feat(base-path): replace window.open with openInNewTab for internal paths 2026-04-21 10:28:57 +05:30
SagarRajput-7
a9e09ee349 feat: code refactor around feedbacks 2026-04-21 10:25:02 +05:30
SagarRajput-7
0a9bb6ba0b feat: applied suggested patch changes 2026-04-21 10:24:51 +05:30
SagarRajput-7
6664a0fae3 feat: code refactor around feedbacks 2026-04-21 10:24:38 +05:30
SagarRajput-7
040dcb9c9b feat: updated base path utils and fixed navigation and translations 2026-04-21 10:24:23 +05:30
SagarRajput-7
4a39453826 feat: updated the html template 2026-04-21 10:24:12 +05:30
SagarRajput-7
ac4db09ec6 feat: removed plugin and serving the index.html only as the template 2026-04-21 10:24:01 +05:30
SagarRajput-7
a691e6a775 feat: refactor the interceptor and added gotmpl into gitignore 2026-04-21 10:23:51 +05:30
SagarRajput-7
d270a3807b feat: changed output path to dir level 2026-04-21 10:23:42 +05:30
SagarRajput-7
c2b553d26c feat: base path config setup and plugin for gotmpl generation at build time 2026-04-21 10:23:33 +05:30
199 changed files with 2847 additions and 18939 deletions

View File

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

View File

@@ -66,6 +66,8 @@ module.exports = {
rules: {
// Asset migration — base-path safety
'rulesdir/no-unsupported-asset-pattern': 'error',
// Base-path safety — window.open and origin-concat patterns; upgrade to error coming PR
'rulesdir/no-raw-absolute-path': 'warn',
// Code quality rules
'prefer-const': 'error', // Enforces const for variables never reassigned

View File

@@ -0,0 +1,153 @@
'use strict';
/**
* ESLint rule: no-raw-absolute-path
*
* Catches patterns that break at runtime when the app is served from a
* sub-path (e.g. /signoz/):
*
* 1. window.open(path, '_blank')
* → use openInNewTab(path) which calls withBasePath internally
*
* 2. window.location.origin + path / `${window.location.origin}${path}`
* → use getAbsoluteUrl(path)
*
* 3. frontendBaseUrl: window.location.origin (bare origin usage)
* → use getBaseUrl() to include the base path
*
* 4. window.location.href = path
* → use withBasePath(path) or navigate() for internal navigation
*
* External URLs (first arg starts with "http") are explicitly allowed.
*/
function isOriginAccess(node) {
return (
node.type === 'MemberExpression' &&
!node.computed &&
node.property.name === 'origin' &&
node.object.type === 'MemberExpression' &&
!node.object.computed &&
node.object.property.name === 'location' &&
node.object.object.type === 'Identifier' &&
node.object.object.name === 'window'
);
}
function isHrefAccess(node) {
return (
node.type === 'MemberExpression' &&
!node.computed &&
node.property.name === 'href' &&
node.object.type === 'MemberExpression' &&
!node.object.computed &&
node.object.property.name === 'location' &&
node.object.object.type === 'Identifier' &&
node.object.object.name === 'window'
);
}
function isExternalUrl(node) {
if (node.type === 'Literal' && typeof node.value === 'string') {
return node.value.startsWith('http://') || node.value.startsWith('https://');
}
if (node.type === 'TemplateLiteral' && node.quasis.length > 0) {
const raw = node.quasis[0].value.raw;
return raw.startsWith('http://') || raw.startsWith('https://');
}
return false;
}
// window.open(withBasePath(x)) and window.open(getAbsoluteUrl(x)) are already safe.
function isSafeHelperCall(node) {
return (
node.type === 'CallExpression' &&
node.callee.type === 'Identifier' &&
(node.callee.name === 'withBasePath' || node.callee.name === 'getAbsoluteUrl')
);
}
module.exports = {
meta: {
type: 'suggestion',
docs: {
description:
'Disallow raw window.open and origin-concatenation patterns that miss the runtime base path',
category: 'Base Path Safety',
},
schema: [],
messages: {
windowOpen:
'Use openInNewTab(path) instead of window.open(path, "_blank") — openInNewTab prepends the base path automatically.',
originConcat:
'Use getAbsoluteUrl(path) instead of window.location.origin + path — getAbsoluteUrl prepends the base path automatically.',
originDirect:
'Use getBaseUrl() instead of window.location.origin — getBaseUrl includes the base path.',
hrefAssign:
'Use withBasePath(path) or navigate() instead of window.location.href = path — ensures the base path is included.',
},
},
create(context) {
return {
// window.open(path, ...) — allow only external first-arg URLs
CallExpression(node) {
const { callee, arguments: args } = node;
if (
callee.type !== 'MemberExpression' ||
callee.object.type !== 'Identifier' ||
callee.object.name !== 'window' ||
callee.property.name !== 'open'
)
return;
if (args.length < 1) return;
if (isExternalUrl(args[0])) return;
if (isSafeHelperCall(args[0])) return;
context.report({ node, messageId: 'windowOpen' });
},
// window.location.origin + path
BinaryExpression(node) {
if (node.operator !== '+') return;
if (isOriginAccess(node.left) || isOriginAccess(node.right)) {
context.report({ node, messageId: 'originConcat' });
}
},
// `${window.location.origin}${path}`
TemplateLiteral(node) {
if (node.expressions.some(isOriginAccess)) {
context.report({ node, messageId: 'originConcat' });
}
},
// window.location.origin used directly (not in concatenation)
// Catches: frontendBaseUrl: window.location.origin
MemberExpression(node) {
if (!isOriginAccess(node)) return;
const parent = node.parent;
// Skip if parent is BinaryExpression with + (handled by BinaryExpression visitor)
if (parent.type === 'BinaryExpression' && parent.operator === '+') return;
// Skip if inside TemplateLiteral (handled by TemplateLiteral visitor)
if (parent.type === 'TemplateLiteral') return;
context.report({ node, messageId: 'originDirect' });
},
// window.location.href = path
AssignmentExpression(node) {
if (node.operator !== '=') return;
if (!isHrefAccess(node.left)) return;
// Allow external URLs
if (isExternalUrl(node.right)) return;
// Allow safe helper calls
if (isSafeHelperCall(node.right)) return;
context.report({ node, messageId: 'hrefAssign' });
},
};
},
};

View File

@@ -2,6 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<base href="[[.BaseHref]]" />
<meta
http-equiv="Cache-Control"
content="no-cache, no-store, must-revalidate, max-age: 0"
@@ -59,7 +60,7 @@
<meta data-react-helmet="true" name="docusaurus_locale" content="en" />
<meta data-react-helmet="true" name="docusaurus_tag" content="default" />
<meta name="robots" content="noindex" />
<link data-react-helmet="true" rel="shortcut icon" href="/favicon.ico" />
<link data-react-helmet="true" rel="shortcut icon" href="favicon.ico" />
</head>
<body data-theme="default">
<script>
@@ -136,7 +137,7 @@
})(document, 'script');
}
</script>
<link rel="stylesheet" href="/css/uPlot.min.css" />
<link rel="stylesheet" href="css/uPlot.min.css" />
<script type="module" src="./src/index.tsx"></script>
</body>
</html>

View File

@@ -62,11 +62,10 @@
"@signozhq/popover": "0.1.2",
"@signozhq/radio-group": "0.0.4",
"@signozhq/resizable": "0.0.2",
"@signozhq/tooltip": "0.0.2",
"@signozhq/ui": "0.0.6",
"@signozhq/tabs": "0.0.11",
"@signozhq/table": "0.3.8",
"@signozhq/toggle-group": "0.0.3",
"@signozhq/ui": "0.0.5",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
"@uiw/codemirror-theme-copilot": "4.23.11",
@@ -138,12 +137,10 @@
"react-helmet-async": "1.3.0",
"react-hook-form": "7.71.2",
"react-i18next": "^11.16.1",
"react-json-tree": "^0.20.0",
"react-lottie": "1.2.10",
"react-markdown": "8.0.7",
"react-query": "3.39.3",
"react-redux": "^7.2.2",
"react-rnd": "^10.5.3",
"react-router-dom": "^5.2.0",
"react-router-dom-v5-compat": "6.27.0",
"react-syntax-highlighter": "15.5.0",

View File

@@ -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'),
);

View File

@@ -48,7 +48,6 @@ import {
StatusPage,
SupportPage,
TraceDetail,
TraceDetailV3,
TraceFilter,
TracesExplorer,
TracesFunnelDetails,
@@ -142,16 +141,9 @@ const routes: AppRoutes[] = [
{
path: ROUTES.TRACE_DETAIL,
exact: true,
component: TraceDetailV3,
isPrivate: true,
key: 'TRACE_DETAIL',
},
{
path: ROUTES.TRACE_DETAIL_OLD,
exact: true,
component: TraceDetail,
isPrivate: true,
key: 'TRACE_DETAIL_OLD',
key: 'TRACE_DETAIL',
},
{
path: ROUTES.SETTINGS,

View File

@@ -2,6 +2,7 @@ import { initReactI18next } from 'react-i18next';
import i18n from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import Backend from 'i18next-http-backend';
import { getBasePath } from 'utils/basePath';
import cacheBursting from '../../i18n-translations-hash.json';
@@ -24,7 +25,7 @@ i18n
const ns = namespace[0];
const pathkey = `/${language}/${ns}`;
const hash = cacheBursting[pathkey as keyof typeof cacheBursting] || '';
return `/locales/${language}/${namespace}.json?h=${hash}`;
return `${getBasePath()}locales/${language}/${namespace}.json?h=${hash}`;
},
},
react: {

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import {
interceptorRejected,
interceptorsRequestBasePath,
interceptorsRequestResponse,
interceptorsResponse,
} from 'api';
@@ -17,6 +18,7 @@ export const GeneratedAPIInstance = <T>(
return generatedAPIAxiosInstance({ ...config }).then(({ data }) => data);
};
generatedAPIAxiosInstance.interceptors.request.use(interceptorsRequestBasePath);
generatedAPIAxiosInstance.interceptors.request.use(interceptorsRequestResponse);
generatedAPIAxiosInstance.interceptors.response.use(
interceptorsResponse,

View File

@@ -11,6 +11,7 @@ import axios, {
import { ENVIRONMENT } from 'constants/env';
import { Events } from 'constants/events';
import { LOCALSTORAGE } from 'constants/localStorage';
import { getBasePath } from 'utils/basePath';
import { eventEmitter } from 'utils/getEventEmitter';
import apiV1, { apiAlertManager, apiV2, apiV3, apiV4, apiV5 } from './apiV1';
@@ -67,6 +68,39 @@ export const interceptorsRequestResponse = (
return value;
};
// Strips the leading '/' from path and joins with base — idempotent if already prefixed.
// e.g. prependBase('/signoz/', '/api/v1/') → '/signoz/api/v1/'
function prependBase(base: string, path: string): string {
return path.startsWith(base) ? path : base + path.slice(1);
}
// Prepends the runtime base path to outgoing requests so API calls work under
// a URL prefix (e.g. /signoz/api/v1/…). No-op for root deployments and dev
// (dev baseURL is a full http:// URL, not an absolute path).
export const interceptorsRequestBasePath = (
value: InternalAxiosRequestConfig,
): InternalAxiosRequestConfig => {
const basePath = getBasePath();
if (basePath === '/') {
return value;
}
if (value.baseURL?.startsWith('/')) {
// Production relative baseURL: '/api/v1/' → '/signoz/api/v1/'
value.baseURL = prependBase(basePath, value.baseURL);
} else if (value.baseURL?.startsWith('http')) {
// Dev absolute baseURL (VITE_FRONTEND_API_ENDPOINT): 'https://host/api/v1/' → 'https://host/signoz/api/v1/'
const url = new URL(value.baseURL);
url.pathname = prependBase(basePath, url.pathname);
value.baseURL = url.toString();
} else if (!value.baseURL && value.url?.startsWith('/')) {
// Orval-generated client (empty baseURL, path in url): '/api/signoz/v1/rules' → '/signoz/api/signoz/v1/rules'
value.url = prependBase(basePath, value.url);
}
return value;
};
export const interceptorRejected = async (
value: AxiosResponse<any>,
): Promise<AxiosResponse<any>> => {
@@ -133,6 +167,7 @@ const instance = axios.create({
});
instance.interceptors.request.use(interceptorsRequestResponse);
instance.interceptors.request.use(interceptorsRequestBasePath);
instance.interceptors.response.use(interceptorsResponse, interceptorRejected);
export const AxiosAlertManagerInstance = axios.create({
@@ -147,6 +182,7 @@ ApiV2Instance.interceptors.response.use(
interceptorRejected,
);
ApiV2Instance.interceptors.request.use(interceptorsRequestResponse);
ApiV2Instance.interceptors.request.use(interceptorsRequestBasePath);
// axios V3
export const ApiV3Instance = axios.create({
@@ -158,6 +194,7 @@ ApiV3Instance.interceptors.response.use(
interceptorRejected,
);
ApiV3Instance.interceptors.request.use(interceptorsRequestResponse);
ApiV3Instance.interceptors.request.use(interceptorsRequestBasePath);
//
// axios V4
@@ -170,6 +207,7 @@ ApiV4Instance.interceptors.response.use(
interceptorRejected,
);
ApiV4Instance.interceptors.request.use(interceptorsRequestResponse);
ApiV4Instance.interceptors.request.use(interceptorsRequestBasePath);
//
// axios V5
@@ -182,6 +220,7 @@ ApiV5Instance.interceptors.response.use(
interceptorRejected,
);
ApiV5Instance.interceptors.request.use(interceptorsRequestResponse);
ApiV5Instance.interceptors.request.use(interceptorsRequestBasePath);
//
// axios Base
@@ -194,6 +233,7 @@ LogEventAxiosInstance.interceptors.response.use(
interceptorRejectedBase,
);
LogEventAxiosInstance.interceptors.request.use(interceptorsRequestResponse);
LogEventAxiosInstance.interceptors.request.use(interceptorsRequestBasePath);
//
AxiosAlertManagerInstance.interceptors.response.use(
@@ -201,6 +241,7 @@ AxiosAlertManagerInstance.interceptors.response.use(
interceptorRejected,
);
AxiosAlertManagerInstance.interceptors.request.use(interceptorsRequestResponse);
AxiosAlertManagerInstance.interceptors.request.use(interceptorsRequestBasePath);
export { apiV1 };
export default instance;

View File

@@ -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;

View File

@@ -24,8 +24,7 @@ import '@signozhq/input';
import '@signozhq/popover';
import '@signozhq/radio-group';
import '@signozhq/resizable';
import '@signozhq/tooltip';
import '@signozhq/ui';
import '@signozhq/tabs';
import '@signozhq/table';
import '@signozhq/toggle-group';
import '@signozhq/ui';

View File

@@ -12,6 +12,7 @@ import { AppState } from 'store/reducers';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { withBasePath } from 'utils/basePath';
export interface NavigateToExplorerProps {
filters: TagFilterItem[];
@@ -133,7 +134,7 @@ export function useNavigateToExplorer(): (
QueryParams.compositeQuery
}=${JSONCompositeQuery}`;
window.open(newExplorerPath, sameTab ? '_self' : '_blank');
window.open(withBasePath(newExplorerPath), sameTab ? '_self' : '_blank');
},
[
prepareQuery,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ import GetMinMax from 'lib/getMinMax';
import { Check, Info, Link2 } from 'lucide-react';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getAbsoluteUrl } from 'utils/basePath';
const routesToBeSharedWithTime = [
ROUTES.LOGS_EXPLORER,
@@ -80,17 +81,13 @@ function ShareURLModal(): JSX.Element {
urlQuery.delete(QueryParams.relativeTime);
currentUrl = `${window.location.origin}${
location.pathname
}?${urlQuery.toString()}`;
currentUrl = getAbsoluteUrl(`${location.pathname}?${urlQuery.toString()}`);
} else {
urlQuery.delete(QueryParams.startTime);
urlQuery.delete(QueryParams.endTime);
urlQuery.set(QueryParams.relativeTime, selectedTime);
currentUrl = `${window.location.origin}${
location.pathname
}?${urlQuery.toString()}`;
currentUrl = getAbsoluteUrl(`${location.pathname}?${urlQuery.toString()}`);
}
}

View File

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

View File

@@ -1,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;
}

View File

@@ -1,119 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { useMeasure } from 'react-use';
import { resolveTimeFromInterval } from 'components/TimelineV2/utils';
import { useIsDarkMode } from 'hooks/useDarkMode';
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 (01). 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 isDarkMode = useIsDarkMode();
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 <div />;
}
const strokeColor = isDarkMode ? ' rgb(192,193,195,0.8)' : 'black';
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;

View File

@@ -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;
}

View File

@@ -37,6 +37,5 @@ 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',
}

View File

@@ -32,7 +32,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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -9,6 +9,7 @@ import {
} from 'container/ApiMonitoring/utils';
import { UnfoldVertical } from 'lucide-react';
import { SuccessResponse } from 'types/api';
import { openInNewTab } from 'utils/navigation';
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
@@ -94,20 +95,14 @@ function DependentServices({
}}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => {
const url = new URL(
`/services/${
record.serviceData.serviceName &&
record.serviceData.serviceName !== '-'
? record.serviceData.serviceName
: ''
}`,
window.location.origin,
);
const serviceName =
record.serviceData.serviceName && record.serviceData.serviceName !== '-'
? record.serviceData.serviceName
: '';
const urlQuery = new URLSearchParams();
urlQuery.set(QueryParams.startTime, timeRange.startTime.toString());
urlQuery.set(QueryParams.endTime, timeRange.endTime.toString());
url.search = urlQuery.toString();
window.open(url.toString(), '_blank');
openInNewTab(`/services/${serviceName}?${urlQuery.toString()}`);
},
className: 'clickable-row',
})}

View File

@@ -14,6 +14,7 @@ import { IUser } from 'providers/App/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { USER_ROLES } from 'types/roles';
import { openInNewTab } from 'utils/navigation';
import { ROUTING_POLICIES_ROUTE } from './constants';
import { RoutingPolicyBannerProps } from './types';
@@ -387,7 +388,7 @@ export function NotificationChannelsNotFoundContent({
style={{ padding: '0 4px' }}
type="link"
onClick={(): void => {
window.open(ROUTES.CHANNELS_NEW, '_blank');
openInNewTab(ROUTES.CHANNELS_NEW);
}}
>
here.

View File

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

View File

@@ -15,6 +15,7 @@ import { AlertDef, Labels } from 'types/api/alerts/def';
import { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import { requireErrorMessage } from 'utils/form/requireErrorMessage';
import { openInNewTab } from 'utils/navigation';
import { popupContainer } from 'utils/selectPopupContainer';
import ChannelSelect from './ChannelSelect';
@@ -87,7 +88,7 @@ function BasicInfo({
dataSource: ALERTS_DATA_SOURCE_MAP[alertDef?.alertType as AlertTypes],
ruleId: isNewRule ? 0 : alertDef?.id,
});
window.open(ROUTES.CHANNELS_NEW, '_blank');
openInNewTab(ROUTES.CHANNELS_NEW);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const hasLoggedEvent = useRef(false);

View File

@@ -83,6 +83,8 @@ import {
} from 'types/api/dashboard/getAll';
import APIError from 'types/api/error';
import { isModifierKeyPressed } from 'utils/app';
import { getAbsoluteUrl } from 'utils/basePath';
import { openInNewTab } from 'utils/navigation';
import awwSnapUrl from '@/assets/Icons/awwSnap.svg';
import dashboardsUrl from '@/assets/Icons/dashboards.svg';
@@ -457,7 +459,7 @@ function DashboardsList(): JSX.Element {
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
window.open(getLink(), '_blank');
openInNewTab(getLink());
}}
>
Open in New Tab
@@ -469,7 +471,7 @@ function DashboardsList(): JSX.Element {
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
setCopy(`${window.location.origin}${getLink()}`);
setCopy(getAbsoluteUrl(getLink()));
}}
>
Copy Link

View File

@@ -1,6 +1,7 @@
import { LockFilled } from '@ant-design/icons';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { openInNewTab } from 'utils/navigation';
import { Data } from '../DashboardsList';
import { TableLinkText } from './styles';
@@ -12,7 +13,7 @@ function Name(name: Data['name'], data: Data): JSX.Element {
const onClickHandler = (event: React.MouseEvent<HTMLElement>): void => {
if (event.metaKey || event.ctrlKey) {
window.open(getLink(), '_blank');
openInNewTab(getLink());
} else {
history.push(getLink());
}

View File

@@ -17,6 +17,7 @@ import useUrlQuery from 'hooks/useUrlQuery';
import { ILog } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { withBasePath } from 'utils/basePath';
import { useContextLogData } from './useContextLogData';
@@ -116,7 +117,7 @@ function ContextLogRenderer({
);
const link = `${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`;
window.open(link, '_blank', 'noopener,noreferrer');
window.open(withBasePath(link), '_blank', 'noopener,noreferrer');
},
[query, urlQuery],
);

View File

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

View File

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

View File

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

View File

@@ -34,6 +34,7 @@ import { SET_DETAILED_LOG_DATA } from 'types/actions/logs';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { openInNewTab } from 'utils/navigation';
import { ActionItemProps } from './ActionItem';
import FieldRenderer from './FieldRenderer';
@@ -191,7 +192,7 @@ function TableView({
if (event.ctrlKey || event.metaKey) {
// open the trace in new tab
window.open(route, '_blank');
openInNewTab(route);
} else {
history.push(route);
}

View File

@@ -34,6 +34,7 @@ import ROUTES from 'constants/routes';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useDragColumns from 'hooks/useDragColumns';
import { getAbsoluteUrl } from 'utils/basePath';
import { infinityDefaultStyles } from '../InfinityTableView/config';
import { TanStackTableStyled } from '../InfinityTableView/styles';
@@ -239,7 +240,7 @@ const TanStackTableView = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
urlQuery.delete(QueryParams.activeLogId);
urlQuery.delete(QueryParams.relativeTime);
urlQuery.set(QueryParams.activeLogId, `"${logId}"`);
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
const link = getAbsoluteUrl(`${pathname}?${urlQuery.toString()}`);
setCopy(link);
toast.success('Copied to clipboard', { position: 'top-right' });

View File

@@ -1,5 +1,6 @@
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { withBasePath } from 'utils/basePath';
import { TopOperationList } from './TopOperationsTable';
import { NavigateToTraceProps } from './types';
@@ -37,7 +38,7 @@ export const navigateToTrace = ({
}=${JSONCompositeQuery}`;
if (openInNewTab) {
window.open(newTraceExplorerPath, '_blank');
window.open(withBasePath(newTraceExplorerPath), '_blank');
} else {
safeNavigate(newTraceExplorerPath);
}

View File

@@ -14,6 +14,7 @@ import ContextMenu from 'periscope/components/ContextMenu';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { ContextLinksData } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { openInNewTab } from 'utils/navigation';
import { ContextMenuItem } from './contextConfig';
import { getDataLinks } from './dataLinksUtils';
@@ -115,7 +116,7 @@ const useBaseAggregateOptions = ({
key={id}
icon={<LinkOutlined />}
onClick={(): void => {
window.open(url, '_blank');
openInNewTab(url);
onClose?.();
}}
>

View File

@@ -14,6 +14,7 @@ import { ModalTitle } from 'container/PipelinePage/PipelineListsView/styles';
import { Check, Loader, X } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { USER_ROLES } from 'types/roles';
import { openInNewTab } from 'utils/navigation';
import { INITIAL_ROUTING_POLICY_DETAILS_FORM_STATE } from './constants';
import {
@@ -76,7 +77,7 @@ function RoutingPolicyDetails({
style={{ padding: '0 4px' }}
type="link"
onClick={(): void => {
window.open(ROUTES.CHANNELS_NEW, '_blank');
openInNewTab(ROUTES.CHANNELS_NEW);
}}
>
here.

View File

@@ -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],

View File

@@ -28,6 +28,7 @@ import {
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { openInNewTab } from 'utils/navigation';
import { v4 as uuid } from 'uuid';
import noDataUrl from '@/assets/Icons/no-data.svg';
@@ -143,7 +144,7 @@ function SpanLogs({
const url = `${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`;
window.open(url, '_blank');
openInNewTab(url);
},
[
isLogSpanRelated,

View File

@@ -23,6 +23,7 @@
&-empty-content {
height: 100%;
border: 1px solid var(--l1-border);
border-top: none;
display: flex;
flex-direction: column;

View File

@@ -17,6 +17,7 @@ import { BarChart2, Compass, X } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Span } from 'types/api/trace/getTraceV2';
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
import { getAbsoluteUrl } from 'utils/basePath';
import { RelatedSignalsViews } from '../constants';
import SpanLogs from '../SpanLogs/SpanLogs';
@@ -158,9 +159,7 @@ function SpanRelatedSignals({
searchParams.set(QueryParams.endTime, endTimeMs.toString());
window.open(
`${window.location.origin}${
ROUTES.LOGS_EXPLORER
}?${searchParams.toString()}`,
getAbsoluteUrl(`${ROUTES.LOGS_EXPLORER}?${searchParams.toString()}`),
'_blank',
'noopener,noreferrer',
);

View File

@@ -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,

View File

@@ -31,6 +31,7 @@ import {
UPDATE_SPANS_AGGREGATE_PAGE_SIZE,
} from 'types/actions/trace';
import { TraceReducer } from 'types/reducer/trace';
import { openInNewTab } from 'utils/navigation';
import { v4 } from 'uuid';
dayjs.extend(duration);
@@ -214,7 +215,7 @@ function TraceTable(): JSX.Element {
event.preventDefault();
event.stopPropagation();
if (event.metaKey || event.ctrlKey) {
window.open(getLink(record), '_blank');
openInNewTab(getLink(record));
} else {
history.push(getLink(record));
}

View File

@@ -28,6 +28,7 @@ import { useTimezone } from 'providers/Timezone';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { openInNewTab } from 'utils/navigation';
import './TracesTableComponent.styles.scss';
@@ -86,7 +87,7 @@ function TracesTableComponent({
event.preventDefault();
event.stopPropagation();
if (event.metaKey || event.ctrlKey) {
window.open(getTraceLink(record), '_blank');
openInNewTab(getTraceLink(record));
} else {
history.push(getTraceLink(record));
}

View File

@@ -17,6 +17,7 @@ import useUrlQuery from 'hooks/useUrlQuery';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getAbsoluteUrl } from 'utils/basePath';
import { HIGHLIGHTED_DELAY } from './configs';
import { UseCopyLogLink } from './types';
@@ -60,7 +61,7 @@ export const useCopyLogLink = (logId?: string): UseCopyLogLink => {
urlQuery.set(QueryParams.startTime, minTime?.toString() || '');
urlQuery.set(QueryParams.endTime, maxTime?.toString() || '');
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
const link = getAbsoluteUrl(`${pathname}?${urlQuery.toString()}`);
setCopy(link);

View File

@@ -1,22 +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';
// 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;
}
import { Span } from 'types/api/trace/getTraceV2';
import { getAbsoluteUrl } from 'utils/basePath';
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) => {
@@ -29,20 +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 = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
const link = getAbsoluteUrl(`${pathname}?${urlQuery.toString()}`);
setCopy(link);
toast.success('Copied to clipboard', {
richColors: true,
position: 'top-right',
notifications.success({
message: 'Copied to clipboard',
});
},
[span, urlQuery, pathname, setCopy],
[span, urlQuery, pathname, setCopy, notifications],
);
return {

View File

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

View File

@@ -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;

View File

@@ -1,6 +1,7 @@
import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
import { cloneDeep, isEqual } from 'lodash-es';
import { withBasePath } from 'utils/basePath';
interface NavigateOptions {
replace?: boolean;
@@ -130,7 +131,7 @@ export const useSafeNavigate = (
typeof to === 'string'
? to
: `${to.pathname || location.pathname}${to.search || ''}`;
window.open(targetPath, '_blank');
window.open(withBasePath(targetPath), '_blank');
return;
}

View File

@@ -1,3 +1,4 @@
import { createBrowserHistory } from 'history';
import { getBasePath } from 'utils/basePath';
export default createBrowserHistory();
export default createBrowserHistory({ basename: getBasePath() });

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import ROUTES from 'constants/routes';
import { handleContactSupport } from 'container/Integrations/utils';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { Home, LifeBuoy } from 'lucide-react';
import { withBasePath } from 'utils/basePath';
import cloudUrl from '@/assets/Images/cloud.svg';
@@ -11,8 +12,8 @@ import './ErrorBoundaryFallback.styles.scss';
function ErrorBoundaryFallback(): JSX.Element {
const handleReload = (): void => {
// Go to home page
window.location.href = ROUTES.HOME;
// Hard reload resets Sentry.ErrorBoundary state; withBasePath preserves any /signoz/ prefix.
window.location.href = withBasePath(ROUTES.HOME);
};
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();

View File

@@ -19,6 +19,7 @@ import {
} from 'pages/MessagingQueues/MessagingQueuesUtils';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { openInNewTab } from 'utils/navigation';
import {
convertToMilliseconds,
@@ -93,7 +94,7 @@ export function getColumns(
key={item}
className="traceid-text"
onClick={(): void => {
window.open(`${ROUTES.TRACE}/${item}`, '_blank');
openInNewTab(`${ROUTES.TRACE}/${item}`);
logEvent(`MQ Kafka: Drop Rate - traceid navigation`, {
item,
});
@@ -123,7 +124,7 @@ export function getColumns(
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
window.open(`/services/${encodeURIComponent(text)}`, '_blank');
openInNewTab(`/services/${encodeURIComponent(text)}`);
}}
>
{text}

View File

@@ -1,5 +0,0 @@
import TraceDetailsV3 from '../TraceDetailsV3';
export default function TraceDetailV3Page(): JSX.Element {
return <TraceDetailsV3 />;
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

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

View File

@@ -1,118 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { 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;

View File

@@ -1,147 +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: flex;
flex-wrap: wrap;
gap: 0;
.key-value-label {
flex: 1 1 50%;
min-width: 120px;
overflow: hidden;
}
}
&__service-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--bg-forest-500);
}
&__trace-id {
color: var(--accent-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
&__key-attributes {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0;
&-label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--l2-foreground);
text-transform: uppercase;
letter-spacing: 0.48px;
line-height: var(--line-height-20);
}
&-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
}
}

View File

@@ -1,607 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { Button } from '@signozhq/button';
import {
Bookmark,
CalendarClock,
ChartBar,
ChartColumnBig,
Dock,
Link2,
Logs,
PanelBottom,
ScrollText,
Timer,
} from '@signozhq/icons';
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from '@signozhq/ui';
import { Skeleton, Tooltip } from 'antd';
import { DetailsHeader, DetailsPanelDrawer } from 'components/DetailsPanel';
import { HeaderAction } from 'components/DetailsPanel/DetailsHeader/DetailsHeader';
import { DetailsPanelState } from 'components/DetailsPanel/types';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { QueryParams } from 'constants/query';
import {
initialQueryBuilderFormValuesMap,
initialQueryState,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
import Events from 'container/SpanDetailsDrawer/Events/Events';
import SpanLogs from 'container/SpanDetailsDrawer/SpanLogs/SpanLogs';
import { useSpanContextLogs } from 'container/SpanDetailsDrawer/SpanLogs/useSpanContextLogs';
import dayjs from 'dayjs';
import { 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 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());
window.open(
`${window.location.origin}${
ROUTES.LOGS_EXPLORER
}?${searchParams.toString()}`,
'_blank',
'noopener,noreferrer',
);
}, [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"
prefixIcon={<ChartBar size={14} />}
onClick={(): void => setIsAnalyticsOpen((prev) => !prev)}
>
Analytics
</Button>
),
},
// TODO: Add back when driven through separate config for different pages
// {
// key: 'view-full-trace',
// component: (
// <Button variant="ghost" size="sm" color="secondary" prefixIcon={<ExternalLink size={14} />} onClick={noop}>
// View full trace
// </Button>
// ),
// },
// TODO: Add back when used in trace explorer page
// {
// key: 'nav',
// component: (
// <div className="span-details-panel__header-nav">
// <Button variant="ghost" size="icon" color="secondary" onClick={noop}><ChevronUp size={14} /></Button>
// <Button variant="ghost" size="icon" color="secondary" onClick={noop}><ChevronDown size={14} /></Button>
// </div>
// ),
// },
];
if (onVariantChange) {
const isDocked = variant === SpanDetailVariant.DOCKED;
actions.push({
key: 'dock-toggle',
component: (
<Tooltip title={isDocked ? 'Open as floating panel' : 'Dock on the side'}>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={(): void =>
onVariantChange(
isDocked ? SpanDetailVariant.DIALOG : SpanDetailVariant.DOCKED,
)
}
>
{isDocked ? <Dock size={14} /> : <PanelBottom size={14} />}
</Button>
</Tooltip>
),
});
}
return actions;
}, [variant, onVariantChange]);
const PANEL_WIDTH = 500;
const PANEL_MARGIN_RIGHT = 20;
const PANEL_MARGIN_TOP = 25;
const PANEL_MARGIN_BOTTOM = 25;
const content = (
<>
<DetailsHeader
title="Span details"
onClose={panelState.close}
actions={headerActions}
className={
variant === SpanDetailVariant.DIALOG ? 'floating-panel__drag-handle' : ''
}
/>
{selectedSpan ? (
<SpanDetailsContent
selectedSpan={selectedSpan}
traceStartTime={traceStartTime}
traceEndTime={traceEndTime}
/>
) : (
<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;

View File

@@ -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(--text-sakura-400, #f56c87);
}
&__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(--text-sakura-400, #f56c87);
}
&-loader {
display: inline-flex;
align-items: flex-end;
margin: 0 4px;
line-height: 18px;
}
}
&__timerange {
width: 100%;
&-select {
width: 100%;
margin-top: 8px;
margin-bottom: 16px;
.ant-select-selector {
border-radius: 50px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
color: var(--l1-foreground);
font-size: 12px;
height: 32px;
}
}
}
&__table {
&-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
&-text {
color: var(--l1-foreground);
font-size: 11px;
font-weight: 500;
line-height: 20px;
text-transform: uppercase;
}
}
&-rows {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
&-skeleton {
.ant-skeleton-title {
width: 100% !important;
margin-top: 0 !important;
}
.ant-skeleton-paragraph {
margin-top: 8px;
& > li + li {
margin-top: 10px;
width: 100% !important;
}
}
}
&-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 4px;
&-key {
flex: 0 0 auto;
color: var(--l1-foreground);
font-size: 12px;
font-weight: 500;
line-height: 20px;
}
&-value {
color: var(--l2-foreground);
font-size: 12px;
line-height: 20px;
}
&-dash {
flex: 1;
height: 0;
margin: 0 8px;
border-top: 1px solid transparent;
border-image: repeating-linear-gradient(
to right,
var(--l1-border) 0,
var(--l1-border) 10px,
transparent 10px,
transparent 20px
)
1 stretch;
}
&--current {
border-radius: 2px;
background: rgba(78, 116, 248, 0.2);
.span-percentile-panel__table-row-key {
color: var(--text-robin-300);
}
.span-percentile-panel__table-row-dash {
border-image: repeating-linear-gradient(
to right,
#abbdff 0,
#abbdff 10px,
transparent 10px,
transparent 20px
)
1 stretch;
}
.span-percentile-panel__table-row-value {
color: var(--text-robin-400);
}
}
}
}
&__resource-selector {
overflow: hidden;
width: calc(100% + 16px);
position: absolute;
top: 32px;
left: -8px;
z-index: 1000;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
&-header {
border-bottom: 1px solid var(--l1-border);
}
&-input {
border-radius: 0;
border: none !important;
box-shadow: none !important;
height: 36px;
}
&-items {
height: 200px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 0.3rem;
height: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--l3-background);
}
}
&-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
&-value {
color: var(--l1-foreground);
font-size: 13px;
line-height: 20px;
}
}
}
}

View File

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

View File

@@ -1,224 +0,0 @@
import { Checkbox, Input, Select, Skeleton, Typography } from 'antd';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { Check, ChevronDown, Loader2, PlusIcon } from 'lucide-react';
import { 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;

View File

@@ -1,331 +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;

View File

@@ -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,
},
];

View File

@@ -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',
],
};

View File

@@ -1,219 +0,0 @@
import React, { useCallback } from 'react';
import { useQueryClient } from 'react-query';
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 { useNotifications } from 'hooks/useNotifications';
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 { notifications } = useNotifications();
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 {
notifications.error({ message: SOMETHING_WENT_WRONG });
}
},
[
currentQuery,
getAutocompleteKey,
redirectWithQueryBuilderData,
notifications,
],
);
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 {
notifications.error({ message: SOMETHING_WENT_WRONG });
}
},
[
currentQuery,
getAutocompleteKey,
redirectWithQueryBuilderData,
notifications,
],
);
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,
},
];
}

View File

@@ -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(--bg-cherry-500);
}
}
&__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);
}
}

View File

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

View File

@@ -1,44 +0,0 @@
.span-hover-card-wrapper {
display: flex;
width: 100%;
height: 100%;
min-width: 0;
}
.span-hover-card-popover {
.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
.flamegraph-tooltip {
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);
}
}

View File

@@ -1,141 +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;

View File

@@ -1,39 +0,0 @@
.trace-details-filter {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
&__search {
flex: 1;
min-width: 0;
}
&__scope {
flex-shrink: 0;
}
&__nav {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
white-space: nowrap;
.ant-typography {
font-size: 12px;
color: var(--l2-foreground);
}
.ant-btn {
padding: 2px 4px;
height: auto;
}
}
&__no-results {
font-size: 12px;
color: var(--l2-foreground);
white-space: nowrap;
}
}

View File

@@ -1,262 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { InfoCircleOutlined, LoadingOutlined } from '@ant-design/icons';
import { Button, Spin, Tooltip, Typography } from 'antd';
import { AxiosError } from 'axios';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBuilderV2/utils';
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import SpanScopeSelector from 'container/QueryBuilder/filters/QueryBuilderSearchV2/SpanScopeSelector';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { uniqBy } from 'lodash-es';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
Query,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import {
DataSource,
TracesAggregatorOperator,
} from 'types/common/queryBuilder';
import './TraceDetailsFilter.styles.scss';
interface TraceDetailsFilterProps {
startTime: number;
endTime: number;
traceId: string;
onFilteredSpansChange: (spanIds: string[], isFilterActive: boolean) => void;
}
const TRACE_ID_FILTER_ITEM = {
id: 'trace-details-trace-id',
key: {
key: 'trace_id',
dataType: DataTypes.String,
type: '',
id: 'trace_id--string----true',
},
op: '=',
value: '',
};
function buildInitialQuery(): IBuilderQuery {
return {
...initialQueriesMap.traces.builder.queryData[0],
aggregateOperator: TracesAggregatorOperator.NOOP,
orderBy: [{ columnName: 'timestamp', order: 'asc' }],
dataSource: DataSource.TRACES,
filters: { items: [], op: 'AND' },
filter: { expression: '' },
};
}
function prepareApiQuery(builderQuery: IBuilderQuery, traceId: string): Query {
return {
...initialQueriesMap.traces,
builder: {
...initialQueriesMap.traces.builder,
queryData: [
{
...builderQuery,
// Inject trace_id filter for scoping results to this trace
filters: {
op: builderQuery.filters?.op || 'AND',
items: [
...(builderQuery.filters?.items || []),
{ ...TRACE_ID_FILTER_ITEM, value: traceId },
],
},
},
],
},
};
}
function TraceDetailsFilter({
startTime,
endTime,
traceId,
onFilteredSpansChange,
}: TraceDetailsFilterProps): JSX.Element {
const [builderQuery, setBuilderQuery] = useState<IBuilderQuery>(
buildInitialQuery,
);
const [noData, setNoData] = useState(false);
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
const [currentSearchedIndex, setCurrentSearchedIndex] = useState(0);
const { search } = useLocation();
const history = useHistory();
const expression = builderQuery.filter?.expression || '';
const hasActiveFilter =
(builderQuery.filters?.items || []).length > 0 ||
expression.trim().length > 0;
// Called by QuerySearch when user types in the CodeMirror editor
const handleExpressionChange = useCallback(
(value: string): void => {
setBuilderQuery((prev) => {
if (!value.trim() && (prev.filters?.items || []).length === 0) {
onFilteredSpansChange([], false);
setFilteredSpanIds([]);
setCurrentSearchedIndex(0);
setNoData(false);
}
return {
...prev,
filter: { expression: value },
};
});
},
[onFilteredSpansChange],
);
// Called by SpanScopeSelector when scope changes (All Spans / Root Spans etc.)
// Merges the scope filter items into filter.expression using the shared util
const handleScopeChange = useCallback((value: TagFilter): void => {
setBuilderQuery((prev) => {
const currentExpression = prev.filter?.expression || '';
const {
filters: mergedFilters,
filter: mergedFilter,
} = convertFiltersToExpressionWithExistingQuery(value, currentExpression);
return {
...prev,
filters: mergedFilters,
filter: mergedFilter,
};
});
}, []);
const handlePrevNext = useCallback(
(index: number, spanId?: string): void => {
const searchParams = new URLSearchParams(search);
if (spanId) {
searchParams.set('spanId', spanId);
} else {
searchParams.set('spanId', filteredSpanIds[index]);
}
history.replace({ search: searchParams.toString() });
},
[filteredSpanIds, history, search],
);
const query = useMemo(() => prepareApiQuery(builderQuery, traceId), [
builderQuery,
traceId,
]);
const { isFetching, error } = useGetQueryRange(
{
query,
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
start: startTime,
end: endTime,
params: {
dataSource: 'traces',
},
tableParams: {
pagination: {
offset: 0,
limit: 200,
},
selectColumns: [
{
key: 'name',
dataType: 'string',
type: 'tag',
id: 'name--string--tag--true',
isIndexed: false,
},
],
},
},
DEFAULT_ENTITY_VERSION,
{
queryKey: [builderQuery.filter, builderQuery.filters],
enabled: hasActiveFilter,
onSuccess: (data) => {
if (data?.payload.data.newResult.data.result[0].list) {
const uniqueSpans = uniqBy(
data?.payload.data.newResult.data.result[0].list,
'data.spanID',
);
const spanIds = uniqueSpans.map((val) => val.data.spanID);
setFilteredSpanIds(spanIds);
onFilteredSpansChange(spanIds, true);
handlePrevNext(0, spanIds[0]);
setNoData(false);
} else {
setNoData(true);
setFilteredSpanIds([]);
onFilteredSpansChange([], true);
setCurrentSearchedIndex(0);
}
},
},
);
return (
<div className="trace-details-filter">
<div className="trace-details-filter__search">
<QuerySearch
queryData={builderQuery}
onChange={handleExpressionChange}
dataSource={DataSource.TRACES}
placeholder="Search Filter : select options from suggested values, for IN/NOT IN operators - press 'Enter' after selecting options"
/>
</div>
<div className="trace-details-filter__scope">
<SpanScopeSelector
query={builderQuery}
onChange={handleScopeChange}
skipQueryBuilderRedirect
/>
</div>
{filteredSpanIds.length > 0 && (
<div className="trace-details-filter__nav">
<Typography.Text>
{currentSearchedIndex + 1} / {filteredSpanIds.length}
</Typography.Text>
<Button
icon={<ChevronUp size={14} />}
disabled={currentSearchedIndex === 0}
type="text"
onClick={(): void => {
handlePrevNext(currentSearchedIndex - 1);
setCurrentSearchedIndex((prev) => prev - 1);
}}
/>
<Button
icon={<ChevronDown size={14} />}
type="text"
disabled={currentSearchedIndex === filteredSpanIds.length - 1}
onClick={(): void => {
handlePrevNext(currentSearchedIndex + 1);
setCurrentSearchedIndex((prev) => prev + 1);
}}
/>
</div>
)}
{isFetching && <Spin indicator={<LoadingOutlined spin />} size="small" />}
{error && (
<Tooltip title={(error as AxiosError)?.message || 'Something went wrong'}>
<InfoCircleOutlined />
</Tooltip>
)}
{noData && (
<Typography.Text className="trace-details-filter__no-results">
No results found
</Typography.Text>
)}
</div>
);
}
export default TraceDetailsFilter;

View File

@@ -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;
}
}

View File

@@ -1,92 +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 &&
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;

View File

@@ -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;
}
}

View File

@@ -1,284 +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';
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={{
position: 'fixed',
left: Math.min(tooltipContent.clientX + 15, window.innerWidth - 220),
top: Math.min(tooltipContent.clientY + 15, window.innerHeight - 100),
zIndex: 1000,
pointerEvents: 'none',
}}
>
{tooltipContent.event ? (
<EventTooltipContent
eventName={tooltipContent.event.name}
timeOffsetMs={tooltipContent.event.timeOffsetMs}
isError={tooltipContent.event.isError}
attributeMap={tooltipContent.event.attributeMap}
/>
) : (
<SpanTooltipContent
spanName={tooltipContent.spanName}
color={tooltipContent.spanColor}
hasError={tooltipContent.status === 'error'}
relativeStartMs={tooltipContent.startMs}
durationMs={tooltipContent.durationMs}
/>
)}
</div>,
document.body,
)
: null;
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
padding: '0 15px',
}}
>
{tooltipElement}
<TimelineV3
startTimestamp={viewStartTs}
endTimestamp={viewEndTs}
offsetTimestamp={viewStartTs - traceMetadata.startTime}
timelineHeight={10}
cursorXPercent={cursorXPercent}
/>
<div
ref={containerRef}
style={{
flex: 1,
overflow: 'hidden',
position: 'relative',
}}
onMouseEnter={(): void => {
isOverFlamegraphRef.current = true;
}}
onMouseLeave={handleMouseLeave}
>
<canvas
ref={canvasRef}
style={{
display: 'block',
width: '100%',
cursor: 'grab',
}}
onMouseDown={(e): void => {
handleMouseDown(e);
handleMouseDownForClick(e);
}}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onClick={handleClick}
/>
<canvas
ref={overlayCanvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
pointerEvents: 'none',
}}
/>
</div>
</div>
);
}
export default FlamegraphCanvas;

View File

@@ -1,133 +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;

View File

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

View File

@@ -1,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);
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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;

View File

@@ -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]);
}

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