mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-17 18:32:11 +00:00
Compare commits
16 Commits
feat/trace
...
chore/issu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97eb4bc27c | ||
|
|
dcc8173c79 | ||
|
|
4b4ef5ce58 | ||
|
|
5b8d5fbfd3 | ||
|
|
0271be11e6 | ||
|
|
92d220c4d9 | ||
|
|
0ed8169bad | ||
|
|
ed553fb02e | ||
|
|
47daba3c17 | ||
|
|
2b3310809a | ||
|
|
542a648cc3 | ||
|
|
61df12d126 | ||
|
|
b846faa1fa | ||
|
|
557451ed81 | ||
|
|
25c513ec2f | ||
|
|
ae71f2608a |
@@ -1,4 +1,22 @@
|
||||
services:
|
||||
init-clickhouse:
|
||||
image: clickhouse/clickhouse-server:25.5.6
|
||||
container_name: init-clickhouse
|
||||
command:
|
||||
- bash
|
||||
- -c
|
||||
- |
|
||||
version="v0.0.1"
|
||||
node_os=$$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
node_arch=$$(uname -m | sed s/aarch64/arm64/ | sed s/x86_64/amd64/)
|
||||
echo "Fetching histogram-binary for $${node_os}/$${node_arch}"
|
||||
cd /tmp
|
||||
wget -O histogram-quantile.tar.gz "https://github.com/SigNoz/signoz/releases/download/histogram-quantile%2F$${version}/histogram-quantile_$${node_os}_$${node_arch}.tar.gz"
|
||||
tar -xvzf histogram-quantile.tar.gz
|
||||
mv histogram-quantile /var/lib/clickhouse/user_scripts/histogramQuantile
|
||||
restart: on-failure
|
||||
volumes:
|
||||
- ${PWD}/fs/tmp/var/lib/clickhouse/user_scripts/:/var/lib/clickhouse/user_scripts/
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server:25.5.6
|
||||
container_name: clickhouse
|
||||
@@ -7,6 +25,7 @@ services:
|
||||
- ${PWD}/fs/etc/clickhouse-server/users.d/users.xml:/etc/clickhouse-server/users.d/users.xml
|
||||
- ${PWD}/fs/tmp/var/lib/clickhouse/:/var/lib/clickhouse/
|
||||
- ${PWD}/fs/tmp/var/lib/clickhouse/user_scripts/:/var/lib/clickhouse/user_scripts/
|
||||
- ${PWD}/../../../deploy/common/clickhouse/custom-function.xml:/etc/clickhouse-server/custom-function.xml
|
||||
ports:
|
||||
- '127.0.0.1:8123:8123'
|
||||
- '127.0.0.1:9000:9000'
|
||||
@@ -22,7 +41,10 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
depends_on:
|
||||
- zookeeper
|
||||
init-clickhouse:
|
||||
condition: service_completed_successfully
|
||||
zookeeper:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- CLICKHOUSE_SKIP_USER_SETUP=1
|
||||
zookeeper:
|
||||
|
||||
@@ -44,4 +44,6 @@
|
||||
<shard>01</shard>
|
||||
<replica>01</replica>
|
||||
</macros>
|
||||
<user_defined_executable_functions_config>*function.xml</user_defined_executable_functions_config>
|
||||
<user_scripts_path>/var/lib/clickhouse/user_scripts/</user_scripts_path>
|
||||
</clickhouse>
|
||||
5
.github/CODEOWNERS
vendored
5
.github/CODEOWNERS
vendored
@@ -127,12 +127,15 @@
|
||||
/frontend/src/pages/DashboardsListPage/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/ListOfDashboard/ @SigNoz/pulse-frontend
|
||||
|
||||
# Dashboard Widget Page
|
||||
/frontend/src/pages/DashboardWidget/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/NewWidget/ @SigNoz/pulse-frontend
|
||||
|
||||
## Dashboard Page
|
||||
|
||||
/frontend/src/pages/DashboardPage/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/DashboardContainer/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/GridCardLayout/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/NewWidget/ @SigNoz/pulse-frontend
|
||||
|
||||
## Public Dashboard Page
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.114.1
|
||||
image: signoz/signoz:v0.115.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
# - "6060:6060" # pprof port
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.114.1
|
||||
image: signoz/signoz:v0.115.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
volumes:
|
||||
|
||||
@@ -181,7 +181,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.114.1}
|
||||
image: signoz/signoz:${VERSION:-v0.115.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -109,7 +109,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.114.1}
|
||||
image: signoz/signoz:${VERSION:-v0.115.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -1768,19 +1768,19 @@ components:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
expires_at:
|
||||
expiresAt:
|
||||
minimum: 0
|
||||
type: integer
|
||||
id:
|
||||
type: string
|
||||
key:
|
||||
type: string
|
||||
last_used:
|
||||
lastObservedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
service_account_id:
|
||||
serviceAccountId:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
@@ -1788,9 +1788,9 @@ components:
|
||||
required:
|
||||
- id
|
||||
- key
|
||||
- expires_at
|
||||
- last_used
|
||||
- service_account_id
|
||||
- expiresAt
|
||||
- lastObservedAt
|
||||
- serviceAccountId
|
||||
type: object
|
||||
ServiceaccounttypesGettableFactorAPIKeyWithKey:
|
||||
properties:
|
||||
@@ -1804,14 +1804,14 @@ components:
|
||||
type: object
|
||||
ServiceaccounttypesPostableFactorAPIKey:
|
||||
properties:
|
||||
expires_at:
|
||||
expiresAt:
|
||||
minimum: 0
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- expires_at
|
||||
- expiresAt
|
||||
type: object
|
||||
ServiceaccounttypesPostableServiceAccount:
|
||||
properties:
|
||||
@@ -1833,13 +1833,16 @@ components:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
deletedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
orgID:
|
||||
orgId:
|
||||
type: string
|
||||
roles:
|
||||
items:
|
||||
@@ -1856,18 +1859,19 @@ components:
|
||||
- email
|
||||
- roles
|
||||
- status
|
||||
- orgID
|
||||
- orgId
|
||||
- deletedAt
|
||||
type: object
|
||||
ServiceaccounttypesUpdatableFactorAPIKey:
|
||||
properties:
|
||||
expires_at:
|
||||
expiresAt:
|
||||
minimum: 0
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- expires_at
|
||||
- expiresAt
|
||||
type: object
|
||||
ServiceaccounttypesUpdatableServiceAccount:
|
||||
properties:
|
||||
|
||||
@@ -2,39 +2,45 @@ module base
|
||||
|
||||
type organisation
|
||||
relations
|
||||
define read: [user, role#assignee]
|
||||
define update: [user, role#assignee]
|
||||
define read: [user, serviceaccount, role#assignee]
|
||||
define update: [user, serviceaccount, role#assignee]
|
||||
|
||||
type user
|
||||
relations
|
||||
define read: [user, role#assignee]
|
||||
define update: [user, role#assignee]
|
||||
define delete: [user, role#assignee]
|
||||
define read: [user, serviceaccount, role#assignee]
|
||||
define update: [user, serviceaccount, role#assignee]
|
||||
define delete: [user, serviceaccount, role#assignee]
|
||||
|
||||
type serviceaccount
|
||||
relations
|
||||
define read: [user, serviceaccount, role#assignee]
|
||||
define update: [user, serviceaccount, role#assignee]
|
||||
define delete: [user, serviceaccount, role#assignee]
|
||||
|
||||
type anonymous
|
||||
|
||||
type role
|
||||
relations
|
||||
define assignee: [user, anonymous]
|
||||
define assignee: [user, serviceaccount, anonymous]
|
||||
|
||||
define read: [user, role#assignee]
|
||||
define update: [user, role#assignee]
|
||||
define delete: [user, role#assignee]
|
||||
define read: [user, serviceaccount, role#assignee]
|
||||
define update: [user, serviceaccount, role#assignee]
|
||||
define delete: [user, serviceaccount, role#assignee]
|
||||
|
||||
type metaresources
|
||||
relations
|
||||
define create: [user, role#assignee]
|
||||
define list: [user, role#assignee]
|
||||
define create: [user, serviceaccount, role#assignee]
|
||||
define list: [user, serviceaccount, role#assignee]
|
||||
|
||||
type metaresource
|
||||
relations
|
||||
define read: [user, anonymous, role#assignee]
|
||||
define update: [user, role#assignee]
|
||||
define delete: [user, role#assignee]
|
||||
define read: [user, serviceaccount, anonymous, role#assignee]
|
||||
define update: [user, serviceaccount, role#assignee]
|
||||
define delete: [user, serviceaccount, role#assignee]
|
||||
|
||||
define block: [user, role#assignee]
|
||||
define block: [user, serviceaccount, role#assignee]
|
||||
|
||||
|
||||
type telemetryresource
|
||||
relations
|
||||
define read: [user, role#assignee]
|
||||
define read: [user, serviceaccount, role#assignee]
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ReactChild, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { matchPath, useLocation } from 'react-router-dom';
|
||||
import { matchPath, Redirect, useLocation } from 'react-router-dom';
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import getAll from 'api/v1/user/get';
|
||||
@@ -128,6 +128,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
isAdmin &&
|
||||
(path === ROUTES.SETTINGS ||
|
||||
path === ROUTES.ORG_SETTINGS ||
|
||||
path === ROUTES.MEMBERS_SETTINGS ||
|
||||
path === ROUTES.BILLING ||
|
||||
path === ROUTES.MY_SETTINGS);
|
||||
|
||||
@@ -236,13 +237,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
useEffect(() => {
|
||||
// if it is an old route navigate to the new route
|
||||
if (isOldRoute) {
|
||||
const redirectUrl = oldNewRoutesMapping[pathname];
|
||||
|
||||
const newLocation = {
|
||||
...location,
|
||||
pathname: redirectUrl,
|
||||
};
|
||||
history.replace(newLocation);
|
||||
// this will be handled by the redirect component below
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -296,6 +291,19 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
}
|
||||
}, [isLoggedInState, pathname, user, isOldRoute, currentRoute, location]);
|
||||
|
||||
if (isOldRoute) {
|
||||
const redirectUrl = oldNewRoutesMapping[pathname];
|
||||
return (
|
||||
<Redirect
|
||||
to={{
|
||||
pathname: redirectUrl,
|
||||
search: location.search,
|
||||
hash: location.hash,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: disabling this rule as there is no need to have div
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ import posthog from 'posthog-js';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { IUser } from 'providers/App/types';
|
||||
import { CmdKProvider } from 'providers/cmdKProvider';
|
||||
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
|
||||
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
@@ -321,6 +320,19 @@ function App(): JSX.Element {
|
||||
// Session Replay
|
||||
replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
|
||||
replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
|
||||
beforeSend(event) {
|
||||
const sessionReplayUrl = posthog.get_session_replay_url?.({
|
||||
withTimestamp: true,
|
||||
});
|
||||
if (sessionReplayUrl) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
event.contexts = {
|
||||
...event.contexts,
|
||||
posthog: { session_replay_url: sessionReplayUrl },
|
||||
};
|
||||
}
|
||||
return event;
|
||||
},
|
||||
});
|
||||
|
||||
setIsSentryInitialized(true);
|
||||
@@ -371,28 +383,26 @@ function App(): JSX.Element {
|
||||
<PrivateRoute>
|
||||
<ResourceProvider>
|
||||
<QueryBuilderProvider>
|
||||
<DashboardProvider>
|
||||
<KeyboardHotkeysProvider>
|
||||
<AppLayout>
|
||||
<PreferenceContextProvider>
|
||||
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
|
||||
<Switch>
|
||||
{routes.map(({ path, component, exact }) => (
|
||||
<Route
|
||||
key={`${path}`}
|
||||
exact={exact}
|
||||
path={path}
|
||||
component={component}
|
||||
/>
|
||||
))}
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route path="*" component={NotFound} />
|
||||
</Switch>
|
||||
</Suspense>
|
||||
</PreferenceContextProvider>
|
||||
</AppLayout>
|
||||
</KeyboardHotkeysProvider>
|
||||
</DashboardProvider>
|
||||
<KeyboardHotkeysProvider>
|
||||
<AppLayout>
|
||||
<PreferenceContextProvider>
|
||||
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
|
||||
<Switch>
|
||||
{routes.map(({ path, component, exact }) => (
|
||||
<Route
|
||||
key={`${path}`}
|
||||
exact={exact}
|
||||
path={path}
|
||||
component={component}
|
||||
/>
|
||||
))}
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route path="*" component={NotFound} />
|
||||
</Switch>
|
||||
</Suspense>
|
||||
</PreferenceContextProvider>
|
||||
</AppLayout>
|
||||
</KeyboardHotkeysProvider>
|
||||
</QueryBuilderProvider>
|
||||
</ResourceProvider>
|
||||
</PrivateRoute>
|
||||
|
||||
@@ -2100,7 +2100,7 @@ export interface ServiceaccounttypesFactorAPIKeyDTO {
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
expires_at: number;
|
||||
expiresAt: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2113,7 +2113,7 @@ export interface ServiceaccounttypesFactorAPIKeyDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
last_used: Date;
|
||||
lastObservedAt: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2121,7 +2121,7 @@ export interface ServiceaccounttypesFactorAPIKeyDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
service_account_id: string;
|
||||
serviceAccountId: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
@@ -2145,7 +2145,7 @@ export interface ServiceaccounttypesPostableFactorAPIKeyDTO {
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
expires_at: number;
|
||||
expiresAt: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2173,6 +2173,11 @@ export interface ServiceaccounttypesServiceAccountDTO {
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
deletedAt: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2188,7 +2193,7 @@ export interface ServiceaccounttypesServiceAccountDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgID: string;
|
||||
orgId: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
@@ -2209,7 +2214,7 @@ export interface ServiceaccounttypesUpdatableFactorAPIKeyDTO {
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
expires_at: number;
|
||||
expiresAt: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
.timeline-v3-container {
|
||||
// flex: 1;
|
||||
overflow: visible;
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMeasure } from 'react-use';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import {
|
||||
getIntervals,
|
||||
getMinimumIntervalsBasedOnWidth,
|
||||
Interval,
|
||||
} from './utils';
|
||||
|
||||
import './TimelineV3.styles.scss';
|
||||
|
||||
interface ITimelineV3Props {
|
||||
startTimestamp: number;
|
||||
endTimestamp: number;
|
||||
timelineHeight: number;
|
||||
offsetTimestamp: number;
|
||||
}
|
||||
|
||||
function TimelineV3(props: ITimelineV3Props): JSX.Element {
|
||||
const {
|
||||
startTimestamp,
|
||||
endTimestamp,
|
||||
timelineHeight,
|
||||
offsetTimestamp,
|
||||
} = props;
|
||||
const [intervals, setIntervals] = useState<Interval[]>([]);
|
||||
const [ref, { width }] = useMeasure<HTMLDivElement>();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
useEffect(() => {
|
||||
const spread = endTimestamp - startTimestamp;
|
||||
if (spread < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const minIntervals = getMinimumIntervalsBasedOnWidth(width);
|
||||
const intervalisedSpread = (spread / minIntervals) * 1.0;
|
||||
const intervals = getIntervals(intervalisedSpread, spread, offsetTimestamp);
|
||||
|
||||
setIntervals(intervals);
|
||||
}, [startTimestamp, endTimestamp, width, offsetTimestamp]);
|
||||
|
||||
if (endTimestamp < startTimestamp) {
|
||||
console.error(
|
||||
'endTimestamp cannot be less than startTimestamp',
|
||||
startTimestamp,
|
||||
endTimestamp,
|
||||
);
|
||||
return <div />;
|
||||
}
|
||||
|
||||
const strokeColor = isDarkMode ? ' rgb(192,193,195,0.8)' : 'black';
|
||||
|
||||
return (
|
||||
<div ref={ref as never} className="timeline-v3-container">
|
||||
<svg
|
||||
width={width}
|
||||
height={timelineHeight * 2.5}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
overflow="visible"
|
||||
>
|
||||
{intervals &&
|
||||
intervals.length > 0 &&
|
||||
intervals.map((interval, index) => (
|
||||
<g
|
||||
transform={`translate(${(interval.percentage * width) / 100},0)`}
|
||||
key={`${interval.percentage + interval.label + index}`}
|
||||
textAnchor="middle"
|
||||
fontSize="0.6rem"
|
||||
>
|
||||
<text
|
||||
x={index === intervals.length - 1 ? -10 : 0}
|
||||
y={timelineHeight * 2}
|
||||
fill={strokeColor}
|
||||
>
|
||||
{interval.label}
|
||||
</text>
|
||||
<line y1={0} y2={timelineHeight} stroke={strokeColor} strokeWidth="1" />
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimelineV3;
|
||||
@@ -1,93 +0,0 @@
|
||||
import {
|
||||
IIntervalUnit,
|
||||
Interval,
|
||||
INTERVAL_UNITS,
|
||||
resolveTimeFromInterval,
|
||||
} from 'components/TimelineV2/utils';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
export type { Interval };
|
||||
|
||||
/** Fewer intervals than TimelineV2 for a cleaner flamegraph ruler. */
|
||||
export function getMinimumIntervalsBasedOnWidth(width: number): number {
|
||||
if (width < 640) {
|
||||
return 3;
|
||||
}
|
||||
if (width < 768) {
|
||||
return 4;
|
||||
}
|
||||
if (width < 1024) {
|
||||
return 5;
|
||||
}
|
||||
return 6;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes timeline intervals with offset-aware labels.
|
||||
* Labels reflect absolute time from trace start (offsetTimestamp + elapsed),
|
||||
* so when zoomed into a window, the first tick shows e.g. "50ms" not "0ms".
|
||||
*/
|
||||
export function getIntervals(
|
||||
intervalSpread: number,
|
||||
baseSpread: number,
|
||||
offsetTimestamp: number,
|
||||
): Interval[] {
|
||||
const integerPartString = intervalSpread.toString().split('.')[0];
|
||||
const integerPartLength = integerPartString.length;
|
||||
|
||||
const intervalSpreadNormalized =
|
||||
intervalSpread < 1.0
|
||||
? intervalSpread
|
||||
: Math.floor(Number(integerPartString) / 10 ** (integerPartLength - 1)) *
|
||||
10 ** (integerPartLength - 1);
|
||||
|
||||
// Unit must suit both: (1) tick granularity (intervalSpread) and (2) label magnitude
|
||||
// (offsetTimestamp). When zoomed deep into a trace, labels show offsetTimestamp + elapsed,
|
||||
// so we must pick a unit where that value is readable (e.g. "500.00s" not "500000.00ms").
|
||||
const valueForUnitSelection = Math.max(offsetTimestamp, intervalSpread);
|
||||
let intervalUnit: IIntervalUnit = INTERVAL_UNITS[0];
|
||||
for (let idx = INTERVAL_UNITS.length - 1; idx >= 0; idx -= 1) {
|
||||
const standardInterval = INTERVAL_UNITS[idx];
|
||||
if (valueForUnitSelection * standardInterval.multiplier >= 1) {
|
||||
intervalUnit = INTERVAL_UNITS[idx];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const intervals: Interval[] = [
|
||||
{
|
||||
label: `${toFixed(
|
||||
resolveTimeFromInterval(offsetTimestamp, intervalUnit),
|
||||
2,
|
||||
)}${intervalUnit.name}`,
|
||||
percentage: 0,
|
||||
},
|
||||
];
|
||||
|
||||
let tempBaseSpread = baseSpread;
|
||||
let elapsedIntervals = 0;
|
||||
|
||||
while (tempBaseSpread && intervals.length < 20) {
|
||||
let intervalTime: number;
|
||||
|
||||
if (tempBaseSpread <= 1.5 * intervalSpreadNormalized) {
|
||||
intervalTime = elapsedIntervals + tempBaseSpread;
|
||||
tempBaseSpread = 0;
|
||||
} else {
|
||||
intervalTime = elapsedIntervals + intervalSpreadNormalized;
|
||||
tempBaseSpread -= intervalSpreadNormalized;
|
||||
}
|
||||
|
||||
elapsedIntervals = intervalTime;
|
||||
const labelTime = offsetTimestamp + intervalTime;
|
||||
|
||||
intervals.push({
|
||||
label: `${toFixed(resolveTimeFromInterval(labelTime, intervalUnit), 2)}${
|
||||
intervalUnit.name
|
||||
}`,
|
||||
percentage: (intervalTime / baseSpread) * 100,
|
||||
});
|
||||
}
|
||||
|
||||
return intervals;
|
||||
}
|
||||
@@ -33,125 +33,6 @@ const themeColors = {
|
||||
purple: '#800080',
|
||||
cyan: '#00FFFF',
|
||||
},
|
||||
traceDetailColorsV3: {
|
||||
// Blues
|
||||
dodgerBlue: '#2F80ED',
|
||||
royalBlue: '#3366E6',
|
||||
steelBlue: '#4682B4',
|
||||
|
||||
// Teals / Cyans
|
||||
turquoise: '#00CEC9',
|
||||
lagoon: '#1ABC9C',
|
||||
cyanBright: '#22A6F2',
|
||||
|
||||
// Greens
|
||||
emeraldGreen: '#27AE60',
|
||||
mediumSeaGreen: '#3CB371',
|
||||
limeGreen: '#A3E635',
|
||||
|
||||
// Yellows / Golds
|
||||
festivalYellow: '#F2C94C',
|
||||
sunflower: '#FFD93D',
|
||||
warmAmber: '#FFCA28',
|
||||
|
||||
// Purples / Violets
|
||||
mediumPurple: '#BB6BD9',
|
||||
royalPurple: '#9B51E0',
|
||||
orchid: '#DA77F2',
|
||||
|
||||
// Accent
|
||||
neonViolet: '#C77DFF',
|
||||
electricPurple: '#6C5CE7',
|
||||
arcticBlue: '#48DBFB',
|
||||
|
||||
// Blues extended
|
||||
blue1: '#1F63E0',
|
||||
blue2: '#3A7AED',
|
||||
blue3: '#5A9DF5',
|
||||
blue4: '#2874A6',
|
||||
blue5: '#2E86C1',
|
||||
blue6: '#3498DB',
|
||||
|
||||
// Cyans
|
||||
cyan1: '#00B0AA',
|
||||
cyan2: '#33D6C2',
|
||||
cyan3: '#66E9DA',
|
||||
|
||||
// Greens extended
|
||||
green1: '#1E8449',
|
||||
green2: '#2ECC71',
|
||||
green3: '#58D68D',
|
||||
green4: '#229954',
|
||||
green5: '#27AE60',
|
||||
green6: '#52BE80',
|
||||
|
||||
// Forest
|
||||
forest1: '#27AE60',
|
||||
forest2: '#2ECC71',
|
||||
forest3: '#58D68D',
|
||||
|
||||
// Lime
|
||||
lime1: '#A3E635',
|
||||
lime2: '#B9F18D',
|
||||
lime3: '#D4FFB0',
|
||||
|
||||
// Teals
|
||||
teal1: '#009688',
|
||||
teal2: '#1ABC9C',
|
||||
teal3: '#48C9B0',
|
||||
teal4: '#1ABC9C',
|
||||
teal5: '#48C9B0',
|
||||
teal6: '#76D7C4',
|
||||
|
||||
// Yellows
|
||||
yellow1: '#F1C40F',
|
||||
yellow2: '#F7DC6F',
|
||||
yellow3: '#F9E79F',
|
||||
|
||||
// Gold
|
||||
gold1: '#F39C12',
|
||||
gold2: '#F1C40F',
|
||||
gold3: '#F7DC6F',
|
||||
gold4: '#B7950B',
|
||||
gold5: '#F1C40F',
|
||||
gold6: '#F4D03F',
|
||||
|
||||
// Mustard
|
||||
mustard1: '#F1C40F',
|
||||
mustard2: '#F7DC6F',
|
||||
mustard3: '#F9E79F',
|
||||
|
||||
// Aqua
|
||||
aqua1: '#00BFFF',
|
||||
aqua2: '#1E90FF',
|
||||
aqua3: '#63B8FF',
|
||||
|
||||
// Purple extended
|
||||
purple1: '#8E44AD',
|
||||
purple2: '#9B59B6',
|
||||
purple3: '#BB8FCE',
|
||||
|
||||
violet1: '#8E44AD',
|
||||
violet2: '#9B59B6',
|
||||
violet3: '#BB8FCE',
|
||||
violet4: '#7D3C98',
|
||||
violet5: '#8E44AD',
|
||||
violet6: '#9B59B6',
|
||||
|
||||
// Lavender
|
||||
lavender1: '#9B59B6',
|
||||
lavender2: '#AF7AC5',
|
||||
lavender3: '#C39BD3',
|
||||
|
||||
// Oranges (safe ones, not red-ish)
|
||||
orange4: '#D35400',
|
||||
orange5: '#E67E22',
|
||||
orange6: '#EB984E',
|
||||
|
||||
coral1: '#E67E22',
|
||||
coral2: '#F39C12',
|
||||
coral3: '#F5B041',
|
||||
},
|
||||
chartcolors: {
|
||||
// Blues (3)
|
||||
dodgerBlue: '#2F80ED',
|
||||
|
||||
@@ -30,14 +30,15 @@ export default function CustomDomainEditModal({
|
||||
onClearError,
|
||||
onSubmit,
|
||||
}: CustomDomainEditModalProps): JSX.Element {
|
||||
const [value, setValue] = useState(customDomainSubdomain ?? '');
|
||||
const initialSubdomain = customDomainSubdomain ?? '';
|
||||
const [value, setValue] = useState(initialSubdomain);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setValue(customDomainSubdomain ?? '');
|
||||
setValue(initialSubdomain);
|
||||
}
|
||||
}, [isOpen, customDomainSubdomain]);
|
||||
}, [isOpen, initialSubdomain]);
|
||||
|
||||
const handleClose = (): void => {
|
||||
setValidationError(null);
|
||||
@@ -58,6 +59,11 @@ export default function CustomDomainEditModal({
|
||||
};
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
if (value === initialSubdomain) {
|
||||
setValidationError('Input is unchanged');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
setValidationError('This field is required');
|
||||
return;
|
||||
@@ -84,7 +90,7 @@ export default function CustomDomainEditModal({
|
||||
|
||||
const hasError = Boolean(errorMessage);
|
||||
|
||||
const statusIcon = ((): JSX.Element => {
|
||||
const statusIcon = ((): JSX.Element | null => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<LoaderCircle size={16} className="animate-spin edit-modal-status-icon" />
|
||||
@@ -95,7 +101,9 @@ export default function CustomDomainEditModal({
|
||||
return <CircleAlert size={16} color={Color.BG_CHERRY_500} />;
|
||||
}
|
||||
|
||||
return <CircleCheck size={16} color={Color.BG_FOREST_500} />;
|
||||
return value && value.length >= 3 ? (
|
||||
<CircleCheck size={16} color={Color.BG_FOREST_500} />
|
||||
) : null;
|
||||
})();
|
||||
|
||||
return (
|
||||
@@ -189,7 +197,7 @@ export default function CustomDomainEditModal({
|
||||
color="primary"
|
||||
className="edit-modal-apply-btn"
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || value === initialSubdomain}
|
||||
loading={isLoading}
|
||||
>
|
||||
Apply Changes
|
||||
|
||||
@@ -81,6 +81,10 @@
|
||||
padding-left: 26px;
|
||||
}
|
||||
|
||||
.custom-domain-card-meta-row.workspace-name-hidden {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.custom-domain-card-meta-timezone {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -117,32 +121,6 @@
|
||||
background: var(--l2-border);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.custom-domain-card-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-5);
|
||||
padding: var(--padding-3);
|
||||
}
|
||||
|
||||
.custom-domain-card-license {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.custom-domain-plan-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-url-trigger {
|
||||
|
||||
@@ -69,8 +69,9 @@ function DomainUpdateToast({
|
||||
}
|
||||
|
||||
export default function CustomDomainSettings(): JSX.Element {
|
||||
const { org, activeLicense } = useAppContext();
|
||||
const { org } = useAppContext();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isPollingEnabled, setIsPollingEnabled] = useState(false);
|
||||
const [hosts, setHosts] = useState<ZeustypesHostDTO[] | null>(null);
|
||||
@@ -175,7 +176,8 @@ export default function CustomDomainSettings(): JSX.Element {
|
||||
[hosts, activeHost],
|
||||
);
|
||||
|
||||
const planName = activeLicense?.plan?.name;
|
||||
const workspaceName =
|
||||
org?.[0]?.displayName || customDomainSubdomain || activeHost?.name;
|
||||
|
||||
if (isLoadingHosts) {
|
||||
return (
|
||||
@@ -191,106 +193,98 @@ export default function CustomDomainSettings(): JSX.Element {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="custom-domain-card">
|
||||
<div className="custom-domain-card-top">
|
||||
<div className="custom-domain-card-info">
|
||||
<div className="custom-domain-card-top">
|
||||
<div className="custom-domain-card-info">
|
||||
{!!workspaceName && (
|
||||
<div className="custom-domain-card-name-row">
|
||||
<span className="beacon" />
|
||||
<span className="custom-domain-card-org-name">
|
||||
{org?.[0]?.displayName ? org?.[0]?.displayName : customDomainSubdomain}
|
||||
</span>
|
||||
<span className="custom-domain-card-org-name">{workspaceName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="custom-domain-card-meta-row">
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
dropdownRender={(): JSX.Element => (
|
||||
<div className="workspace-url-dropdown">
|
||||
<span className="workspace-url-dropdown-header">
|
||||
All Workspace URLs
|
||||
</span>
|
||||
<div className="workspace-url-dropdown-divider" />
|
||||
{sortedHosts.map((host) => {
|
||||
const isActive = host.name === activeHost?.name;
|
||||
return (
|
||||
<a
|
||||
key={host.name}
|
||||
href={host.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`workspace-url-dropdown-item${
|
||||
isActive ? ' workspace-url-dropdown-item--active' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="workspace-url-dropdown-item-label">
|
||||
{stripProtocol(host.url ?? '')}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<Check size={14} className="workspace-url-dropdown-item-check" />
|
||||
) : (
|
||||
<ExternalLink
|
||||
size={12}
|
||||
className="workspace-url-dropdown-item-external"
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
className="workspace-url-trigger"
|
||||
disabled={isFetchingHosts}
|
||||
>
|
||||
<Link2 size={12} />
|
||||
<span>{stripProtocol(activeHost?.url ?? '')}</span>
|
||||
<ChevronDown size={12} />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<span className="custom-domain-card-meta-timezone">
|
||||
<Clock size={11} />
|
||||
{timezone.offset}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
className="custom-domain-edit-button"
|
||||
prefixIcon={<FilePenLine size={12} />}
|
||||
disabled={isFetchingHosts || isPollingEnabled}
|
||||
onClick={(): void => setIsEditModalOpen(true)}
|
||||
<div
|
||||
className={`custom-domain-card-meta-row ${
|
||||
!workspaceName ? 'workspace-name-hidden' : ''
|
||||
}`}
|
||||
>
|
||||
Edit workspace link
|
||||
</Button>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
dropdownRender={(): JSX.Element => (
|
||||
<div className="workspace-url-dropdown">
|
||||
<span className="workspace-url-dropdown-header">
|
||||
All Workspace URLs
|
||||
</span>
|
||||
<div className="workspace-url-dropdown-divider" />
|
||||
{sortedHosts.map((host) => {
|
||||
const isActive = host.name === activeHost?.name;
|
||||
return (
|
||||
<a
|
||||
key={host.name}
|
||||
href={host.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`workspace-url-dropdown-item${
|
||||
isActive ? ' workspace-url-dropdown-item--active' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="workspace-url-dropdown-item-label">
|
||||
{stripProtocol(host.url ?? '')}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<Check size={14} className="workspace-url-dropdown-item-check" />
|
||||
) : (
|
||||
<ExternalLink
|
||||
size={12}
|
||||
className="workspace-url-dropdown-item-external"
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
className="workspace-url-trigger"
|
||||
disabled={isFetchingHosts}
|
||||
>
|
||||
<Link2 size={12} />
|
||||
<span>{stripProtocol(activeHost?.url ?? '')}</span>
|
||||
<ChevronDown size={12} />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<span className="custom-domain-card-meta-timezone">
|
||||
<Clock size={11} />
|
||||
{timezone.offset}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPollingEnabled && (
|
||||
<Callout
|
||||
type="info"
|
||||
showIcon
|
||||
className="custom-domain-callout"
|
||||
size="small"
|
||||
icon={<SolidAlertCircle size={13} color="primary" />}
|
||||
message={`Updating your URL to ⎯ ${customDomainSubdomain}.${dnsSuffix}. This may take a few mins.`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="custom-domain-card-divider" />
|
||||
|
||||
<div className="custom-domain-card-bottom">
|
||||
<span className="beacon" />
|
||||
<span className="custom-domain-card-license">
|
||||
{planName && <code className="custom-domain-plan-badge">{planName}</code>}{' '}
|
||||
license is currently active
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
className="custom-domain-edit-button"
|
||||
prefixIcon={<FilePenLine size={12} />}
|
||||
disabled={isFetchingHosts || isPollingEnabled}
|
||||
onClick={(): void => setIsEditModalOpen(true)}
|
||||
>
|
||||
Edit workspace link
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isPollingEnabled && (
|
||||
<Callout
|
||||
type="info"
|
||||
showIcon
|
||||
className="custom-domain-callout"
|
||||
size="small"
|
||||
icon={<SolidAlertCircle size={13} color="primary" />}
|
||||
message={`Updating your URL to ⎯ ${customDomainSubdomain}.${dnsSuffix}. This may take a few mins.`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CustomDomainEditModal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={(): void => setIsEditModalOpen(false)}
|
||||
|
||||
@@ -239,4 +239,87 @@ describe('CustomDomainSettings', () => {
|
||||
const { container } = render(toastRenderer('test-id'));
|
||||
expect(container).toHaveTextContent(/myteam\.test\.cloud/i);
|
||||
});
|
||||
|
||||
describe('Workspace Name rendering', () => {
|
||||
it('renders org displayName when available from appContext', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<CustomDomainSettings />, undefined, {
|
||||
appContextOverrides: {
|
||||
org: [{ id: 'xyz', displayName: 'My Org Name', createdAt: 0 }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(await screen.findByText('My Org Name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to customDomainSubdomain when org displayName is missing', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<CustomDomainSettings />, undefined, {
|
||||
appContextOverrides: { org: [] },
|
||||
});
|
||||
|
||||
expect(await screen.findByText('custom-host')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to activeHost.name when neither org name nor custom domain exists', async () => {
|
||||
const onlyDefaultHostResponse = {
|
||||
...mockHostsResponse,
|
||||
data: {
|
||||
...mockHostsResponse.data,
|
||||
hosts: mockHostsResponse.data.hosts
|
||||
? [mockHostsResponse.data.hosts[0]]
|
||||
: [],
|
||||
},
|
||||
};
|
||||
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(onlyDefaultHostResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<CustomDomainSettings />, undefined, {
|
||||
appContextOverrides: { org: [] },
|
||||
});
|
||||
|
||||
// 'accepted-starfish' is the default host's name
|
||||
expect(await screen.findByText('accepted-starfish')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the card name row if workspaceName is totally falsy', async () => {
|
||||
const emptyHostsResponse = {
|
||||
...mockHostsResponse,
|
||||
data: {
|
||||
...mockHostsResponse.data,
|
||||
hosts: [],
|
||||
},
|
||||
};
|
||||
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(emptyHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const { container } = render(<CustomDomainSettings />, undefined, {
|
||||
appContextOverrides: { org: [] },
|
||||
});
|
||||
|
||||
await screen.findByRole('button', { name: /edit workspace link/i });
|
||||
|
||||
expect(
|
||||
container.querySelector('.custom-domain-card-name-row'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,11 +34,6 @@ const mockSafeNavigate = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
useRouteMatch: jest.fn().mockReturnValue({
|
||||
params: {
|
||||
dashboardId: 4,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
@@ -69,7 +64,7 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
(useLocation as jest.Mock).mockReturnValue(mockLocation);
|
||||
const { getByTestId } = render(
|
||||
<MemoryRouter initialEntries={[DASHBOARD_PATH]}>
|
||||
<DashboardProvider>
|
||||
<DashboardProvider dashboardId="4">
|
||||
<DashboardDescription
|
||||
handle={{
|
||||
active: false,
|
||||
@@ -110,7 +105,7 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
);
|
||||
const { getByTestId } = render(
|
||||
<MemoryRouter initialEntries={[DASHBOARD_PATH]}>
|
||||
<DashboardProvider>
|
||||
<DashboardProvider dashboardId="4">
|
||||
<DashboardDescription
|
||||
handle={{
|
||||
active: false,
|
||||
@@ -149,7 +144,7 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
|
||||
const { getByText } = render(
|
||||
<MemoryRouter initialEntries={[DASHBOARD_PATH]}>
|
||||
<DashboardProvider>
|
||||
<DashboardProvider dashboardId="4">
|
||||
<DashboardDescription
|
||||
handle={{
|
||||
active: false,
|
||||
@@ -198,9 +193,6 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
setPanelMap: jest.fn(),
|
||||
setLayouts: jest.fn(),
|
||||
setSelectedDashboard: jest.fn(),
|
||||
updatedTimeRef: { current: null },
|
||||
toScrollWidgetId: '',
|
||||
setToScrollWidgetId: jest.fn(),
|
||||
updateLocalStorageDashboardVariables: jest.fn(),
|
||||
dashboardQueryRangeCalled: false,
|
||||
setDashboardQueryRangeCalled: jest.fn(),
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from 'hooks/dashboard/useDashboardVariables';
|
||||
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables';
|
||||
import { updateDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
|
||||
import {
|
||||
enqueueDescendantsOfVariable,
|
||||
@@ -30,7 +29,7 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
updateLocalStorageDashboardVariables,
|
||||
} = useDashboard();
|
||||
|
||||
const { updateUrlVariable, getUrlVariables } = useVariablesFromUrl();
|
||||
const { updateUrlVariable } = useVariablesFromUrl();
|
||||
|
||||
const { dashboardVariables } = useDashboardVariables();
|
||||
const dashboardId = useDashboardVariablesSelector(
|
||||
@@ -50,15 +49,6 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize variables with default values if not in URL
|
||||
initializeDefaultVariables(
|
||||
dashboardVariables,
|
||||
getUrlVariables,
|
||||
updateUrlVariable,
|
||||
);
|
||||
}, [getUrlVariables, updateUrlVariable, dashboardVariables]);
|
||||
|
||||
// Memoize the order key to avoid unnecessary triggers
|
||||
const variableOrderKey = useMemo(() => {
|
||||
const queryVariableOrderKey = dependencyData?.order?.join(',') ?? '';
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useScrollToWidgetIdStore } from 'providers/Dashboard/helpers/scrollToWidgetIdHelper';
|
||||
|
||||
import { useScrollWidgetIntoView } from '../useScrollWidgetIntoView';
|
||||
|
||||
jest.mock('providers/Dashboard/Dashboard');
|
||||
jest.mock('providers/Dashboard/helpers/scrollToWidgetIdHelper');
|
||||
|
||||
type MockHTMLElement = {
|
||||
scrollIntoView: jest.Mock;
|
||||
@@ -18,25 +18,35 @@ function createMockElement(): MockHTMLElement {
|
||||
}
|
||||
|
||||
describe('useScrollWidgetIntoView', () => {
|
||||
const mockedUseDashboard = useDashboard as jest.MockedFunction<
|
||||
typeof useDashboard
|
||||
const mockedUseScrollToWidgetIdStore = useScrollToWidgetIdStore as jest.MockedFunction<
|
||||
typeof useScrollToWidgetIdStore
|
||||
>;
|
||||
|
||||
let mockElement: MockHTMLElement;
|
||||
let ref: React.RefObject<HTMLDivElement>;
|
||||
let setToScrollWidgetId: jest.Mock;
|
||||
|
||||
function mockStore(toScrollWidgetId: string): void {
|
||||
const storeState = { toScrollWidgetId, setToScrollWidgetId };
|
||||
mockedUseScrollToWidgetIdStore.mockImplementation(
|
||||
(selector) =>
|
||||
selector(
|
||||
(storeState as unknown) as Parameters<typeof selector>[0],
|
||||
) as ReturnType<typeof useScrollToWidgetIdStore>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockElement = createMockElement();
|
||||
ref = ({
|
||||
current: mockElement,
|
||||
} as unknown) as React.RefObject<HTMLDivElement>;
|
||||
setToScrollWidgetId = jest.fn();
|
||||
});
|
||||
|
||||
it('scrolls into view and focuses when toScrollWidgetId matches widget id', () => {
|
||||
const setToScrollWidgetId = jest.fn();
|
||||
const mockElement = createMockElement();
|
||||
const ref = ({
|
||||
current: mockElement,
|
||||
} as unknown) as React.RefObject<HTMLDivElement>;
|
||||
|
||||
mockedUseDashboard.mockReturnValue(({
|
||||
toScrollWidgetId: 'widget-id',
|
||||
setToScrollWidgetId,
|
||||
} as unknown) as ReturnType<typeof useDashboard>);
|
||||
mockStore('widget-id');
|
||||
|
||||
renderHook(() => useScrollWidgetIntoView('widget-id', ref));
|
||||
|
||||
@@ -49,16 +59,7 @@ describe('useScrollWidgetIntoView', () => {
|
||||
});
|
||||
|
||||
it('does nothing when toScrollWidgetId does not match widget id', () => {
|
||||
const setToScrollWidgetId = jest.fn();
|
||||
const mockElement = createMockElement();
|
||||
const ref = ({
|
||||
current: mockElement,
|
||||
} as unknown) as React.RefObject<HTMLDivElement>;
|
||||
|
||||
mockedUseDashboard.mockReturnValue(({
|
||||
toScrollWidgetId: 'other-widget',
|
||||
setToScrollWidgetId,
|
||||
} as unknown) as ReturnType<typeof useDashboard>);
|
||||
mockStore('other-widget');
|
||||
|
||||
renderHook(() => useScrollWidgetIntoView('widget-id', ref));
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { RefObject, useEffect } from 'react';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useScrollToWidgetIdStore } from 'providers/Dashboard/helpers/scrollToWidgetIdHelper';
|
||||
|
||||
/**
|
||||
* Scrolls the given widget container into view when the dashboard
|
||||
@@ -11,7 +11,10 @@ export function useScrollWidgetIntoView<T extends HTMLElement>(
|
||||
widgetId: string,
|
||||
widgetContainerRef: RefObject<T>,
|
||||
): void {
|
||||
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
|
||||
const toScrollWidgetId = useScrollToWidgetIdStore((s) => s.toScrollWidgetId);
|
||||
const setToScrollWidgetId = useScrollToWidgetIdStore(
|
||||
(s) => s.setToScrollWidgetId,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (toScrollWidgetId === widgetId) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useScrollWidgetIntoView } from 'container/DashboardContainer/visualization/hooks/useScrollWidgetIntoView';
|
||||
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
@@ -34,8 +33,6 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
useScrollWidgetIntoView(widget.id, graphRef);
|
||||
|
||||
useEffect((): void => {
|
||||
const { startTime, endTime } = getTimeRange(queryResponse);
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useScrollWidgetIntoView } from 'container/DashboardContainer/visualization/hooks/useScrollWidgetIntoView';
|
||||
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
@@ -32,8 +31,6 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
useScrollWidgetIntoView(widget.id, graphRef);
|
||||
|
||||
const config = useMemo(() => {
|
||||
return prepareHistogramPanelConfig({
|
||||
widget,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
|
||||
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
|
||||
import { usePanelContextMenu } from 'container/DashboardContainer/visualization/hooks/usePanelContextMenu';
|
||||
import { useScrollWidgetIntoView } from 'container/DashboardContainer/visualization/hooks/useScrollWidgetIntoView';
|
||||
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
@@ -33,8 +32,6 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
useScrollWidgetIntoView(widget.id, graphRef);
|
||||
|
||||
useEffect((): void => {
|
||||
const { startTime, endTime } = getTimeRange(queryResponse);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import setRetentionApi from 'api/settings/setRetention';
|
||||
import setRetentionApiV2 from 'api/settings/setRetentionV2';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import CustomDomainSettings from 'container/CustomDomainSettings';
|
||||
import LicenseKeyRow from 'container/GeneralSettings/LicenseKeyRow/LicenseKeyRow';
|
||||
import GeneralSettingsCloud from 'container/GeneralSettingsCloud';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
@@ -81,7 +82,7 @@ function GeneralSettings({
|
||||
logsTtlValuesPayload,
|
||||
);
|
||||
|
||||
const { user } = useAppContext();
|
||||
const { user, activeLicense } = useAppContext();
|
||||
|
||||
const [setRetentionPermission] = useComponentPermission(
|
||||
['set_retention_period'],
|
||||
@@ -680,7 +681,15 @@ function GeneralSettings({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showCustomDomainSettings && <CustomDomainSettings />}
|
||||
{(showCustomDomainSettings || activeLicense?.key) && (
|
||||
<div className="custom-domain-card">
|
||||
{showCustomDomainSettings && <CustomDomainSettings />}
|
||||
{showCustomDomainSettings && activeLicense?.key && (
|
||||
<div className="custom-domain-card-divider" />
|
||||
)}
|
||||
{activeLicense?.key && <LicenseKeyRow />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="retention-controls-container">
|
||||
<div className="retention-controls-header">
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
.license-key-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--padding-2) var(--padding-3);
|
||||
gap: var(--spacing-5);
|
||||
|
||||
&__left {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--l2-foreground);
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__value {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
&__code {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 2px;
|
||||
border-radius: 2px 0 0 2px;
|
||||
background: var(--l3-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'SF Mono', 'Fira Code', 'Fira Mono', monospace;
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
white-space: nowrap;
|
||||
margin-right: -1px;
|
||||
}
|
||||
|
||||
&__copy-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
padding: 1px 2px;
|
||||
border-radius: 0 2px 2px 0;
|
||||
background: var(--l3-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l2-foreground);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
height: 24px;
|
||||
|
||||
&:hover {
|
||||
background: var(--l3-background-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Copy, KeyRound } from '@signozhq/icons';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { getMaskedKey } from 'utils/maskedKey';
|
||||
|
||||
import './LicenseKeyRow.styles.scss';
|
||||
|
||||
function LicenseKeyRow(): JSX.Element | null {
|
||||
const { activeLicense } = useAppContext();
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
if (!activeLicense?.key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleCopyLicenseKey = (text: string): void => {
|
||||
copyToClipboard(text);
|
||||
toast.success('License key copied to clipboard.', { richColors: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="license-key-row">
|
||||
<span className="license-key-row__left">
|
||||
<KeyRound size={14} />
|
||||
<span className="license-key-row__label">SigNoz License Key</span>
|
||||
</span>
|
||||
<span className="license-key-row__value">
|
||||
<code className="license-key-row__code">
|
||||
{getMaskedKey(activeLicense.key)}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
aria-label="Copy license key"
|
||||
data-testid="license-key-row-copy-btn"
|
||||
className="license-key-row__copy-btn"
|
||||
onClick={(): void => handleCopyLicenseKey(activeLicense.key)}
|
||||
>
|
||||
<Copy size={12} />
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LicenseKeyRow;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import LicenseKeyRow from '../LicenseKeyRow';
|
||||
|
||||
const mockCopyToClipboard = jest.fn();
|
||||
|
||||
jest.mock('react-use', () => ({
|
||||
__esModule: true,
|
||||
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopyToClipboard],
|
||||
}));
|
||||
|
||||
const mockToastSuccess = jest.fn();
|
||||
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: {
|
||||
success: (...args: unknown[]): unknown => mockToastSuccess(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('LicenseKeyRow', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders nothing when activeLicense key is absent', () => {
|
||||
const { container } = render(<LicenseKeyRow />, undefined, {
|
||||
appContextOverrides: { activeLicense: null },
|
||||
});
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders label and masked key when activeLicense key exists', () => {
|
||||
render(<LicenseKeyRow />, undefined, {
|
||||
appContextOverrides: {
|
||||
activeLicense: { key: 'abcdefghij' } as any,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText('SigNoz License Key')).toBeInTheDocument();
|
||||
expect(screen.getByText('ab·······ij')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls copyToClipboard and shows success toast when clipboard is available', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<LicenseKeyRow />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /copy license key/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith('test-key');
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith(
|
||||
'License key copied to clipboard.',
|
||||
{
|
||||
richColors: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import logEvent from 'api/common/logEvent';
|
||||
import { DEFAULT_ENTITY_VERSION, ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useScrollWidgetIntoView } from 'container/DashboardContainer/visualization/hooks/useScrollWidgetIntoView';
|
||||
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
|
||||
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { useIsPanelWaitingOnVariable } from 'hooks/dashboard/useVariableFetchState';
|
||||
@@ -67,11 +68,7 @@ function GridCardGraph({
|
||||
const [isInternalServerError, setIsInternalServerError] = useState<boolean>(
|
||||
false,
|
||||
);
|
||||
const {
|
||||
toScrollWidgetId,
|
||||
setToScrollWidgetId,
|
||||
setDashboardQueryRangeCalled,
|
||||
} = useDashboard();
|
||||
const { setDashboardQueryRangeCalled } = useDashboard();
|
||||
|
||||
const {
|
||||
minTime,
|
||||
@@ -109,20 +106,11 @@ function GridCardGraph({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const widgetContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isVisible = useIntersectionObserver(graphRef, undefined, true);
|
||||
const isVisible = useIntersectionObserver(widgetContainerRef, undefined, true);
|
||||
|
||||
useEffect(() => {
|
||||
if (toScrollWidgetId === widget.id) {
|
||||
graphRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
graphRef.current?.focus();
|
||||
setToScrollWidgetId('');
|
||||
}
|
||||
}, [toScrollWidgetId, setToScrollWidgetId, widget.id]);
|
||||
useScrollWidgetIntoView(widget?.id || '', widgetContainerRef);
|
||||
|
||||
const updatedQuery = widget?.query;
|
||||
|
||||
@@ -306,7 +294,7 @@ function GridCardGraph({
|
||||
: headerMenuList;
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
||||
<div style={{ height: '100%', width: '100%' }} ref={widgetContainerRef}>
|
||||
{isEmptyLayout ? (
|
||||
<EmptyWidget />
|
||||
) : (
|
||||
|
||||
@@ -4,8 +4,8 @@ import { getColorsForSeverityLabels, isRedLike } from '../utils';
|
||||
|
||||
describe('getColorsForSeverityLabels', () => {
|
||||
it('should return slate for blank labels', () => {
|
||||
expect(getColorsForSeverityLabels('', 0)).toBe(Color.BG_SLATE_300);
|
||||
expect(getColorsForSeverityLabels(' ', 0)).toBe(Color.BG_SLATE_300);
|
||||
expect(getColorsForSeverityLabels('', 0)).toBe(Color.BG_VANILLA_400);
|
||||
expect(getColorsForSeverityLabels(' ', 0)).toBe(Color.BG_VANILLA_400);
|
||||
});
|
||||
|
||||
it('should return correct colors for known severity variants', () => {
|
||||
|
||||
@@ -79,7 +79,7 @@ export function getColorsForSeverityLabels(
|
||||
const trimmed = label.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return Color.BG_SLATE_300;
|
||||
return Color.BG_VANILLA_400; // Default color for empty labels
|
||||
}
|
||||
|
||||
const variantColor = SEVERITY_VARIANT_COLORS[trimmed];
|
||||
@@ -119,6 +119,6 @@ export function getColorsForSeverityLabels(
|
||||
|
||||
return (
|
||||
SAFE_FALLBACK_COLORS[index % SAFE_FALLBACK_COLORS.length] ||
|
||||
Color.BG_SLATE_400
|
||||
Color.BG_VANILLA_400
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import NewWidget from 'container/NewWidget';
|
||||
import { logsPaginationQueryRangeSuccessResponse } from 'mocks-server/__mockdata__/logs_query_range';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import i18n from 'ReactI18';
|
||||
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
@@ -104,15 +103,13 @@ describe('LogsPanelComponent', () => {
|
||||
const renderComponent = async (): Promise<void> => {
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<DashboardProvider>
|
||||
<PreferenceContextProvider>
|
||||
<NewWidget
|
||||
selectedGraph={PANEL_TYPES.LIST}
|
||||
fillSpans={undefined}
|
||||
yAxisUnit={undefined}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
</DashboardProvider>
|
||||
<PreferenceContextProvider>
|
||||
<NewWidget
|
||||
dashboardId=""
|
||||
selectedDashboard={undefined}
|
||||
selectedGraph={PANEL_TYPES.LIST}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
</I18nextProvider>,
|
||||
);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Typography } from 'antd';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { Copy } from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { getMaskedKey } from 'utils/maskedKey';
|
||||
|
||||
import './LicenseSection.styles.scss';
|
||||
|
||||
@@ -12,15 +13,6 @@ function LicenseSection(): JSX.Element | null {
|
||||
const { notifications } = useNotifications();
|
||||
const [, handleCopyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const getMaskedKey = (key: string): string => {
|
||||
if (!key || key.length < 4) {
|
||||
return key || 'N/A';
|
||||
}
|
||||
return `${key.substring(0, 2)}********${key
|
||||
.substring(key.length - 2)
|
||||
.trim()}`;
|
||||
};
|
||||
|
||||
const handleCopyKey = (text: string): void => {
|
||||
handleCopyToClipboard(text);
|
||||
notifications.success({
|
||||
|
||||
@@ -271,7 +271,7 @@ describe('MySettings Flows', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(within(container).getByText('ab********cd')).toBeInTheDocument();
|
||||
expect(within(container).getByText('ab·······cd')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should not mask license key if it is too short', () => {
|
||||
|
||||
@@ -8,28 +8,15 @@ import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { QBShortcuts } from 'constants/shortcuts/QBShortcuts';
|
||||
import {
|
||||
getDefaultWidgetData,
|
||||
PANEL_TYPE_TO_QUERY_TYPES,
|
||||
} from 'container/NewWidget/utils';
|
||||
import { PANEL_TYPE_TO_QUERY_TYPES } from 'container/NewWidget/utils';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
// import { QueryBuilder } from 'container/QueryBuilder';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { defaultTo, isUndefined } from 'lodash-es';
|
||||
import { Atom, Terminal } from 'lucide-react';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import {
|
||||
getNextWidgets,
|
||||
getPreviousWidgets,
|
||||
getSelectedWidgetIndex,
|
||||
} from 'providers/Dashboard/util';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import ClickHouseQueryContainer from './QueryBuilder/clickHouse';
|
||||
@@ -40,77 +27,25 @@ function QuerySection({
|
||||
selectedGraph,
|
||||
queryRangeKey,
|
||||
isLoadingQueries,
|
||||
selectedWidget,
|
||||
dashboardVersion,
|
||||
dashboardId,
|
||||
dashboardName,
|
||||
isNewPanel,
|
||||
}: QueryProps): JSX.Element {
|
||||
const {
|
||||
currentQuery,
|
||||
handleRunQuery: handleRunQueryFromQueryBuilder,
|
||||
redirectWithQueryBuilderData,
|
||||
} = useQueryBuilder();
|
||||
const urlQuery = useUrlQuery();
|
||||
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
|
||||
|
||||
const { selectedDashboard, setSelectedDashboard } = useDashboard();
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const { widgets } = selectedDashboard?.data || {};
|
||||
|
||||
const getWidget = useCallback(() => {
|
||||
const widgetId = urlQuery.get('widgetId');
|
||||
return defaultTo(
|
||||
widgets?.find((e) => e.id === widgetId),
|
||||
getDefaultWidgetData(widgetId || '', selectedGraph),
|
||||
);
|
||||
}, [urlQuery, widgets, selectedGraph]);
|
||||
|
||||
const selectedWidget = getWidget() as Widgets;
|
||||
|
||||
const { query } = selectedWidget;
|
||||
|
||||
useShareBuilderUrl({ defaultValue: query });
|
||||
|
||||
const handleStageQuery = useCallback(
|
||||
(query: Query): void => {
|
||||
if (selectedDashboard === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedWidgetIndex = getSelectedWidgetIndex(
|
||||
selectedDashboard,
|
||||
selectedWidget.id,
|
||||
);
|
||||
|
||||
const previousWidgets = getPreviousWidgets(
|
||||
selectedDashboard,
|
||||
selectedWidgetIndex,
|
||||
);
|
||||
|
||||
const nextWidgets = getNextWidgets(selectedDashboard, selectedWidgetIndex);
|
||||
|
||||
setSelectedDashboard({
|
||||
...selectedDashboard,
|
||||
data: {
|
||||
...selectedDashboard?.data,
|
||||
widgets: [
|
||||
...previousWidgets,
|
||||
{
|
||||
...selectedWidget,
|
||||
query,
|
||||
},
|
||||
...nextWidgets,
|
||||
],
|
||||
},
|
||||
});
|
||||
handleRunQueryFromQueryBuilder();
|
||||
},
|
||||
[
|
||||
selectedDashboard,
|
||||
selectedWidget,
|
||||
setSelectedDashboard,
|
||||
handleRunQueryFromQueryBuilder,
|
||||
],
|
||||
);
|
||||
|
||||
const handleQueryCategoryChange = useCallback(
|
||||
(qCategory: string): void => {
|
||||
const currentQueryType = qCategory as EQueryType;
|
||||
@@ -123,19 +58,16 @@ function QuerySection({
|
||||
);
|
||||
|
||||
const handleRunQuery = (): void => {
|
||||
const widgetId = urlQuery.get('widgetId');
|
||||
const isNewPanel = isUndefined(widgets?.find((e) => e.id === widgetId));
|
||||
|
||||
logEvent('Panel Edit: Stage and run query', {
|
||||
dataSource: currentQuery.builder?.queryData?.[0]?.dataSource,
|
||||
panelType: selectedWidget.panelTypes,
|
||||
queryType: currentQuery.queryType,
|
||||
widgetId: selectedWidget.id,
|
||||
dashboardId: selectedDashboard?.id,
|
||||
dashboardName: selectedDashboard?.data.title,
|
||||
dashboardId,
|
||||
dashboardName,
|
||||
isNewPanel,
|
||||
});
|
||||
handleStageQuery(currentQuery);
|
||||
handleRunQueryFromQueryBuilder();
|
||||
};
|
||||
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
|
||||
@@ -164,7 +96,7 @@ function QuerySection({
|
||||
panelType={selectedGraph}
|
||||
filterConfigs={filterConfigs}
|
||||
showTraceOperator={selectedGraph !== PANEL_TYPES.LIST}
|
||||
version={selectedDashboard?.data?.version || 'v3'}
|
||||
version={dashboardVersion || 'v3'}
|
||||
isListViewPanel={selectedGraph === PANEL_TYPES.LIST}
|
||||
queryComponents={queryComponents}
|
||||
signalSourceChangeEnabled
|
||||
@@ -204,7 +136,7 @@ function QuerySection({
|
||||
queryComponents,
|
||||
selectedGraph,
|
||||
filterConfigs,
|
||||
selectedDashboard?.data?.version,
|
||||
dashboardVersion,
|
||||
isDarkMode,
|
||||
]);
|
||||
|
||||
@@ -261,6 +193,11 @@ interface QueryProps {
|
||||
selectedGraph: PANEL_TYPES;
|
||||
queryRangeKey?: QueryKey;
|
||||
isLoadingQueries?: boolean;
|
||||
selectedWidget: Widgets;
|
||||
dashboardVersion?: string;
|
||||
dashboardId?: string;
|
||||
dashboardName?: string;
|
||||
isNewPanel?: boolean;
|
||||
}
|
||||
|
||||
export default QuerySection;
|
||||
|
||||
@@ -30,6 +30,8 @@ function LeftContainer({
|
||||
setRequestData,
|
||||
setQueryResponse,
|
||||
enableDrillDown = false,
|
||||
selectedDashboard,
|
||||
isNewPanel = false,
|
||||
}: WidgetGraphProps): JSX.Element {
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
|
||||
@@ -75,6 +77,11 @@ function LeftContainer({
|
||||
selectedGraph={selectedGraph}
|
||||
queryRangeKey={queryRangeKey}
|
||||
isLoadingQueries={queryResponse.isFetching}
|
||||
selectedWidget={selectedWidget}
|
||||
dashboardVersion={ENTITY_VERSION_V5}
|
||||
dashboardId={selectedDashboard?.id}
|
||||
dashboardName={selectedDashboard?.data.title}
|
||||
isNewPanel={isNewPanel}
|
||||
/>
|
||||
{selectedGraph === PANEL_TYPES.LIST && (
|
||||
<ExplorerColumnsRenderer
|
||||
|
||||
@@ -8,7 +8,6 @@ import userEvent from '@testing-library/user-event';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { AppContext } from 'providers/App/App';
|
||||
import { IAppContext } from 'providers/App/types';
|
||||
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
|
||||
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
import configureStore from 'redux-mock-store';
|
||||
@@ -96,9 +95,7 @@ const render = (ui: React.ReactElement): ReturnType<typeof rtlRender> =>
|
||||
<Provider store={createMockStore()}>
|
||||
<AppContext.Provider value={createMockAppContext() as IAppContext}>
|
||||
<ErrorModalProvider>
|
||||
<DashboardProvider>
|
||||
<QueryBuilderProvider>{ui}</QueryBuilderProvider>
|
||||
</DashboardProvider>
|
||||
<QueryBuilderProvider>{ui}</QueryBuilderProvider>
|
||||
</ErrorModalProvider>
|
||||
</AppContext.Provider>
|
||||
</Provider>
|
||||
|
||||
@@ -310,12 +310,12 @@ describe('Stacking bar in new panel', () => {
|
||||
|
||||
const { container, getByText } = render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<DashboardProvider>
|
||||
<DashboardProvider dashboardId="">
|
||||
<PreferenceContextProvider>
|
||||
<NewWidget
|
||||
dashboardId=""
|
||||
selectedDashboard={undefined}
|
||||
selectedGraph={PANEL_TYPES.BAR}
|
||||
fillSpans={undefined}
|
||||
yAxisUnit={undefined}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
</DashboardProvider>
|
||||
@@ -356,11 +356,11 @@ describe('when switching to BAR panel type', () => {
|
||||
|
||||
it('should preserve saved stacking value of true', async () => {
|
||||
const { getByTestId, getByText, container } = render(
|
||||
<DashboardProvider>
|
||||
<DashboardProvider dashboardId="">
|
||||
<NewWidget
|
||||
dashboardId=""
|
||||
selectedDashboard={undefined}
|
||||
selectedGraph={PANEL_TYPES.BAR}
|
||||
fillSpans={undefined}
|
||||
yAxisUnit={undefined}
|
||||
/>
|
||||
</DashboardProvider>,
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { generatePath, useParams } from 'react-router-dom';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { WarningOutlined } from '@ant-design/icons';
|
||||
import { Button, Flex, Modal, Space, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -32,8 +32,7 @@ import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
|
||||
import { cloneDeep, defaultTo, isEmpty, isUndefined } from 'lodash-es';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { DashboardWidgetPageParams } from 'pages/DashboardWidget';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useScrollToWidgetIdStore } from 'providers/Dashboard/helpers/scrollToWidgetIdHelper';
|
||||
import {
|
||||
clearSelectedRowWidgetId,
|
||||
getSelectedRowWidgetId,
|
||||
@@ -82,16 +81,15 @@ import {
|
||||
import './NewWidget.styles.scss';
|
||||
|
||||
function NewWidget({
|
||||
selectedDashboard,
|
||||
dashboardId,
|
||||
selectedGraph,
|
||||
enableDrillDown = false,
|
||||
}: NewWidgetProps): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const {
|
||||
selectedDashboard,
|
||||
setSelectedDashboard,
|
||||
setToScrollWidgetId,
|
||||
columnWidths,
|
||||
} = useDashboard();
|
||||
const setToScrollWidgetId = useScrollToWidgetIdStore(
|
||||
(s) => s.setToScrollWidgetId,
|
||||
);
|
||||
|
||||
const { dashboardVariables } = useDashboardVariables();
|
||||
|
||||
@@ -136,8 +134,6 @@ function NewWidget({
|
||||
|
||||
const query = useUrlQuery();
|
||||
|
||||
const { dashboardId } = useParams<DashboardWidgetPageParams>();
|
||||
|
||||
const [isNewDashboard, setIsNewDashboard] = useState<boolean>(false);
|
||||
|
||||
const logEventCalledRef = useRef(false);
|
||||
@@ -283,11 +279,10 @@ function NewWidget({
|
||||
isLogScale,
|
||||
legendPosition,
|
||||
customLegendColors,
|
||||
columnWidths: columnWidths?.[selectedWidget?.id],
|
||||
columnWidths: selectedWidget.columnWidths,
|
||||
contextLinks,
|
||||
};
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
columnUnits,
|
||||
currentQuery,
|
||||
@@ -310,8 +305,8 @@ function NewWidget({
|
||||
isLogScale,
|
||||
legendPosition,
|
||||
customLegendColors,
|
||||
columnWidths,
|
||||
contextLinks,
|
||||
selectedWidget.columnWidths,
|
||||
]);
|
||||
|
||||
const closeModal = (): void => {
|
||||
@@ -444,6 +439,19 @@ function NewWidget({
|
||||
globalSelectedInterval,
|
||||
]);
|
||||
|
||||
const navigateToDashboardPage = useCallback(() => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
const urlVariablesQueryString = query.get(QueryParams.variables);
|
||||
if (urlVariablesQueryString) {
|
||||
params.set(QueryParams.variables, urlVariablesQueryString);
|
||||
}
|
||||
|
||||
const search = params.toString() ? `?${params.toString()}` : '';
|
||||
|
||||
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId }) + search);
|
||||
}, [dashboardId, query, safeNavigate]);
|
||||
|
||||
const onClickSaveHandler = useCallback(() => {
|
||||
if (!selectedDashboard) {
|
||||
return;
|
||||
@@ -557,12 +565,9 @@ function NewWidget({
|
||||
};
|
||||
|
||||
updateDashboardMutation.mutateAsync(dashboard, {
|
||||
onSuccess: (updatedDashboard) => {
|
||||
setSelectedDashboard(updatedDashboard.data);
|
||||
onSuccess: () => {
|
||||
setToScrollWidgetId(selectedWidget?.id || '');
|
||||
safeNavigate({
|
||||
pathname: generatePath(ROUTES.DASHBOARD, { dashboardId }),
|
||||
});
|
||||
navigateToDashboardPage();
|
||||
},
|
||||
});
|
||||
}, [
|
||||
@@ -577,9 +582,8 @@ function NewWidget({
|
||||
preWidgets,
|
||||
updateDashboardMutation,
|
||||
widgets,
|
||||
setSelectedDashboard,
|
||||
setToScrollWidgetId,
|
||||
safeNavigate,
|
||||
navigateToDashboardPage,
|
||||
dashboardId,
|
||||
]);
|
||||
|
||||
@@ -588,12 +592,12 @@ function NewWidget({
|
||||
setDiscardModal(true);
|
||||
return;
|
||||
}
|
||||
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId }));
|
||||
}, [dashboardId, isQueryModified, safeNavigate]);
|
||||
navigateToDashboardPage();
|
||||
}, [isQueryModified, navigateToDashboardPage]);
|
||||
|
||||
const discardChanges = useCallback(() => {
|
||||
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId }));
|
||||
}, [dashboardId, safeNavigate]);
|
||||
navigateToDashboardPage();
|
||||
}, [navigateToDashboardPage]);
|
||||
|
||||
const setGraphHandler = (type: PANEL_TYPES): void => {
|
||||
setIsLoadingPanelData(true);
|
||||
@@ -627,22 +631,25 @@ function NewWidget({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query]);
|
||||
|
||||
const onSaveDashboard = useCallback((): void => {
|
||||
const isNewPanel = useMemo(() => {
|
||||
const widgetId = query.get('widgetId');
|
||||
const selectWidget = widgets?.find((e) => e.id === widgetId);
|
||||
const selectedWidget = widgets?.find((e) => e.id === widgetId);
|
||||
return isUndefined(selectedWidget);
|
||||
}, [query, widgets]);
|
||||
|
||||
const onSaveDashboard = useCallback((): void => {
|
||||
logEvent('Panel Edit: Save changes', {
|
||||
panelType: selectedWidget.panelTypes,
|
||||
dashboardId: selectedDashboard?.id,
|
||||
widgetId: selectedWidget.id,
|
||||
dashboardName: selectedDashboard?.data.title,
|
||||
queryType: currentQuery.queryType,
|
||||
isNewPanel: isUndefined(selectWidget),
|
||||
isNewPanel,
|
||||
dataSource: currentQuery?.builder?.queryData?.[0]?.dataSource,
|
||||
});
|
||||
setSaveModal(true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [isNewPanel]);
|
||||
|
||||
const isNewTraceLogsAvailable =
|
||||
currentQuery.queryType === EQueryType.QUERY_BUILDER &&
|
||||
@@ -732,12 +739,14 @@ function NewWidget({
|
||||
}
|
||||
const widgetId = query.get('widgetId') || '';
|
||||
const graphType = query.get('graphType') || '';
|
||||
const variables = query.get(QueryParams.variables) || '';
|
||||
const queryParams = {
|
||||
[QueryParams.expandedWidgetId]: widgetId,
|
||||
[QueryParams.graphType]: graphType,
|
||||
[QueryParams.compositeQuery]: encodeURIComponent(
|
||||
JSON.stringify(currentQuery),
|
||||
),
|
||||
[QueryParams.variables]: variables,
|
||||
};
|
||||
|
||||
const updatedSearch = createQueryParams(queryParams);
|
||||
@@ -818,6 +827,8 @@ function NewWidget({
|
||||
isLoadingPanelData={isLoadingPanelData}
|
||||
setQueryResponse={setQueryResponse}
|
||||
enableDrillDown={enableDrillDown}
|
||||
selectedDashboard={selectedDashboard}
|
||||
isNewPanel={isNewPanel}
|
||||
/>
|
||||
)}
|
||||
</OverlayScrollbar>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Dispatch, SetStateAction } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { IDashboardContext } from 'providers/Dashboard/types';
|
||||
import { SuccessResponse, Warning } from 'types/api';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
@@ -9,9 +10,9 @@ import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { timePreferance } from './RightContainer/timeItems';
|
||||
|
||||
export interface NewWidgetProps {
|
||||
dashboardId: string;
|
||||
selectedDashboard: IDashboardContext['selectedDashboard'];
|
||||
selectedGraph: PANEL_TYPES;
|
||||
yAxisUnit: Widgets['yAxisUnit'];
|
||||
fillSpans: Widgets['fillSpans'];
|
||||
enableDrillDown?: boolean;
|
||||
}
|
||||
|
||||
@@ -34,6 +35,8 @@ export interface WidgetGraphProps {
|
||||
>
|
||||
>;
|
||||
enableDrillDown?: boolean;
|
||||
selectedDashboard: IDashboardContext['selectedDashboard'];
|
||||
isNewPanel?: boolean;
|
||||
}
|
||||
|
||||
export type WidgetGraphContainerProps = {
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { ToggleGraphProps } from 'components/Graph/types';
|
||||
import Uplot from 'components/Uplot';
|
||||
import GraphManager from 'container/GridCardLayout/GridCard/FullView/GraphManager';
|
||||
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
|
||||
import { getUplotClickData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import useGraphContextMenu from 'container/QueryTable/Drilldown/useGraphContextMenu';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { getUplotHistogramChartOptions } from 'lib/uPlotLib/getUplotHistogramChartOptions';
|
||||
import _noop from 'lodash-es/noop';
|
||||
import { ContextMenu, useCoordinates } from 'periscope/components/ContextMenu';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
|
||||
import { buildHistogramData } from './histogram';
|
||||
import { PanelWrapperProps } from './panelWrapper.types';
|
||||
|
||||
function HistogramPanelWrapper({
|
||||
queryResponse,
|
||||
widget,
|
||||
setGraphVisibility,
|
||||
graphVisibility,
|
||||
isFullViewMode,
|
||||
onToggleModelHandler,
|
||||
onClickHandler,
|
||||
enableDrillDown = false,
|
||||
}: PanelWrapperProps): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const legendScrollPositionRef = useRef<number>(0);
|
||||
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
const {
|
||||
coordinates,
|
||||
popoverPosition,
|
||||
clickedData,
|
||||
onClose,
|
||||
onClick,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
} = useCoordinates();
|
||||
const { menuItemsConfig } = useGraphContextMenu({
|
||||
widgetId: widget.id || '',
|
||||
query: widget.query,
|
||||
graphData: clickedData,
|
||||
onClose,
|
||||
coordinates,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
contextLinks: widget.contextLinks,
|
||||
panelType: widget.panelTypes,
|
||||
queryRange: queryResponse,
|
||||
});
|
||||
|
||||
const clickHandlerWithContextMenu = useCallback(
|
||||
(...args: any[]) => {
|
||||
const [
|
||||
,
|
||||
,
|
||||
,
|
||||
,
|
||||
metric,
|
||||
queryData,
|
||||
absoluteMouseX,
|
||||
absoluteMouseY,
|
||||
,
|
||||
focusedSeries,
|
||||
] = args;
|
||||
const data = getUplotClickData({
|
||||
metric,
|
||||
queryData,
|
||||
absoluteMouseX,
|
||||
absoluteMouseY,
|
||||
focusedSeries,
|
||||
});
|
||||
if (data && data?.record?.queryName) {
|
||||
onClick(data.coord, { ...data.record, label: data.label });
|
||||
}
|
||||
},
|
||||
[onClick],
|
||||
);
|
||||
|
||||
const histogramData = buildHistogramData(
|
||||
queryResponse.data?.payload.data.result,
|
||||
widget?.bucketWidth,
|
||||
widget?.bucketCount,
|
||||
widget?.mergeAllActiveQueries,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (toScrollWidgetId === widget.id) {
|
||||
graphRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
graphRef.current?.focus();
|
||||
setToScrollWidgetId('');
|
||||
}
|
||||
}, [toScrollWidgetId, setToScrollWidgetId, widget.id]);
|
||||
const lineChartRef = useRef<ToggleGraphProps>();
|
||||
|
||||
useEffect(() => {
|
||||
const {
|
||||
graphVisibilityStates: localStoredVisibilityState,
|
||||
} = getLocalStorageGraphVisibilityState({
|
||||
apiResponse: queryResponse.data?.payload.data.result || [],
|
||||
name: widget.id,
|
||||
});
|
||||
if (setGraphVisibility) {
|
||||
setGraphVisibility(localStoredVisibilityState);
|
||||
}
|
||||
}, [
|
||||
queryResponse?.data?.payload?.data?.result,
|
||||
setGraphVisibility,
|
||||
widget.id,
|
||||
]);
|
||||
|
||||
const histogramOptions = useMemo(
|
||||
() =>
|
||||
getUplotHistogramChartOptions({
|
||||
id: widget.id,
|
||||
dimensions: containerDimensions,
|
||||
isDarkMode,
|
||||
apiResponse: queryResponse.data?.payload,
|
||||
histogramData,
|
||||
panelType: widget.panelTypes,
|
||||
setGraphsVisibilityStates: setGraphVisibility,
|
||||
graphsVisibilityStates: graphVisibility,
|
||||
mergeAllQueries: widget.mergeAllActiveQueries,
|
||||
onClickHandler: enableDrillDown
|
||||
? clickHandlerWithContextMenu
|
||||
: onClickHandler ?? _noop,
|
||||
legendScrollPosition: legendScrollPositionRef.current,
|
||||
setLegendScrollPosition: (position: number) => {
|
||||
legendScrollPositionRef.current = position;
|
||||
},
|
||||
}),
|
||||
[
|
||||
containerDimensions,
|
||||
graphVisibility,
|
||||
histogramData,
|
||||
isDarkMode,
|
||||
queryResponse.data?.payload,
|
||||
setGraphVisibility,
|
||||
widget.id,
|
||||
widget.mergeAllActiveQueries,
|
||||
widget.panelTypes,
|
||||
clickHandlerWithContextMenu,
|
||||
enableDrillDown,
|
||||
onClickHandler,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
||||
<Uplot options={histogramOptions} data={histogramData} ref={lineChartRef} />
|
||||
<ContextMenu
|
||||
coordinates={coordinates}
|
||||
popoverPosition={popoverPosition}
|
||||
title={menuItemsConfig.header as string}
|
||||
items={menuItemsConfig.items}
|
||||
onClose={onClose}
|
||||
/>
|
||||
{isFullViewMode && setGraphVisibility && !widget.mergeAllActiveQueries && (
|
||||
<GraphManager
|
||||
data={histogramData}
|
||||
name={widget.id}
|
||||
options={histogramOptions}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
onToggleModelHandler={onToggleModelHandler}
|
||||
setGraphsVisibilityStates={setGraphVisibility}
|
||||
graphsVisibilityStates={graphVisibility}
|
||||
lineChartRef={lineChartRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HistogramPanelWrapper;
|
||||
@@ -1,4 +0,0 @@
|
||||
.info-text {
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Alert } from 'antd';
|
||||
import { ToggleGraphProps } from 'components/Graph/types';
|
||||
import Uplot from 'components/Uplot';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import GraphManager from 'container/GridCardLayout/GridCard/FullView/GraphManager';
|
||||
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
|
||||
import { getUplotClickData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import useGraphContextMenu from 'container/QueryTable/Drilldown/useGraphContextMenu';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
|
||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { cloneDeep, isEqual, isUndefined } from 'lodash-es';
|
||||
import _noop from 'lodash-es/noop';
|
||||
import { ContextMenu, useCoordinates } from 'periscope/components/ContextMenu';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import uPlot from 'uplot';
|
||||
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
|
||||
import { getTimeRange } from 'utils/getTimeRange';
|
||||
|
||||
import { PanelWrapperProps } from './panelWrapper.types';
|
||||
import { getTimeRangeFromStepInterval, isApmMetric } from './utils';
|
||||
|
||||
import './UplotPanelWrapper.styles.scss';
|
||||
|
||||
function UplotPanelWrapper({
|
||||
queryResponse,
|
||||
widget,
|
||||
isFullViewMode,
|
||||
setGraphVisibility,
|
||||
graphVisibility,
|
||||
onToggleModelHandler,
|
||||
onClickHandler,
|
||||
onDragSelect,
|
||||
selectedGraph,
|
||||
customTooltipElement,
|
||||
customSeries,
|
||||
enableDrillDown = false,
|
||||
}: PanelWrapperProps): JSX.Element {
|
||||
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const lineChartRef = useRef<ToggleGraphProps>();
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const legendScrollPositionRef = useRef<{
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}>({
|
||||
scrollTop: 0,
|
||||
scrollLeft: 0,
|
||||
});
|
||||
const [minTimeScale, setMinTimeScale] = useState<number>();
|
||||
const [maxTimeScale, setMaxTimeScale] = useState<number>();
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
const [hiddenGraph, setHiddenGraph] = useState<{ [key: string]: boolean }>();
|
||||
|
||||
useEffect(() => {
|
||||
if (toScrollWidgetId === widget.id) {
|
||||
graphRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
graphRef.current?.focus();
|
||||
setToScrollWidgetId('');
|
||||
}
|
||||
}, [toScrollWidgetId, setToScrollWidgetId, widget.id]);
|
||||
|
||||
useEffect((): void => {
|
||||
const { startTime, endTime } = getTimeRange(queryResponse);
|
||||
|
||||
setMinTimeScale(startTime);
|
||||
setMaxTimeScale(endTime);
|
||||
}, [queryResponse]);
|
||||
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
|
||||
const {
|
||||
coordinates,
|
||||
popoverPosition,
|
||||
clickedData,
|
||||
onClose,
|
||||
onClick,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
} = useCoordinates();
|
||||
const { menuItemsConfig } = useGraphContextMenu({
|
||||
widgetId: widget.id || '',
|
||||
query: widget.query,
|
||||
graphData: clickedData,
|
||||
onClose,
|
||||
coordinates,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
contextLinks: widget.contextLinks,
|
||||
panelType: widget.panelTypes,
|
||||
queryRange: queryResponse,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const {
|
||||
graphVisibilityStates: localStoredVisibilityState,
|
||||
} = getLocalStorageGraphVisibilityState({
|
||||
apiResponse: queryResponse.data?.payload.data.result || [],
|
||||
name: widget.id,
|
||||
});
|
||||
if (setGraphVisibility) {
|
||||
setGraphVisibility(localStoredVisibilityState);
|
||||
}
|
||||
}, [
|
||||
queryResponse?.data?.payload?.data?.result,
|
||||
setGraphVisibility,
|
||||
widget.id,
|
||||
]);
|
||||
|
||||
if (queryResponse.data && widget.panelTypes === PANEL_TYPES.BAR) {
|
||||
const sortedSeriesData = getSortedSeriesData(
|
||||
queryResponse.data?.payload.data.result,
|
||||
);
|
||||
queryResponse.data.payload.data.result = sortedSeriesData;
|
||||
}
|
||||
|
||||
const stackedBarChart = useMemo(
|
||||
() =>
|
||||
(selectedGraph
|
||||
? selectedGraph === PANEL_TYPES.BAR
|
||||
: widget?.panelTypes === PANEL_TYPES.BAR) && widget?.stackedBarChart,
|
||||
[selectedGraph, widget?.panelTypes, widget?.stackedBarChart],
|
||||
);
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
getUPlotChartData(
|
||||
queryResponse?.data?.payload,
|
||||
widget.fillSpans,
|
||||
stackedBarChart,
|
||||
hiddenGraph,
|
||||
),
|
||||
[
|
||||
queryResponse?.data?.payload,
|
||||
widget.fillSpans,
|
||||
stackedBarChart,
|
||||
hiddenGraph,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (widget.panelTypes === PANEL_TYPES.BAR && stackedBarChart) {
|
||||
const graphV = cloneDeep(graphVisibility)?.slice(1);
|
||||
const isSomeSelectedLegend = graphV?.some((v) => v === false);
|
||||
if (isSomeSelectedLegend) {
|
||||
const hiddenIndex = graphV?.findIndex((v) => v === true);
|
||||
if (!isUndefined(hiddenIndex) && hiddenIndex !== -1) {
|
||||
const updatedHiddenGraph = { [hiddenIndex]: true };
|
||||
if (!isEqual(hiddenGraph, updatedHiddenGraph)) {
|
||||
setHiddenGraph(updatedHiddenGraph);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [graphVisibility, hiddenGraph, widget.panelTypes, stackedBarChart]);
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const clickHandlerWithContextMenu = useCallback(
|
||||
(...args: any[]) => {
|
||||
const [
|
||||
xValue,
|
||||
,
|
||||
,
|
||||
,
|
||||
metric,
|
||||
queryData,
|
||||
absoluteMouseX,
|
||||
absoluteMouseY,
|
||||
axesData,
|
||||
focusedSeries,
|
||||
] = args;
|
||||
const data = getUplotClickData({
|
||||
metric,
|
||||
queryData,
|
||||
absoluteMouseX,
|
||||
absoluteMouseY,
|
||||
focusedSeries,
|
||||
});
|
||||
// Compute time range if needed and if axes data is available
|
||||
let timeRange;
|
||||
if (axesData && queryData?.queryName) {
|
||||
// Get the compositeQuery from the response params
|
||||
const compositeQuery = (queryResponse?.data?.params as any)?.compositeQuery;
|
||||
|
||||
if (compositeQuery?.queries) {
|
||||
// Find the specific query by name from the queries array
|
||||
const specificQuery = compositeQuery.queries.find(
|
||||
(query: any) => query.spec?.name === queryData.queryName,
|
||||
);
|
||||
|
||||
// Use the stepInterval from the specific query, fallback to default
|
||||
const stepInterval = specificQuery?.spec?.stepInterval || 60;
|
||||
timeRange = getTimeRangeFromStepInterval(
|
||||
stepInterval,
|
||||
metric?.clickedTimestamp || xValue, // Use the clicked timestamp if available, otherwise use the click position timestamp
|
||||
specificQuery?.spec?.signal === DataSource.METRICS &&
|
||||
isApmMetric(specificQuery?.spec?.aggregations[0]?.metricName),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (data && data?.record?.queryName) {
|
||||
onClick(data.coord, { ...data.record, label: data.label, timeRange });
|
||||
}
|
||||
},
|
||||
[onClick, queryResponse],
|
||||
);
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
getUPlotChartOptions({
|
||||
id: widget?.id,
|
||||
apiResponse: queryResponse.data?.payload,
|
||||
dimensions: containerDimensions,
|
||||
isDarkMode,
|
||||
onDragSelect,
|
||||
yAxisUnit: widget?.yAxisUnit,
|
||||
onClickHandler: enableDrillDown
|
||||
? clickHandlerWithContextMenu
|
||||
: onClickHandler ?? _noop,
|
||||
thresholds: widget.thresholds,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
softMax: widget.softMax === undefined ? null : widget.softMax,
|
||||
softMin: widget.softMin === undefined ? null : widget.softMin,
|
||||
graphsVisibilityStates: graphVisibility,
|
||||
setGraphsVisibilityStates: setGraphVisibility,
|
||||
panelType: selectedGraph || widget.panelTypes,
|
||||
currentQuery,
|
||||
stackBarChart: stackedBarChart,
|
||||
hiddenGraph,
|
||||
setHiddenGraph,
|
||||
customTooltipElement,
|
||||
tzDate: (timestamp: number) =>
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value),
|
||||
timezone: timezone.value,
|
||||
customSeries,
|
||||
isLogScale: widget?.isLogScale,
|
||||
colorMapping: widget?.customLegendColors,
|
||||
enhancedLegend: true, // Enable enhanced legend
|
||||
legendPosition: widget?.legendPosition,
|
||||
query: widget?.query || currentQuery,
|
||||
legendScrollPosition: legendScrollPositionRef.current,
|
||||
setLegendScrollPosition: (position: {
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}) => {
|
||||
legendScrollPositionRef.current = position;
|
||||
},
|
||||
decimalPrecision: widget.decimalPrecision,
|
||||
}),
|
||||
[
|
||||
queryResponse.data?.payload,
|
||||
containerDimensions,
|
||||
isDarkMode,
|
||||
onDragSelect,
|
||||
clickHandlerWithContextMenu,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
graphVisibility,
|
||||
setGraphVisibility,
|
||||
selectedGraph,
|
||||
currentQuery,
|
||||
hiddenGraph,
|
||||
customTooltipElement,
|
||||
timezone.value,
|
||||
customSeries,
|
||||
enableDrillDown,
|
||||
onClickHandler,
|
||||
widget,
|
||||
stackedBarChart,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
||||
<Uplot options={options} data={chartData} ref={lineChartRef} />
|
||||
<ContextMenu
|
||||
coordinates={coordinates}
|
||||
popoverPosition={popoverPosition}
|
||||
title={menuItemsConfig.header as string}
|
||||
items={menuItemsConfig.items}
|
||||
onClose={onClose}
|
||||
/>
|
||||
{stackedBarChart && isFullViewMode && (
|
||||
<Alert
|
||||
message="Selecting multiple legends is currently not supported in case of stacked bar charts"
|
||||
type="info"
|
||||
className="info-text"
|
||||
/>
|
||||
)}
|
||||
{isFullViewMode && setGraphVisibility && !stackedBarChart && (
|
||||
<GraphManager
|
||||
data={chartData}
|
||||
name={widget.id}
|
||||
options={options}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
onToggleModelHandler={onToggleModelHandler}
|
||||
setGraphsVisibilityStates={setGraphVisibility}
|
||||
graphsVisibilityStates={graphVisibility}
|
||||
lineChartRef={lineChartRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UplotPanelWrapper;
|
||||
@@ -38,6 +38,7 @@ export const routeConfig: Record<string, QueryParams[]> = {
|
||||
[ROUTES.MY_SETTINGS]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.NOT_FOUND]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.ORG_SETTINGS]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.MEMBERS_SETTINGS]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.PASSWORD_RESET]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.SETTINGS]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.SIGN_UP]: [QueryParams.resourceAttributes],
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useDashboardVariablesFromLocalStorage } from 'hooks/dashboard/useDashboardFromLocalStorage';
|
||||
import { useTransformDashboardVariables } from 'hooks/dashboard/useTransformDashboardVariables';
|
||||
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
|
||||
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
jest.mock('hooks/dashboard/useDashboardFromLocalStorage');
|
||||
jest.mock('hooks/dashboard/useVariablesFromUrl');
|
||||
|
||||
const mockUseDashboardVariablesFromLocalStorage = useDashboardVariablesFromLocalStorage as jest.MockedFunction<
|
||||
typeof useDashboardVariablesFromLocalStorage
|
||||
>;
|
||||
const mockUseVariablesFromUrl = useVariablesFromUrl as jest.MockedFunction<
|
||||
typeof useVariablesFromUrl
|
||||
>;
|
||||
|
||||
const makeVariable = (
|
||||
overrides: Partial<IDashboardVariable> = {},
|
||||
): IDashboardVariable => ({
|
||||
id: 'existing-id',
|
||||
name: 'env',
|
||||
description: '',
|
||||
type: 'QUERY',
|
||||
sort: 'DISABLED',
|
||||
multiSelect: false,
|
||||
showALLOption: false,
|
||||
selectedValue: 'prod',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeDashboard = (
|
||||
variables: Record<string, IDashboardVariable>,
|
||||
): Dashboard => ({
|
||||
id: 'dash-1',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
createdBy: '',
|
||||
updatedBy: '',
|
||||
data: {
|
||||
title: 'Test',
|
||||
variables,
|
||||
},
|
||||
});
|
||||
|
||||
const setupHook = (
|
||||
currentDashboard: Record<string, any> = {},
|
||||
urlVariables: Record<string, any> = {},
|
||||
): ReturnType<typeof useTransformDashboardVariables> => {
|
||||
mockUseDashboardVariablesFromLocalStorage.mockReturnValue({
|
||||
currentDashboard,
|
||||
updateLocalStorageDashboardVariables: jest.fn(),
|
||||
});
|
||||
mockUseVariablesFromUrl.mockReturnValue({
|
||||
getUrlVariables: () => urlVariables,
|
||||
setUrlVariables: jest.fn(),
|
||||
updateUrlVariable: jest.fn(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTransformDashboardVariables('dash-1'));
|
||||
return result.current;
|
||||
};
|
||||
|
||||
describe('useTransformDashboardVariables', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
describe('order assignment', () => {
|
||||
it('assigns order starting from 0 to variables that have none', () => {
|
||||
const { transformDashboardVariables } = setupHook();
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({ id: 'id1', name: 'v1', order: undefined }),
|
||||
v2: makeVariable({ id: 'id2', name: 'v2', order: undefined }),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
const orders = Object.values(result.data.variables).map((v) => v.order);
|
||||
expect(orders).toContain(0);
|
||||
expect(orders).toContain(1);
|
||||
});
|
||||
|
||||
it('preserves existing order values', () => {
|
||||
const { transformDashboardVariables } = setupHook();
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({ id: 'id1', name: 'v1', order: 5 }),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.order).toBe(5);
|
||||
});
|
||||
|
||||
it('assigns unique orders across multiple variables that all lack an order', () => {
|
||||
const { transformDashboardVariables } = setupHook();
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({ id: 'id1', name: 'v1', order: undefined }),
|
||||
v2: makeVariable({ id: 'id2', name: 'v2', order: undefined }),
|
||||
v3: makeVariable({ id: 'id3', name: 'v3', order: undefined }),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
const orders = Object.values(result.data.variables).map((v) => v.order);
|
||||
// All three newly assigned orders must be distinct
|
||||
expect(new Set(orders).size).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ID assignment', () => {
|
||||
it('assigns a UUID to variables that have no id', () => {
|
||||
const { transformDashboardVariables } = setupHook();
|
||||
const variable = makeVariable({ name: 'v1' });
|
||||
(variable as any).id = undefined;
|
||||
const dashboard = makeDashboard({ v1: variable });
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves existing IDs', () => {
|
||||
const { transformDashboardVariables } = setupHook();
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({ id: 'keep-me', name: 'v1' }),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.id).toBe('keep-me');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TEXTBOX backward compatibility', () => {
|
||||
it('copies textboxValue to defaultValue when defaultValue is missing', () => {
|
||||
const { transformDashboardVariables } = setupHook();
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({
|
||||
id: 'id1',
|
||||
name: 'v1',
|
||||
type: 'TEXTBOX',
|
||||
textboxValue: 'hello',
|
||||
defaultValue: undefined,
|
||||
order: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.defaultValue).toBe('hello');
|
||||
});
|
||||
|
||||
it('does not overwrite an existing defaultValue', () => {
|
||||
const { transformDashboardVariables } = setupHook();
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({
|
||||
id: 'id1',
|
||||
name: 'v1',
|
||||
type: 'TEXTBOX',
|
||||
textboxValue: 'old',
|
||||
defaultValue: 'keep',
|
||||
order: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.defaultValue).toBe('keep');
|
||||
});
|
||||
});
|
||||
|
||||
describe('localStorage merge', () => {
|
||||
it('applies localStorage selectedValue over DB value', () => {
|
||||
const { transformDashboardVariables } = setupHook({
|
||||
env: { selectedValue: 'staging', allSelected: false },
|
||||
});
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({ id: 'id1', name: 'env', selectedValue: 'prod' }),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toBe('staging');
|
||||
});
|
||||
|
||||
it('applies localStorage allSelected over DB value', () => {
|
||||
const { transformDashboardVariables } = setupHook({
|
||||
env: { selectedValue: undefined, allSelected: true },
|
||||
});
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({
|
||||
id: 'id1',
|
||||
name: 'env',
|
||||
allSelected: false,
|
||||
showALLOption: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.allSelected).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL variable override', () => {
|
||||
it('sets allSelected=true when URL value is __ALL__', () => {
|
||||
const { transformDashboardVariables } = setupHook(
|
||||
{ env: { selectedValue: 'prod', allSelected: false } },
|
||||
{ env: '__ALL__' },
|
||||
);
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({
|
||||
id: 'id1',
|
||||
name: 'env',
|
||||
showALLOption: true,
|
||||
allSelected: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.allSelected).toBe(true);
|
||||
});
|
||||
|
||||
it('sets selectedValue from URL and clears allSelected when showALLOption is true', () => {
|
||||
const { transformDashboardVariables } = setupHook(
|
||||
{ env: { selectedValue: undefined, allSelected: true } },
|
||||
{ env: 'dev' },
|
||||
);
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({
|
||||
id: 'id1',
|
||||
name: 'env',
|
||||
showALLOption: true,
|
||||
allSelected: true,
|
||||
multiSelect: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toBe('dev');
|
||||
expect(result.data.variables.v1.allSelected).toBe(false);
|
||||
});
|
||||
|
||||
it('does not set allSelected=false when showALLOption is false', () => {
|
||||
const { transformDashboardVariables } = setupHook(
|
||||
{ env: { selectedValue: undefined, allSelected: true } },
|
||||
{ env: 'dev' },
|
||||
);
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({
|
||||
id: 'id1',
|
||||
name: 'env',
|
||||
showALLOption: false,
|
||||
allSelected: true,
|
||||
multiSelect: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toBe('dev');
|
||||
expect(result.data.variables.v1.allSelected).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes array URL value to single value for single-select variable', () => {
|
||||
const { transformDashboardVariables } = setupHook(
|
||||
{},
|
||||
{ env: ['prod', 'dev'] },
|
||||
);
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({
|
||||
id: 'id1',
|
||||
name: 'env',
|
||||
multiSelect: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toBe('prod');
|
||||
});
|
||||
|
||||
it('wraps single URL value in array for multi-select variable', () => {
|
||||
const { transformDashboardVariables } = setupHook({}, { env: 'prod' });
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({
|
||||
id: 'id1',
|
||||
name: 'env',
|
||||
multiSelect: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toEqual(['prod']);
|
||||
});
|
||||
|
||||
it('looks up URL variable by variable id when name is absent', () => {
|
||||
const { transformDashboardVariables } = setupHook(
|
||||
{},
|
||||
{ 'var-uuid': 'fallback' },
|
||||
);
|
||||
const variable = makeVariable({ id: 'var-uuid', multiSelect: false });
|
||||
delete variable.name;
|
||||
const dashboard = makeDashboard({ v1: variable });
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toBe('fallback');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('returns data unchanged when there are no variables', () => {
|
||||
const { transformDashboardVariables } = setupHook();
|
||||
const dashboard = makeDashboard({});
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables).toEqual({});
|
||||
});
|
||||
|
||||
it('does not mutate the original dashboard', () => {
|
||||
const { transformDashboardVariables } = setupHook({
|
||||
env: { selectedValue: 'staging', allSelected: false },
|
||||
});
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({ id: 'id1', name: 'env', selectedValue: 'prod' }),
|
||||
});
|
||||
const originalValue = dashboard.data.variables.v1.selectedValue;
|
||||
|
||||
transformDashboardVariables(dashboard);
|
||||
|
||||
expect(dashboard.data.variables.v1.selectedValue).toBe(originalValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ interface DashboardLocalStorageVariables {
|
||||
[id: string]: LocalStoreDashboardVariables;
|
||||
}
|
||||
|
||||
interface UseDashboardVariablesFromLocalStorageReturn {
|
||||
export interface UseDashboardVariablesFromLocalStorageReturn {
|
||||
currentDashboard: LocalStoreDashboardVariables;
|
||||
updateLocalStorageDashboardVariables: (
|
||||
id: string,
|
||||
|
||||
128
frontend/src/hooks/dashboard/useTransformDashboardVariables.ts
Normal file
128
frontend/src/hooks/dashboard/useTransformDashboardVariables.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { ALL_SELECTED_VALUE } from 'components/NewSelect/utils';
|
||||
import {
|
||||
useDashboardVariablesFromLocalStorage,
|
||||
UseDashboardVariablesFromLocalStorageReturn,
|
||||
} from 'hooks/dashboard/useDashboardFromLocalStorage';
|
||||
import useVariablesFromUrl, {
|
||||
UseVariablesFromUrlReturn,
|
||||
} from 'hooks/dashboard/useVariablesFromUrl';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { normalizeUrlValueForVariable } from 'providers/Dashboard/normalizeUrlValue';
|
||||
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
import { v4 as generateUUID } from 'uuid';
|
||||
|
||||
export function useTransformDashboardVariables(
|
||||
dashboardId: string,
|
||||
): Pick<UseVariablesFromUrlReturn, 'getUrlVariables' | 'updateUrlVariable'> &
|
||||
UseDashboardVariablesFromLocalStorageReturn & {
|
||||
transformDashboardVariables: (data: Dashboard) => Dashboard;
|
||||
} {
|
||||
const {
|
||||
currentDashboard,
|
||||
updateLocalStorageDashboardVariables,
|
||||
} = useDashboardVariablesFromLocalStorage(dashboardId);
|
||||
const { getUrlVariables, updateUrlVariable } = useVariablesFromUrl();
|
||||
|
||||
const mergeDBWithLocalStorage = (
|
||||
data: Dashboard,
|
||||
localStorageVariables: any,
|
||||
): Dashboard => {
|
||||
const updatedData = data;
|
||||
if (data && localStorageVariables) {
|
||||
const updatedVariables = data.data.variables;
|
||||
const variablesFromUrl = getUrlVariables();
|
||||
Object.keys(data.data.variables).forEach((variable) => {
|
||||
const variableData = data.data.variables[variable];
|
||||
|
||||
// values from url
|
||||
const urlVariable = variableData?.name
|
||||
? variablesFromUrl[variableData?.name] || variablesFromUrl[variableData.id]
|
||||
: variablesFromUrl[variableData.id];
|
||||
|
||||
let updatedVariable = {
|
||||
...data.data.variables[variable],
|
||||
...localStorageVariables[variableData.name as any],
|
||||
};
|
||||
|
||||
// respect the url variable if it is set, override the others
|
||||
if (!isEmpty(urlVariable)) {
|
||||
if (urlVariable === ALL_SELECTED_VALUE) {
|
||||
updatedVariable = {
|
||||
...updatedVariable,
|
||||
allSelected: true,
|
||||
};
|
||||
} else {
|
||||
// Normalize URL value to match variable's multiSelect configuration
|
||||
const normalizedValue = normalizeUrlValueForVariable(
|
||||
urlVariable,
|
||||
variableData,
|
||||
);
|
||||
|
||||
updatedVariable = {
|
||||
...updatedVariable,
|
||||
selectedValue: normalizedValue,
|
||||
// Only set allSelected to false if showALLOption is available
|
||||
...(updatedVariable?.showALLOption && { allSelected: false }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
updatedVariables[variable] = updatedVariable;
|
||||
});
|
||||
updatedData.data.variables = updatedVariables;
|
||||
}
|
||||
return updatedData;
|
||||
};
|
||||
|
||||
// As we do not have order and ID's in the variables object, we have to process variables to add order and ID if they do not exist in the variables object
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
const transformDashboardVariables = (data: Dashboard): Dashboard => {
|
||||
if (data && data.data && data.data.variables) {
|
||||
const clonedDashboardData = mergeDBWithLocalStorage(
|
||||
JSON.parse(JSON.stringify(data)),
|
||||
currentDashboard,
|
||||
);
|
||||
const { variables } = clonedDashboardData.data;
|
||||
const existingOrders: Set<number> = new Set();
|
||||
|
||||
for (const key in variables) {
|
||||
// eslint-disable-next-line no-prototype-builtins
|
||||
if (variables.hasOwnProperty(key)) {
|
||||
const variable: IDashboardVariable = variables[key];
|
||||
|
||||
// Check if 'order' property doesn't exist or is undefined
|
||||
if (variable.order === undefined) {
|
||||
// Find a unique order starting from 0
|
||||
let order = 0;
|
||||
while (existingOrders.has(order)) {
|
||||
order += 1;
|
||||
}
|
||||
|
||||
variable.order = order;
|
||||
existingOrders.add(order);
|
||||
// ! BWC - Specific case for backward compatibility where textboxValue was used instead of defaultValue
|
||||
if (variable.type === 'TEXTBOX' && !variable.defaultValue) {
|
||||
variable.defaultValue = variable.textboxValue || '';
|
||||
}
|
||||
}
|
||||
|
||||
if (variable.id === undefined) {
|
||||
variable.id = generateUUID();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return clonedDashboardData;
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
return {
|
||||
transformDashboardVariables,
|
||||
getUrlVariables,
|
||||
updateUrlVariable,
|
||||
currentDashboard,
|
||||
updateLocalStorageDashboardVariables,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useMutation, UseMutationResult } from 'react-query';
|
||||
import update from 'api/v1/dashboards/id/update';
|
||||
import dayjs from 'dayjs';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
@@ -9,12 +8,12 @@ import { Props } from 'types/api/dashboard/update';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
export const useUpdateDashboard = (): UseUpdateDashboard => {
|
||||
const { updatedTimeRef } = useDashboard();
|
||||
const { setSelectedDashboard } = useDashboard();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
return useMutation(update, {
|
||||
onSuccess: (data) => {
|
||||
if (data.data) {
|
||||
updatedTimeRef.current = dayjs(data.data.updatedAt);
|
||||
setSelectedDashboard(data.data);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface LocalStoreDashboardVariables {
|
||||
| IDashboardVariable['selectedValue'];
|
||||
}
|
||||
|
||||
interface UseVariablesFromUrlReturn {
|
||||
export interface UseVariablesFromUrlReturn {
|
||||
getUrlVariables: () => LocalStoreDashboardVariables;
|
||||
setUrlVariables: (variables: LocalStoreDashboardVariables) => void;
|
||||
updateUrlVariable: (
|
||||
|
||||
@@ -107,7 +107,6 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
|
||||
queryRangeMutation,
|
||||
dashboardVariables,
|
||||
dashboardDynamicVariables,
|
||||
selectedDashboard?.data.version,
|
||||
widget,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -23,10 +23,10 @@ export default {
|
||||
relations: {
|
||||
assignee: ['role'],
|
||||
create: ['metaresources'],
|
||||
delete: ['user', 'role', 'organization', 'metaresource'],
|
||||
delete: ['user', 'serviceaccount', 'role', 'organization', 'metaresource'],
|
||||
list: ['metaresources'],
|
||||
read: ['user', 'role', 'organization', 'metaresource'],
|
||||
update: ['user', 'role', 'organization', 'metaresource'],
|
||||
read: ['user', 'serviceaccount', 'role', 'organization', 'metaresource'],
|
||||
update: ['user', 'serviceaccount', 'role', 'organization', 'metaresource'],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
|
||||
|
||||
import DashboardPage from './DashboardPage';
|
||||
|
||||
export default DashboardPage;
|
||||
function DashboardPageWithProvider(): JSX.Element {
|
||||
const { dashboardId } = useParams<{ dashboardId: string }>();
|
||||
|
||||
return (
|
||||
<DashboardProvider dashboardId={dashboardId}>
|
||||
<DashboardPage />
|
||||
</DashboardProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardPageWithProvider;
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Route } from 'react-router-dom';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import DashboardWidget from '../index';
|
||||
|
||||
const DASHBOARD_ID = 'dash-1';
|
||||
const WIDGET_ID = 'widget-abc';
|
||||
|
||||
const mockDashboardResponse = {
|
||||
status: 'success',
|
||||
data: {
|
||||
id: DASHBOARD_ID,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
createdBy: 'test',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
updatedBy: 'test',
|
||||
isLocked: false,
|
||||
data: {
|
||||
collapsableRowsMigrated: true,
|
||||
description: '',
|
||||
name: '',
|
||||
panelMap: {},
|
||||
tags: [],
|
||||
title: 'Test Dashboard',
|
||||
uploadedGrafana: false,
|
||||
uuid: '',
|
||||
version: '',
|
||||
variables: {},
|
||||
widgets: [],
|
||||
layout: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('container/NewWidget', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="new-widget">NewWidget</div>,
|
||||
}));
|
||||
|
||||
// nuqs's useQueryState doesn't read from MemoryRouter, so we mock it to return
|
||||
// controlled values via the `mockQueryState` map below.
|
||||
const mockQueryState: Record<string, string | null> = {};
|
||||
|
||||
jest.mock('nuqs', () => ({
|
||||
...jest.requireActual('nuqs'),
|
||||
useQueryState: (key: string): [string | null, jest.Mock] => [
|
||||
mockQueryState[key] ?? null,
|
||||
jest.fn(),
|
||||
],
|
||||
}));
|
||||
|
||||
// Wrap component in a Route so useParams can resolve dashboardId
|
||||
function renderAtRoute(
|
||||
queryState: Record<string, string | null> = {},
|
||||
): ReturnType<typeof render> {
|
||||
Object.assign(mockQueryState, queryState);
|
||||
return render(
|
||||
<Route path="/dashboard/:dashboardId/new">
|
||||
<DashboardWidget />
|
||||
</Route>,
|
||||
undefined,
|
||||
{ initialRoute: `/dashboard/${DASHBOARD_ID}/new` },
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockSafeNavigate.mockClear();
|
||||
Object.keys(mockQueryState).forEach((k) => delete mockQueryState[k]);
|
||||
});
|
||||
|
||||
describe('DashboardWidget', () => {
|
||||
it('redirects to dashboard when widgetId is missing', async () => {
|
||||
renderAtRoute({ graphType: PANEL_TYPES.TIME_SERIES });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSafeNavigate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const [navigatedTo] = mockSafeNavigate.mock.calls[0];
|
||||
expect(navigatedTo).toContain(`/dashboard/${DASHBOARD_ID}`);
|
||||
});
|
||||
|
||||
it('redirects to dashboard when graphType is missing', async () => {
|
||||
renderAtRoute({ widgetId: WIDGET_ID });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSafeNavigate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const [navigatedTo] = mockSafeNavigate.mock.calls[0];
|
||||
expect(navigatedTo).toContain(`/dashboard/${DASHBOARD_ID}`);
|
||||
});
|
||||
|
||||
it('shows spinner while dashboard is loading', () => {
|
||||
server.use(
|
||||
rest.get(
|
||||
`http://localhost/api/v1/dashboards/${DASHBOARD_ID}`,
|
||||
(_req, res, ctx) => res(ctx.delay('infinite')),
|
||||
),
|
||||
);
|
||||
|
||||
renderAtRoute({ widgetId: WIDGET_ID, graphType: PANEL_TYPES.TIME_SERIES });
|
||||
|
||||
expect(screen.getByRole('img', { name: 'loading' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error message when dashboard fetch fails', async () => {
|
||||
server.use(
|
||||
rest.get(
|
||||
`http://localhost/api/v1/dashboards/${DASHBOARD_ID}`,
|
||||
(_req, res, ctx) => res(ctx.status(500), ctx.json({ status: 'error' })),
|
||||
),
|
||||
);
|
||||
|
||||
renderAtRoute({ widgetId: WIDGET_ID, graphType: PANEL_TYPES.TIME_SERIES });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders NewWidget when dashboard loads successfully', async () => {
|
||||
server.use(
|
||||
rest.get(
|
||||
`http://localhost/api/v1/dashboards/${DASHBOARD_ID}`,
|
||||
(_req, res, ctx) => res(ctx.status(200), ctx.json(mockDashboardResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
renderAtRoute({ widgetId: WIDGET_ID, graphType: PANEL_TYPES.TIME_SERIES });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('new-widget')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,50 +1,98 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { generatePath, useLocation, useParams } from 'react-router-dom';
|
||||
import { useQuery } from 'react-query';
|
||||
import { generatePath, useParams } from 'react-router-dom';
|
||||
import { Card, Typography } from 'antd';
|
||||
import getDashboard from 'api/v1/dashboards/id/get';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { DASHBOARD_CACHE_TIME } from 'constants/queryCacheTime';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ROUTES from 'constants/routes';
|
||||
import NewWidget from 'container/NewWidget';
|
||||
import { isDrilldownEnabled } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import { useTransformDashboardVariables } from 'hooks/dashboard/useTransformDashboardVariables';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { parseAsStringEnum, useQueryState } from 'nuqs';
|
||||
import { setDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
|
||||
function DashboardWidget(): JSX.Element | null {
|
||||
const { search } = useLocation();
|
||||
const { dashboardId } = useParams<DashboardWidgetPageParams>();
|
||||
const { dashboardId } = useParams<{
|
||||
dashboardId: string;
|
||||
}>();
|
||||
const [widgetId] = useQueryState('widgetId');
|
||||
const [graphType] = useQueryState(
|
||||
'graphType',
|
||||
parseAsStringEnum<PANEL_TYPES>(Object.values(PANEL_TYPES)),
|
||||
);
|
||||
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const [selectedGraph, setSelectedGraph] = useState<PANEL_TYPES>();
|
||||
|
||||
const { selectedDashboard, dashboardResponse } = useDashboard();
|
||||
|
||||
const params = useUrlQuery();
|
||||
|
||||
const widgetId = params.get('widgetId');
|
||||
const { data } = selectedDashboard || {};
|
||||
const { widgets } = data || {};
|
||||
|
||||
const selectedWidget = widgets?.find((e) => e.id === widgetId) as Widgets;
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(search);
|
||||
const graphType = params.get('graphType') as PANEL_TYPES | null;
|
||||
|
||||
if (graphType === null) {
|
||||
if (!graphType || !widgetId) {
|
||||
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId }));
|
||||
} else {
|
||||
setSelectedGraph(graphType);
|
||||
} else if (!dashboardId) {
|
||||
safeNavigate(ROUTES.HOME);
|
||||
}
|
||||
}, [dashboardId, safeNavigate, search]);
|
||||
}, [graphType, widgetId, dashboardId, safeNavigate]);
|
||||
|
||||
if (selectedGraph === undefined || dashboardResponse.isLoading) {
|
||||
if (!widgetId || !graphType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardWidgetInternal
|
||||
dashboardId={dashboardId}
|
||||
widgetId={widgetId}
|
||||
graphType={graphType}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardWidgetInternal({
|
||||
dashboardId,
|
||||
widgetId,
|
||||
graphType,
|
||||
}: {
|
||||
dashboardId: string;
|
||||
widgetId: string;
|
||||
graphType: PANEL_TYPES;
|
||||
}): JSX.Element | null {
|
||||
const [selectedDashboard, setSelectedDashboard] = useState<
|
||||
Dashboard | undefined
|
||||
>(undefined);
|
||||
|
||||
const { transformDashboardVariables } = useTransformDashboardVariables(
|
||||
dashboardId,
|
||||
);
|
||||
|
||||
const {
|
||||
isFetching: isFetchingDashboardResponse,
|
||||
isError: isErrorDashboardResponse,
|
||||
} = useQuery([REACT_QUERY_KEY.DASHBOARD_BY_ID, dashboardId, widgetId], {
|
||||
enabled: true,
|
||||
queryFn: async () =>
|
||||
await getDashboard({
|
||||
id: dashboardId,
|
||||
}),
|
||||
refetchOnWindowFocus: false,
|
||||
cacheTime: DASHBOARD_CACHE_TIME,
|
||||
onSuccess: (response) => {
|
||||
const updatedDashboardData = transformDashboardVariables(response.data);
|
||||
setSelectedDashboard(updatedDashboardData);
|
||||
setDashboardVariablesStore({
|
||||
dashboardId,
|
||||
variables: updatedDashboardData.data.variables,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (isFetchingDashboardResponse) {
|
||||
return <Spinner tip="Loading.." />;
|
||||
}
|
||||
|
||||
if (dashboardResponse.isError) {
|
||||
if (isErrorDashboardResponse) {
|
||||
return (
|
||||
<Card>
|
||||
<Typography>{SOMETHING_WENT_WRONG}</Typography>
|
||||
@@ -54,16 +102,11 @@ function DashboardWidget(): JSX.Element | null {
|
||||
|
||||
return (
|
||||
<NewWidget
|
||||
yAxisUnit={selectedWidget?.yAxisUnit}
|
||||
selectedGraph={selectedGraph}
|
||||
fillSpans={selectedWidget?.fillSpans}
|
||||
dashboardId={dashboardId}
|
||||
selectedGraph={graphType}
|
||||
enableDrillDown={isDrilldownEnabled()}
|
||||
selectedDashboard={selectedDashboard}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DashboardWidgetPageParams {
|
||||
dashboardId: string;
|
||||
}
|
||||
|
||||
export default DashboardWidget;
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from 'mocks-server/__mockdata__/dashboards';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
|
||||
import { fireEvent, render, waitFor } from 'tests/test-utils';
|
||||
|
||||
jest.mock('container/DashboardContainer/DashboardDescription/utils', () => ({
|
||||
@@ -19,11 +18,6 @@ jest.mock('container/DashboardContainer/DashboardDescription/utils', () => ({
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
useRouteMatch: jest.fn().mockReturnValue({
|
||||
params: {
|
||||
dashboardId: 4,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockWindowOpen = jest.fn();
|
||||
@@ -47,9 +41,7 @@ describe('dashboard list page', () => {
|
||||
<MemoryRouter
|
||||
initialEntries={['/dashbords?columnKey=asgard&order=stones&page=1']}
|
||||
>
|
||||
<DashboardProvider>
|
||||
<DashboardsList />
|
||||
</DashboardProvider>
|
||||
<DashboardsList />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
@@ -71,9 +63,7 @@ describe('dashboard list page', () => {
|
||||
<MemoryRouter
|
||||
initialEntries={['/dashbords?columnKey=createdAt&order=descend&page=1']}
|
||||
>
|
||||
<DashboardProvider>
|
||||
<DashboardsList />
|
||||
</DashboardProvider>
|
||||
<DashboardsList />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
@@ -92,9 +82,7 @@ describe('dashboard list page', () => {
|
||||
'/dashbords?columnKey=createdAt&order=descend&page=1&search=tho',
|
||||
]}
|
||||
>
|
||||
<DashboardProvider>
|
||||
<DashboardsList />
|
||||
</DashboardProvider>
|
||||
<DashboardsList />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
@@ -135,9 +123,7 @@ describe('dashboard list page', () => {
|
||||
'/dashbords?columnKey=createdAt&order=descend&page=1&search=tho',
|
||||
]}
|
||||
>
|
||||
<DashboardProvider>
|
||||
<DashboardsList />
|
||||
</DashboardProvider>
|
||||
<DashboardsList />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
@@ -164,9 +150,7 @@ describe('dashboard list page', () => {
|
||||
'/dashbords?columnKey=createdAt&order=descend&page=1&search=tho',
|
||||
]}
|
||||
>
|
||||
<DashboardProvider>
|
||||
<DashboardsList />
|
||||
</DashboardProvider>
|
||||
<DashboardsList />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
@@ -196,9 +180,7 @@ describe('dashboard list page', () => {
|
||||
'/dashbords?columnKey=createdAt&order=descend&page=1&search=tho',
|
||||
]}
|
||||
>
|
||||
<DashboardProvider>
|
||||
<DashboardsList />
|
||||
</DashboardProvider>
|
||||
<DashboardsList />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ function SettingsPage(): JSX.Element {
|
||||
isAdmin &&
|
||||
(item.key === ROUTES.BILLING ||
|
||||
item.key === ROUTES.ORG_SETTINGS ||
|
||||
item.key === ROUTES.MEMBERS_SETTINGS ||
|
||||
item.key === ROUTES.MY_SETTINGS ||
|
||||
item.key === ROUTES.SHORTCUTS)
|
||||
),
|
||||
|
||||
@@ -36,6 +36,7 @@ export const getRoutes = (
|
||||
if (isWorkspaceBlocked && isAdmin) {
|
||||
settings.push(
|
||||
...organizationSettings(t),
|
||||
...membersSettings(t),
|
||||
...mySettings(t),
|
||||
...billingSettings(t),
|
||||
...keyboardShortcuts(t),
|
||||
|
||||
@@ -4,11 +4,19 @@ import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { Compass, Cone, TowerControl } from 'lucide-react';
|
||||
|
||||
import TraceDetailsV3 from '../TraceDetailsV3';
|
||||
import TraceDetailsV2 from './TraceDetailV2';
|
||||
|
||||
import './TraceDetailV2.styles.scss';
|
||||
|
||||
function NewTraceDetail(props: any): JSX.Element {
|
||||
interface INewTraceDetailProps {
|
||||
items: {
|
||||
label: JSX.Element;
|
||||
key: string;
|
||||
children: JSX.Element;
|
||||
}[];
|
||||
}
|
||||
|
||||
function NewTraceDetail(props: INewTraceDetailProps): JSX.Element {
|
||||
const { items } = props;
|
||||
return (
|
||||
<div className="traces-module-container">
|
||||
@@ -42,7 +50,7 @@ export default function TraceDetailsPage(): JSX.Element {
|
||||
</div>
|
||||
),
|
||||
key: 'trace-details',
|
||||
children: <TraceDetailsV3 />,
|
||||
children: <TraceDetailsV2 />,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
.span-hover-card-popover {
|
||||
.ant-popover-inner {
|
||||
background-color: rgba(30, 30, 30, 0.95);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.ant-popover-inner-content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.span-hover-card-content {
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
|
||||
&__name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&__row {
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { ReactNode } 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 { Span } from 'types/api/trace/getTraceV2';
|
||||
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: Span;
|
||||
traceMetadata: ITraceMetadata;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function SpanHoverCard({
|
||||
span,
|
||||
traceMetadata,
|
||||
children,
|
||||
}: SpanHoverCardProps): JSX.Element {
|
||||
const durationMs = span.durationNano / 1e6;
|
||||
const relativeStartMs = span.timestamp - traceMetadata.startTime;
|
||||
|
||||
let color = generateColor(span.serviceName, themeColors.traceDetailColorsV3);
|
||||
if (span.hasError) {
|
||||
color = 'var(--bg-cherry-500)';
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
mouseEnterDelay={0.2}
|
||||
content={
|
||||
<SpanTooltipContent
|
||||
spanName={span.name}
|
||||
color={color}
|
||||
hasError={span.hasError}
|
||||
relativeStartMs={relativeStartMs}
|
||||
durationMs={durationMs}
|
||||
/>
|
||||
}
|
||||
trigger="hover"
|
||||
rootClassName="span-hover-card-popover"
|
||||
autoAdjustOverflow
|
||||
arrow={false}
|
||||
>
|
||||
{children}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanHoverCard;
|
||||
@@ -1,91 +0,0 @@
|
||||
.trace-details-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0px 16px;
|
||||
|
||||
.previous-btn {
|
||||
display: flex;
|
||||
height: 30px;
|
||||
padding: 6px 8px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
background: var(--bg-slate-500);
|
||||
border-radius: 4px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.trace-name {
|
||||
display: flex;
|
||||
padding: 6px 8px;
|
||||
margin-left: 6px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
border-radius: 4px 0px 0px 4px;
|
||||
background: var(--bg-slate-500);
|
||||
|
||||
.drafting {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.trace-id {
|
||||
color: #fff;
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.trace-id-value {
|
||||
display: flex;
|
||||
padding: 6px 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--bg-slate-400);
|
||||
color: var(--Vanilla-400, #c0c1c3);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
border-left: unset;
|
||||
border-radius: 0px 4px 4px 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.trace-details-header {
|
||||
.previous-btn {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-200);
|
||||
}
|
||||
|
||||
.trace-name {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-200);
|
||||
border-right: none;
|
||||
|
||||
.drafting {
|
||||
color: var(--bg-ink-100);
|
||||
}
|
||||
|
||||
.trace-id {
|
||||
color: var(--bg-ink-100);
|
||||
}
|
||||
}
|
||||
|
||||
.trace-id-value {
|
||||
background: var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: move to new css module name system
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button, Typography } from 'antd';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import './TraceDetailsHeader.styles.scss';
|
||||
|
||||
function TraceDetailsHeader(): JSX.Element {
|
||||
const { id: traceID } = useParams<TraceDetailV2URLProps>();
|
||||
|
||||
const handlePreviousBtnClick = useCallback((): void => {
|
||||
const isSpaNavigate =
|
||||
document.referrer &&
|
||||
new URL(document.referrer).origin === window.location.origin;
|
||||
if (isSpaNavigate) {
|
||||
history.goBack();
|
||||
} else {
|
||||
history.push(ROUTES.TRACES_EXPLORER);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="trace-details-header">
|
||||
<Button className="previous-btn" onClick={handlePreviousBtnClick}>
|
||||
<ArrowLeft size={14} />
|
||||
</Button>
|
||||
<div className="trace-name">
|
||||
<Typography.Text className="trace-id">Trace ID</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text className="trace-id-value">{traceID}</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDetailsHeader;
|
||||
@@ -1,234 +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 { SpanTooltipContent } from '../SpanHoverCard/SpanHoverCard';
|
||||
import { DEFAULT_ROW_HEIGHT } from './constants';
|
||||
import { useCanvasSetup } from './hooks/useCanvasSetup';
|
||||
import { useFlamegraphDrag } from './hooks/useFlamegraphDrag';
|
||||
import { useFlamegraphDraw } from './hooks/useFlamegraphDraw';
|
||||
import { useFlamegraphHover } from './hooks/useFlamegraphHover';
|
||||
import { useFlamegraphZoom } from './hooks/useFlamegraphZoom';
|
||||
import { useScrollToSpan } from './hooks/useScrollToSpan';
|
||||
import { useVisualLayoutWorker } from './hooks/useVisualLayoutWorker';
|
||||
import { FlamegraphCanvasProps, SpanRect } from './types';
|
||||
|
||||
function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
|
||||
const { spans, traceMetadata, firstSpanAtFetchLevel, onSpanClick } = props;
|
||||
|
||||
const isDarkMode = useIsDarkMode(); //TODO: see if can be removed or use a new hook
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const spanRectsRef = useRef<SpanRect[]>([]);
|
||||
|
||||
const [viewStartTs, setViewStartTs] = useState<number>(
|
||||
traceMetadata.startTime,
|
||||
);
|
||||
const [viewEndTs, setViewEndTs] = useState<number>(traceMetadata.endTime);
|
||||
const [scrollTop, setScrollTop] = useState<number>(0);
|
||||
const [rowHeight, setRowHeight] = useState<number>(DEFAULT_ROW_HEIGHT);
|
||||
|
||||
// Mutable refs for zoom and drag hooks to read during rAF / mouse callbacks
|
||||
const viewStartRef = useRef(viewStartTs);
|
||||
const viewEndRef = useRef(viewEndTs);
|
||||
const rowHeightRef = useRef(rowHeight);
|
||||
const scrollTopRef = useRef(scrollTop);
|
||||
|
||||
useEffect(() => {
|
||||
viewStartRef.current = viewStartTs;
|
||||
}, [viewStartTs]);
|
||||
|
||||
useEffect(() => {
|
||||
viewEndRef.current = viewEndTs;
|
||||
}, [viewEndTs]);
|
||||
|
||||
useEffect(() => {
|
||||
rowHeightRef.current = rowHeight;
|
||||
}, [rowHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollTopRef.current = scrollTop;
|
||||
}, [scrollTop]);
|
||||
|
||||
useEffect(() => {
|
||||
//TODO: see if this can be removed as once loaded the view start and end ts will not change
|
||||
setViewStartTs(traceMetadata.startTime);
|
||||
setViewEndTs(traceMetadata.endTime);
|
||||
viewStartRef.current = traceMetadata.startTime;
|
||||
viewEndRef.current = traceMetadata.endTime;
|
||||
}, [traceMetadata.startTime, traceMetadata.endTime]);
|
||||
|
||||
const { layout, isComputing: _isComputing } = useVisualLayoutWorker(spans);
|
||||
|
||||
const totalHeight = layout.totalVisualRows * rowHeight;
|
||||
|
||||
const { isOverFlamegraphRef } = useFlamegraphZoom({
|
||||
canvasRef,
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
rowHeightRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setRowHeight,
|
||||
});
|
||||
|
||||
const {
|
||||
handleMouseDown,
|
||||
handleMouseMove: handleDragMouseMove,
|
||||
handleMouseUp,
|
||||
handleDragMouseLeave,
|
||||
suppressClickRef,
|
||||
isDraggingRef,
|
||||
} = useFlamegraphDrag({
|
||||
canvasRef,
|
||||
containerRef,
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
scrollTopRef,
|
||||
setScrollTop,
|
||||
totalHeight,
|
||||
});
|
||||
|
||||
const {
|
||||
hoveredSpanId,
|
||||
handleHoverMouseMove,
|
||||
handleHoverMouseLeave,
|
||||
handleClick,
|
||||
tooltipContent,
|
||||
} = useFlamegraphHover({
|
||||
canvasRef,
|
||||
spanRectsRef,
|
||||
traceMetadata,
|
||||
viewStartTs,
|
||||
viewEndTs,
|
||||
isDraggingRef,
|
||||
suppressClickRef,
|
||||
onSpanClick,
|
||||
isDarkMode,
|
||||
});
|
||||
|
||||
const { drawFlamegraph } = useFlamegraphDraw({
|
||||
canvasRef,
|
||||
containerRef,
|
||||
spans: layout.visualRows,
|
||||
connectors: layout.connectors,
|
||||
viewStartTs,
|
||||
viewEndTs,
|
||||
scrollTop,
|
||||
rowHeight,
|
||||
selectedSpanId: firstSpanAtFetchLevel || undefined,
|
||||
hoveredSpanId: hoveredSpanId ?? '',
|
||||
isDarkMode,
|
||||
spanRectsRef,
|
||||
});
|
||||
|
||||
useScrollToSpan({
|
||||
firstSpanAtFetchLevel,
|
||||
spans: layout.visualRows,
|
||||
traceMetadata,
|
||||
containerRef,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
scrollTopRef,
|
||||
rowHeight,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setScrollTop,
|
||||
});
|
||||
|
||||
useCanvasSetup(canvasRef, containerRef, drawFlamegraph);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent): void => {
|
||||
handleDragMouseMove(e);
|
||||
handleHoverMouseMove(e);
|
||||
},
|
||||
[handleDragMouseMove, handleHoverMouseMove],
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback((): void => {
|
||||
isOverFlamegraphRef.current = false;
|
||||
handleDragMouseLeave();
|
||||
handleHoverMouseLeave();
|
||||
}, [isOverFlamegraphRef, handleDragMouseLeave, handleHoverMouseLeave]);
|
||||
|
||||
const tooltipElement = tooltipContent
|
||||
? createPortal(
|
||||
<div
|
||||
className="span-hover-card-popover"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: Math.min(tooltipContent.clientX + 15, window.innerWidth - 220),
|
||||
top: Math.min(tooltipContent.clientY + 15, window.innerHeight - 100),
|
||||
zIndex: 1000,
|
||||
backgroundColor: 'rgba(30, 30, 30, 0.95)',
|
||||
padding: '8px 12px',
|
||||
borderRadius: 4,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{/* TODO: passing each content is too much, we should use the tooltipContent object directly */}
|
||||
<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}
|
||||
/>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={(): void => {
|
||||
isOverFlamegraphRef.current = true;
|
||||
}}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
cursor: 'grab',
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FlamegraphCanvas;
|
||||
@@ -1,107 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useHistory, useLocation, useParams } from 'react-router-dom';
|
||||
import useGetTraceFlamegraph from 'hooks/trace/useGetTraceFlamegraph';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { TraceDetailFlamegraphURLProps } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
import FlamegraphCanvas from './FlamegraphCanvas';
|
||||
|
||||
//TODO: analyse if this is needed or not and move to separate file if needed else delete this enum.
|
||||
enum TraceFlamegraphState {
|
||||
LOADING = 'LOADING',
|
||||
SUCCESS = 'SUCCESS',
|
||||
NO_DATA = 'NO_DATA',
|
||||
ERROR = 'ERROR',
|
||||
FETCHING_WITH_OLD_DATA = 'FETCHING_WITH_OLD_DATA',
|
||||
}
|
||||
|
||||
function TraceFlamegraph(): JSX.Element {
|
||||
const { id: traceId } = useParams<TraceDetailFlamegraphURLProps>();
|
||||
const urlQuery = useUrlQuery();
|
||||
const history = useHistory();
|
||||
const { search } = useLocation();
|
||||
const [firstSpanAtFetchLevel, setFirstSpanAtFetchLevel] = useState<string>(
|
||||
urlQuery.get('spanId') || '',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFirstSpanAtFetchLevel(urlQuery.get('spanId') || '');
|
||||
}, [urlQuery]);
|
||||
|
||||
const handleSpanClick = useCallback(
|
||||
(spanId: string): void => {
|
||||
setFirstSpanAtFetchLevel(spanId);
|
||||
const searchParams = new URLSearchParams(search);
|
||||
//tood: use from query params constants
|
||||
if (searchParams.get('spanId') !== spanId) {
|
||||
searchParams.set('spanId', spanId);
|
||||
history.replace({ search: searchParams.toString() });
|
||||
}
|
||||
},
|
||||
[history, search],
|
||||
);
|
||||
|
||||
const { data, isFetching, error } = useGetTraceFlamegraph({
|
||||
traceId,
|
||||
// selectedSpanId: firstSpanAtFetchLevel,
|
||||
});
|
||||
|
||||
const flamegraphState = useMemo(() => {
|
||||
if (isFetching) {
|
||||
if (data?.payload?.spans && data.payload.spans.length > 0) {
|
||||
return TraceFlamegraphState.FETCHING_WITH_OLD_DATA;
|
||||
}
|
||||
return TraceFlamegraphState.LOADING;
|
||||
}
|
||||
if (error) {
|
||||
return TraceFlamegraphState.ERROR;
|
||||
}
|
||||
if (data?.payload?.spans && data.payload.spans.length === 0) {
|
||||
return TraceFlamegraphState.NO_DATA;
|
||||
}
|
||||
return TraceFlamegraphState.SUCCESS;
|
||||
}, [error, isFetching, data]);
|
||||
|
||||
const spans = useMemo(() => data?.payload?.spans || [], [
|
||||
data?.payload?.spans,
|
||||
]);
|
||||
|
||||
const content = useMemo(() => {
|
||||
switch (flamegraphState) {
|
||||
case TraceFlamegraphState.LOADING:
|
||||
return <div>Loading...</div>;
|
||||
case TraceFlamegraphState.ERROR:
|
||||
return <div>Error loading flamegraph</div>;
|
||||
case TraceFlamegraphState.NO_DATA:
|
||||
return <div>No data found for trace {traceId}</div>;
|
||||
case TraceFlamegraphState.SUCCESS:
|
||||
case TraceFlamegraphState.FETCHING_WITH_OLD_DATA:
|
||||
return (
|
||||
<FlamegraphCanvas
|
||||
spans={spans}
|
||||
firstSpanAtFetchLevel={firstSpanAtFetchLevel}
|
||||
setFirstSpanAtFetchLevel={setFirstSpanAtFetchLevel}
|
||||
onSpanClick={handleSpanClick}
|
||||
traceMetadata={{
|
||||
startTime: data?.payload?.startTimestampMillis || 0,
|
||||
endTime: data?.payload?.endTimestampMillis || 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <div>Fetching the trace...</div>;
|
||||
}
|
||||
}, [
|
||||
data?.payload?.endTimestampMillis,
|
||||
data?.payload?.startTimestampMillis,
|
||||
firstSpanAtFetchLevel,
|
||||
flamegraphState,
|
||||
spans,
|
||||
traceId,
|
||||
handleSpanClick,
|
||||
]);
|
||||
|
||||
return <>{content}</>;
|
||||
}
|
||||
|
||||
export default TraceFlamegraph;
|
||||
@@ -1,475 +0,0 @@
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
import { computeVisualLayout } from '../computeVisualLayout';
|
||||
|
||||
function makeSpan(
|
||||
overrides: Partial<FlamegraphSpan> & {
|
||||
spanId: string;
|
||||
timestamp: number;
|
||||
durationNano: number;
|
||||
},
|
||||
): FlamegraphSpan {
|
||||
return {
|
||||
parentSpanId: '',
|
||||
traceId: 'trace-1',
|
||||
hasError: false,
|
||||
serviceName: 'svc',
|
||||
name: 'op',
|
||||
level: 0,
|
||||
event: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('computeVisualLayout', () => {
|
||||
it('should handle empty input', () => {
|
||||
const layout = computeVisualLayout([]);
|
||||
expect(layout.totalVisualRows).toBe(0);
|
||||
expect(layout.visualRows).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle single root, no children — 1 visual row', () => {
|
||||
const root = makeSpan({
|
||||
spanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 100e6,
|
||||
});
|
||||
const layout = computeVisualLayout([[root]]);
|
||||
expect(layout.totalVisualRows).toBe(1);
|
||||
expect(layout.visualRows[0]).toEqual([root]);
|
||||
expect(layout.spanToVisualRow['root']).toBe(0);
|
||||
});
|
||||
|
||||
it('should keep non-overlapping siblings on the same row (compact)', () => {
|
||||
const root = makeSpan({
|
||||
spanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 500e6,
|
||||
});
|
||||
const a = makeSpan({
|
||||
spanId: 'a',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 100e6,
|
||||
});
|
||||
const b = makeSpan({
|
||||
spanId: 'b',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 200,
|
||||
durationNano: 100e6,
|
||||
});
|
||||
const c = makeSpan({
|
||||
spanId: 'c',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 400,
|
||||
durationNano: 100e6,
|
||||
});
|
||||
|
||||
const layout = computeVisualLayout([[root], [a, b, c]]);
|
||||
|
||||
// root on row 0, all children on row 1
|
||||
expect(layout.totalVisualRows).toBe(2);
|
||||
expect(layout.spanToVisualRow['root']).toBe(0);
|
||||
expect(layout.spanToVisualRow['a']).toBe(1);
|
||||
expect(layout.spanToVisualRow['b']).toBe(1);
|
||||
expect(layout.spanToVisualRow['c']).toBe(1);
|
||||
});
|
||||
|
||||
it('should pack non-overlapping siblings into shared lanes (greedy packing)', () => {
|
||||
const root = makeSpan({
|
||||
spanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 300e6,
|
||||
});
|
||||
// A and B overlap; C does not overlap with either
|
||||
const a = makeSpan({
|
||||
spanId: 'a',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 100e6, // ends at 100ms
|
||||
});
|
||||
const b = makeSpan({
|
||||
spanId: 'b',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 50,
|
||||
durationNano: 100e6, // starts at 50ms < 100ms end of A → overlap → lane 1
|
||||
});
|
||||
const c = makeSpan({
|
||||
spanId: 'c',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 200,
|
||||
durationNano: 100e6, // 200 >= 100, fits lane 0 with A
|
||||
});
|
||||
|
||||
const layout = computeVisualLayout([[root], [a, b, c]]);
|
||||
|
||||
// root on row 0, C placed first (latest) → row 1, B doesn't overlap C → row 1, A overlaps B → row 2
|
||||
expect(layout.totalVisualRows).toBe(3);
|
||||
expect(layout.spanToVisualRow['root']).toBe(0);
|
||||
expect(layout.spanToVisualRow['c']).toBe(1);
|
||||
expect(layout.spanToVisualRow['b']).toBe(1);
|
||||
expect(layout.spanToVisualRow['a']).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle full overlap — all siblings get own row', () => {
|
||||
const root = makeSpan({
|
||||
spanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
const a = makeSpan({
|
||||
spanId: 'a',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
const b = makeSpan({
|
||||
spanId: 'b',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
|
||||
const layout = computeVisualLayout([[root], [a, b]]);
|
||||
|
||||
expect(layout.totalVisualRows).toBe(3);
|
||||
expect(layout.spanToVisualRow['a']).toBe(1);
|
||||
expect(layout.spanToVisualRow['b']).toBe(2);
|
||||
});
|
||||
|
||||
it('should stack children correctly below overlapping parents', () => {
|
||||
const root = makeSpan({
|
||||
spanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 300e6,
|
||||
});
|
||||
const a = makeSpan({
|
||||
spanId: 'a',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
const b = makeSpan({
|
||||
spanId: 'b',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 50,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
// Child of A
|
||||
const childA = makeSpan({
|
||||
spanId: 'childA',
|
||||
parentSpanId: 'a',
|
||||
timestamp: 10,
|
||||
durationNano: 50e6,
|
||||
});
|
||||
// Child of B
|
||||
const childB = makeSpan({
|
||||
spanId: 'childB',
|
||||
parentSpanId: 'b',
|
||||
timestamp: 60,
|
||||
durationNano: 50e6,
|
||||
});
|
||||
|
||||
const layout = computeVisualLayout([[root], [a, b], [childA, childB]]);
|
||||
|
||||
// DFS processes b's subtree first (latest):
|
||||
// root → row 0
|
||||
// b → row 1 (parentRow 0 + 1)
|
||||
// childB → row 2 (parentRow 1 + 1)
|
||||
// a → try row 1 (parentRow 0 + 1), overlaps b → try row 2, overlaps childB → row 3
|
||||
// childA → row 4 (parentRow 3 + 1)
|
||||
expect(layout.spanToVisualRow['root']).toBe(0);
|
||||
expect(layout.spanToVisualRow['b']).toBe(1);
|
||||
expect(layout.spanToVisualRow['childB']).toBe(2);
|
||||
expect(layout.spanToVisualRow['a']).toBe(3);
|
||||
expect(layout.spanToVisualRow['childA']).toBe(4);
|
||||
expect(layout.totalVisualRows).toBe(5);
|
||||
});
|
||||
|
||||
it('should handle multiple roots as a sibling group', () => {
|
||||
// Two overlapping roots
|
||||
const r1 = makeSpan({
|
||||
spanId: 'r1',
|
||||
timestamp: 0,
|
||||
durationNano: 100e6,
|
||||
});
|
||||
const r2 = makeSpan({
|
||||
spanId: 'r2',
|
||||
timestamp: 50,
|
||||
durationNano: 100e6,
|
||||
});
|
||||
|
||||
const layout = computeVisualLayout([[r1, r2]]);
|
||||
|
||||
expect(layout.spanToVisualRow['r1']).toBe(0);
|
||||
expect(layout.spanToVisualRow['r2']).toBe(1);
|
||||
expect(layout.totalVisualRows).toBe(2);
|
||||
});
|
||||
|
||||
it('should produce compact layout for deep nesting without overlap', () => {
|
||||
const root = makeSpan({
|
||||
spanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 1000e6,
|
||||
});
|
||||
const child = makeSpan({
|
||||
spanId: 'child',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 10,
|
||||
durationNano: 500e6,
|
||||
});
|
||||
const grandchild = makeSpan({
|
||||
spanId: 'grandchild',
|
||||
parentSpanId: 'child',
|
||||
timestamp: 20,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
|
||||
const layout = computeVisualLayout([[root], [child], [grandchild]]);
|
||||
|
||||
// No overlap at any level → visual rows == tree depth
|
||||
expect(layout.totalVisualRows).toBe(3);
|
||||
expect(layout.spanToVisualRow['root']).toBe(0);
|
||||
expect(layout.spanToVisualRow['child']).toBe(1);
|
||||
expect(layout.spanToVisualRow['grandchild']).toBe(2);
|
||||
});
|
||||
|
||||
it('should pack many sequential siblings into 1 row (no diagonal staircase)', () => {
|
||||
const root = makeSpan({
|
||||
spanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 500e6,
|
||||
});
|
||||
// 6 sequential children — like checkoutservice/PlaceOrder scenario
|
||||
const spans = [
|
||||
makeSpan({
|
||||
spanId: 's1',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 3,
|
||||
durationNano: 30e6,
|
||||
}),
|
||||
makeSpan({
|
||||
spanId: 's2',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 35,
|
||||
durationNano: 4e6,
|
||||
}),
|
||||
makeSpan({
|
||||
spanId: 's3',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 39,
|
||||
durationNano: 1e6,
|
||||
}),
|
||||
makeSpan({
|
||||
spanId: 's4',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 40,
|
||||
durationNano: 4e6,
|
||||
}),
|
||||
makeSpan({
|
||||
spanId: 's5',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 44,
|
||||
durationNano: 5e6,
|
||||
}),
|
||||
makeSpan({
|
||||
spanId: 's6',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 49,
|
||||
durationNano: 1e6,
|
||||
}),
|
||||
];
|
||||
|
||||
const layout = computeVisualLayout([[root], spans]);
|
||||
|
||||
// All 6 sequential siblings should share 1 row
|
||||
expect(layout.totalVisualRows).toBe(2);
|
||||
expect(layout.spanToVisualRow['root']).toBe(0);
|
||||
for (const span of spans) {
|
||||
expect(layout.spanToVisualRow[span.spanId]).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should keep children below parents even with misparented spans', () => {
|
||||
// Simulates the dd_sig2 bug: /route spans have parentSpanId pointing
|
||||
// to the wrong ancestor, but they are at level 2 in the spans[][] input.
|
||||
// Level-based packing must place them below level 1 regardless.
|
||||
const httpGet = makeSpan({
|
||||
spanId: 'http-get',
|
||||
timestamp: 0,
|
||||
durationNano: 500e6,
|
||||
});
|
||||
const route = makeSpan({
|
||||
spanId: 'route',
|
||||
parentSpanId: 'some-wrong-ancestor', // misparented!
|
||||
timestamp: 10,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
|
||||
const layout = computeVisualLayout([[httpGet], [route]]);
|
||||
|
||||
// httpGet at level 0 → row 0, route at level 1 → row 1
|
||||
expect(layout.spanToVisualRow['http-get']).toBe(0);
|
||||
expect(layout.spanToVisualRow['route']).toBe(1);
|
||||
expect(layout.totalVisualRows).toBe(2);
|
||||
});
|
||||
|
||||
it('should keep parent-child pairs adjacent when sibling subtrees overlap', () => {
|
||||
// Multiple overlapping parents each with a child — the subtree-unit
|
||||
// guarantee means every parent→child gap should be exactly 1.
|
||||
const root = makeSpan({
|
||||
spanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 500e6,
|
||||
});
|
||||
// Three overlapping HTTP GET children of root, each with its own /route child
|
||||
const get1 = makeSpan({
|
||||
spanId: 'get1',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
const route1 = makeSpan({
|
||||
spanId: 'route1',
|
||||
parentSpanId: 'get1',
|
||||
timestamp: 10,
|
||||
durationNano: 180e6,
|
||||
});
|
||||
const get2 = makeSpan({
|
||||
spanId: 'get2',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 50,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
const route2 = makeSpan({
|
||||
spanId: 'route2',
|
||||
parentSpanId: 'get2',
|
||||
timestamp: 60,
|
||||
durationNano: 180e6,
|
||||
});
|
||||
const get3 = makeSpan({
|
||||
spanId: 'get3',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 100,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
const route3 = makeSpan({
|
||||
spanId: 'route3',
|
||||
parentSpanId: 'get3',
|
||||
timestamp: 110,
|
||||
durationNano: 180e6,
|
||||
});
|
||||
|
||||
const layout = computeVisualLayout([
|
||||
[root],
|
||||
[get1, get2, get3],
|
||||
[route1, route2, route3],
|
||||
]);
|
||||
|
||||
// Each parent-child pair should have a gap of exactly 1
|
||||
const get1Row = layout.spanToVisualRow['get1'];
|
||||
const route1Row = layout.spanToVisualRow['route1'];
|
||||
const get2Row = layout.spanToVisualRow['get2'];
|
||||
const route2Row = layout.spanToVisualRow['route2'];
|
||||
const get3Row = layout.spanToVisualRow['get3'];
|
||||
const route3Row = layout.spanToVisualRow['route3'];
|
||||
|
||||
expect(route1Row - get1Row).toBe(1);
|
||||
expect(route2Row - get2Row).toBe(1);
|
||||
expect(route3Row - get3Row).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle mixed levels — overlap at level 2 but not level 1', () => {
|
||||
const root = makeSpan({
|
||||
spanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 1000e6,
|
||||
});
|
||||
// Non-overlapping children
|
||||
const a = makeSpan({
|
||||
spanId: 'a',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 400e6,
|
||||
});
|
||||
const b = makeSpan({
|
||||
spanId: 'b',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 500,
|
||||
durationNano: 400e6,
|
||||
});
|
||||
// Overlapping grandchildren under A
|
||||
const ga1 = makeSpan({
|
||||
spanId: 'ga1',
|
||||
parentSpanId: 'a',
|
||||
timestamp: 0,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
const ga2 = makeSpan({
|
||||
spanId: 'ga2',
|
||||
parentSpanId: 'a',
|
||||
timestamp: 100,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
|
||||
const layout = computeVisualLayout([[root], [a, b], [ga1, ga2]]);
|
||||
|
||||
// root → row 0
|
||||
// a, b → row 1 (no overlap, share row)
|
||||
// ga1 → row 2, ga2 → row 3 (overlap, expanded)
|
||||
// b has no children, so nothing after ga2
|
||||
expect(layout.spanToVisualRow['root']).toBe(0);
|
||||
expect(layout.spanToVisualRow['a']).toBe(1);
|
||||
expect(layout.spanToVisualRow['b']).toBe(1);
|
||||
expect(layout.spanToVisualRow['ga2']).toBe(2);
|
||||
expect(layout.spanToVisualRow['ga1']).toBe(3);
|
||||
expect(layout.totalVisualRows).toBe(4);
|
||||
});
|
||||
|
||||
it('should not place a span where it covers an existing connector point (Check 2)', () => {
|
||||
// Scenario: root has 3 leaf children. Sorted latest-first: C(200), B(100), A(80).
|
||||
//
|
||||
// C placed at row 1 [200, 400].
|
||||
// B overlaps C → placed at row 2 [100, 300]. Connector from row 0→2 at x=100
|
||||
// passes through row 1, recording connector point at (row 1, x=100).
|
||||
// A [80, 110] does NOT overlap C's span [200, 400] at row 1 (110 < 200),
|
||||
// so without connector reservation A would fit at row 1.
|
||||
// But A's span [80, 110) contains the connector point x=100 at row 1.
|
||||
// Check 2 prevents this placement, pushing A further down.
|
||||
const root = makeSpan({
|
||||
spanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 500e6,
|
||||
});
|
||||
const c = makeSpan({
|
||||
spanId: 'c',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 200,
|
||||
durationNano: 200e6, // [200, 400]
|
||||
});
|
||||
const b = makeSpan({
|
||||
spanId: 'b',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 100,
|
||||
durationNano: 200e6, // [100, 300]
|
||||
});
|
||||
const a = makeSpan({
|
||||
spanId: 'a',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 80,
|
||||
durationNano: 30e6, // [80, 110]
|
||||
});
|
||||
|
||||
const layout = computeVisualLayout([[root], [a, b, c]]);
|
||||
|
||||
expect(layout.spanToVisualRow['root']).toBe(0);
|
||||
expect(layout.spanToVisualRow['c']).toBe(1); // latest, placed first
|
||||
expect(layout.spanToVisualRow['b']).toBe(2); // overlaps C → row 2
|
||||
|
||||
// A would fit at row 1 by span overlap alone, but connector point at
|
||||
// (row 1, x=100) falls within A's span [80, 110). Check 2 pushes A down.
|
||||
const aRow = layout.spanToVisualRow['a']!;
|
||||
expect(aRow).toBeGreaterThan(1); // must NOT be at row 1
|
||||
expect(aRow).toBe(3); // next free row after B at row 2 (A overlaps B)
|
||||
});
|
||||
});
|
||||
@@ -1,525 +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 } from '../utils';
|
||||
import { MOCK_SPAN } from './testUtils';
|
||||
|
||||
jest.mock('container/TraceDetail/utils', () => ({
|
||||
convertTimeToRelevantUnit: (): { time: number; timeUnitName: string } => ({
|
||||
time: 50,
|
||||
timeUnitName: 'ms',
|
||||
}),
|
||||
}));
|
||||
|
||||
/** Minimal 2D context for createStripePattern's internal canvas (jsdom getContext often returns null) */
|
||||
const mockPatternCanvasCtx = {
|
||||
beginPath: jest.fn(),
|
||||
moveTo: jest.fn(),
|
||||
lineTo: jest.fn(),
|
||||
stroke: jest.fn(),
|
||||
globalAlpha: 1,
|
||||
};
|
||||
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
document.createElement = function (
|
||||
tagName: string,
|
||||
): ReturnType<typeof originalCreateElement> {
|
||||
const el = originalCreateElement(tagName);
|
||||
if (tagName.toLowerCase() === 'canvas') {
|
||||
(el as HTMLCanvasElement).getContext = (() =>
|
||||
mockPatternCanvasCtx as unknown) as HTMLCanvasElement['getContext'];
|
||||
}
|
||||
return el;
|
||||
};
|
||||
|
||||
function createMockCtx(): jest.Mocked<CanvasRenderingContext2D> {
|
||||
return ({
|
||||
beginPath: jest.fn(),
|
||||
roundRect: jest.fn(),
|
||||
fill: jest.fn(),
|
||||
stroke: jest.fn(),
|
||||
save: jest.fn(),
|
||||
restore: jest.fn(),
|
||||
translate: jest.fn(),
|
||||
rotate: jest.fn(),
|
||||
fillRect: jest.fn(),
|
||||
strokeRect: jest.fn(),
|
||||
setLineDash: jest.fn(),
|
||||
measureText: jest.fn(
|
||||
(text: string) => ({ width: text.length * 6 } as TextMetrics),
|
||||
),
|
||||
createPattern: jest.fn(() => ({} as CanvasPattern)),
|
||||
clip: jest.fn(),
|
||||
rect: jest.fn(),
|
||||
fillText: jest.fn(),
|
||||
font: '',
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
textAlign: '',
|
||||
textBaseline: '',
|
||||
lineWidth: 0,
|
||||
globalAlpha: 1,
|
||||
} as unknown) as jest.Mocked<CanvasRenderingContext2D>;
|
||||
}
|
||||
|
||||
const METRICS: FlamegraphRowMetrics = getFlamegraphRowMetrics(24);
|
||||
|
||||
describe('Canvas Draw Utils', () => {
|
||||
describe('drawSpanBar', () => {
|
||||
it('draws rect + fill for normal span (no selected/hovered)', () => {
|
||||
const ctx = createMockCtx();
|
||||
const spanRectsArray: {
|
||||
span: typeof MOCK_SPAN;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
}[] = [];
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, event: [] },
|
||||
x: 10,
|
||||
y: 0,
|
||||
width: 100,
|
||||
levelIndex: 0,
|
||||
spanRectsArray,
|
||||
color: '#1890ff',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(ctx.beginPath).toHaveBeenCalled();
|
||||
expect(ctx.roundRect).toHaveBeenCalledWith(10, 1, 100, 22, 2);
|
||||
expect(ctx.fill).toHaveBeenCalled();
|
||||
expect(ctx.stroke).not.toHaveBeenCalled();
|
||||
expect(spanRectsArray).toHaveLength(1);
|
||||
expect(spanRectsArray[0]).toMatchObject({
|
||||
x: 10,
|
||||
y: 1,
|
||||
width: 100,
|
||||
height: 22,
|
||||
level: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses stripe pattern + dashed stroke + 2px when selected', () => {
|
||||
const ctx = createMockCtx();
|
||||
const spanRectsArray: {
|
||||
span: typeof MOCK_SPAN;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
}[] = [];
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, spanId: 'sel', event: [] },
|
||||
x: 20,
|
||||
y: 0,
|
||||
width: 80,
|
||||
levelIndex: 1,
|
||||
spanRectsArray,
|
||||
color: '#2F80ED',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
selectedSpanId: 'sel',
|
||||
});
|
||||
|
||||
expect(ctx.createPattern).toHaveBeenCalled();
|
||||
expect(ctx.setLineDash).toHaveBeenCalledWith(DASHED_BORDER_LINE_DASH);
|
||||
expect(ctx.strokeStyle).toBe('#2F80ED');
|
||||
expect(ctx.lineWidth).toBe(2);
|
||||
expect(ctx.stroke).toHaveBeenCalled();
|
||||
expect(ctx.setLineDash).toHaveBeenLastCalledWith([]);
|
||||
});
|
||||
|
||||
it('uses stripe pattern + solid stroke + 1px when hovered (not selected)', () => {
|
||||
const ctx = createMockCtx();
|
||||
const spanRectsArray: {
|
||||
span: typeof MOCK_SPAN;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
}[] = [];
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, spanId: 'hov', event: [] },
|
||||
x: 30,
|
||||
y: 0,
|
||||
width: 60,
|
||||
levelIndex: 0,
|
||||
spanRectsArray,
|
||||
color: '#2F80ED',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
hoveredSpanId: 'hov',
|
||||
});
|
||||
|
||||
expect(ctx.createPattern).toHaveBeenCalled();
|
||||
expect(ctx.setLineDash).not.toHaveBeenCalled();
|
||||
expect(ctx.lineWidth).toBe(1);
|
||||
expect(ctx.stroke).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pushes spanRectsArray with correct dimensions', () => {
|
||||
const ctx = createMockCtx();
|
||||
const spanRectsArray: {
|
||||
span: typeof MOCK_SPAN;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
}[] = [];
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, spanId: 'rect-test', event: [] },
|
||||
x: 5,
|
||||
y: 24,
|
||||
width: 200,
|
||||
levelIndex: 2,
|
||||
spanRectsArray,
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(spanRectsArray[0]).toMatchObject({
|
||||
x: 5,
|
||||
y: 25,
|
||||
width: 200,
|
||||
height: 22,
|
||||
level: 2,
|
||||
});
|
||||
expect(spanRectsArray[0].span.spanId).toBe('rect-test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('drawSpanLabel (via drawSpanBar)', () => {
|
||||
it('skips label when width < MIN_WIDTH_FOR_NAME', () => {
|
||||
const ctx = createMockCtx();
|
||||
const spanRectsArray: {
|
||||
span: typeof MOCK_SPAN;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
}[] = [];
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, name: 'long-span-name', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: MIN_WIDTH_FOR_NAME - 1,
|
||||
levelIndex: 0,
|
||||
spanRectsArray,
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(ctx.clip).not.toHaveBeenCalled();
|
||||
expect(ctx.fillText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('draws name only when width >= MIN_WIDTH_FOR_NAME but < MIN_WIDTH_FOR_NAME_AND_DURATION', () => {
|
||||
const ctx = createMockCtx();
|
||||
ctx.measureText = jest.fn(
|
||||
(t: string) => ({ width: t.length * 6 } as TextMetrics),
|
||||
);
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, name: 'foo', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 50,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(ctx.clip).toHaveBeenCalled();
|
||||
expect(ctx.fillText).toHaveBeenCalled();
|
||||
expect(ctx.textAlign).toBe('left');
|
||||
});
|
||||
|
||||
it('draws name + duration when width >= MIN_WIDTH_FOR_NAME_AND_DURATION', () => {
|
||||
const ctx = createMockCtx();
|
||||
ctx.measureText = jest.fn(
|
||||
(t: string) => ({ width: t.length * 6 } as TextMetrics),
|
||||
);
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, name: 'my-span', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(ctx.fillText).toHaveBeenCalledTimes(2);
|
||||
expect(ctx.fillText).toHaveBeenCalledWith(
|
||||
'50ms',
|
||||
expect.any(Number),
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(ctx.fillText).toHaveBeenCalledWith(
|
||||
'my-span',
|
||||
expect.any(Number),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncateText (via drawSpanBar)', () => {
|
||||
it('uses full text when it fits', () => {
|
||||
const ctx = createMockCtx();
|
||||
ctx.measureText = jest.fn(
|
||||
(t: string) => ({ width: t.length * 4 } as TextMetrics),
|
||||
);
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, name: 'short', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(ctx.fillText).toHaveBeenCalledWith(
|
||||
'short',
|
||||
expect.any(Number),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('truncates text when it exceeds available width', () => {
|
||||
const ctx = createMockCtx();
|
||||
ctx.measureText = jest.fn(
|
||||
(t: string) =>
|
||||
({
|
||||
width: t.includes('...') ? 24 : t.length * 10,
|
||||
} as TextMetrics),
|
||||
);
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, name: 'very-long-span-name', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 50,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
const fillTextCalls = (ctx.fillText as jest.Mock).mock.calls;
|
||||
const nameArg = fillTextCalls.find((c) => c[0] !== '50ms')?.[0];
|
||||
expect(nameArg).toBeDefined();
|
||||
expect(nameArg).toMatch(/\.\.\.$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('drawEventDot', () => {
|
||||
it('uses error styling when isError is true', () => {
|
||||
const ctx = createMockCtx();
|
||||
|
||||
drawEventDot({
|
||||
ctx,
|
||||
x: 50,
|
||||
y: 11,
|
||||
isError: true,
|
||||
isDarkMode: false,
|
||||
eventDotSize: 6,
|
||||
});
|
||||
|
||||
expect(ctx.save).toHaveBeenCalled();
|
||||
expect(ctx.translate).toHaveBeenCalledWith(50, 11);
|
||||
expect(ctx.rotate).toHaveBeenCalledWith(Math.PI / 4);
|
||||
expect(ctx.fillStyle).toBe('rgb(220, 38, 38)');
|
||||
expect(ctx.strokeStyle).toBe('rgb(153, 27, 27)');
|
||||
expect(ctx.fillRect).toHaveBeenCalledWith(-3, -3, 6, 6);
|
||||
expect(ctx.strokeRect).toHaveBeenCalledWith(-3, -3, 6, 6);
|
||||
expect(ctx.restore).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses normal styling when isError is false', () => {
|
||||
const ctx = createMockCtx();
|
||||
|
||||
drawEventDot({
|
||||
ctx,
|
||||
x: 0,
|
||||
y: 0,
|
||||
isError: false,
|
||||
isDarkMode: false,
|
||||
eventDotSize: 6,
|
||||
});
|
||||
|
||||
expect(ctx.fillStyle).toBe('rgb(6, 182, 212)');
|
||||
expect(ctx.strokeStyle).toBe('rgb(8, 145, 178)');
|
||||
});
|
||||
|
||||
it('uses dark mode colors for error', () => {
|
||||
const ctx = createMockCtx();
|
||||
|
||||
drawEventDot({
|
||||
ctx,
|
||||
x: 0,
|
||||
y: 0,
|
||||
isError: true,
|
||||
isDarkMode: true,
|
||||
eventDotSize: 6,
|
||||
});
|
||||
|
||||
expect(ctx.fillStyle).toBe('rgb(239, 68, 68)');
|
||||
expect(ctx.strokeStyle).toBe('rgb(185, 28, 28)');
|
||||
});
|
||||
|
||||
it('uses dark mode colors for non-error', () => {
|
||||
const ctx = createMockCtx();
|
||||
|
||||
drawEventDot({
|
||||
ctx,
|
||||
x: 0,
|
||||
y: 0,
|
||||
isError: false,
|
||||
isDarkMode: true,
|
||||
eventDotSize: 6,
|
||||
});
|
||||
|
||||
expect(ctx.fillStyle).toBe('rgb(14, 165, 233)');
|
||||
expect(ctx.strokeStyle).toBe('rgb(2, 132, 199)');
|
||||
});
|
||||
|
||||
it('calls save, translate, rotate, restore', () => {
|
||||
const ctx = createMockCtx();
|
||||
|
||||
drawEventDot({
|
||||
ctx,
|
||||
x: 10,
|
||||
y: 20,
|
||||
isError: false,
|
||||
isDarkMode: false,
|
||||
eventDotSize: 4,
|
||||
});
|
||||
|
||||
expect(ctx.save).toHaveBeenCalled();
|
||||
expect(ctx.translate).toHaveBeenCalledWith(10, 20);
|
||||
expect(ctx.rotate).toHaveBeenCalledWith(Math.PI / 4);
|
||||
expect(ctx.restore).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createStripePattern (via drawSpanBar)', () => {
|
||||
it('uses pattern when createPattern returns non-null', () => {
|
||||
const ctx = createMockCtx();
|
||||
const mockPattern = {} as CanvasPattern;
|
||||
(ctx.createPattern as jest.Mock).mockReturnValue(mockPattern);
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, spanId: 'p', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: MIN_WIDTH_FOR_NAME - 1,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
hoveredSpanId: 'p',
|
||||
});
|
||||
|
||||
expect(ctx.createPattern).toHaveBeenCalled();
|
||||
expect(ctx.fillStyle).toBe(mockPattern);
|
||||
expect(ctx.fill).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips fill when createPattern returns null', () => {
|
||||
const ctx = createMockCtx();
|
||||
(ctx.createPattern as jest.Mock).mockReturnValue(null);
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, spanId: 'p', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: MIN_WIDTH_FOR_NAME - 1,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
selectedSpanId: 'p',
|
||||
});
|
||||
|
||||
expect(ctx.fill).not.toHaveBeenCalled();
|
||||
expect(ctx.stroke).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('drawSpanBar with events', () => {
|
||||
it('draws event dots for each span event', () => {
|
||||
const ctx = createMockCtx();
|
||||
const spanWithEvents = {
|
||||
...MOCK_SPAN,
|
||||
event: [
|
||||
{
|
||||
name: 'e1',
|
||||
timeUnixNano: 1_010_000_000,
|
||||
attributeMap: {},
|
||||
isError: false,
|
||||
},
|
||||
{
|
||||
name: 'e2',
|
||||
timeUnixNano: 1_025_000_000,
|
||||
attributeMap: {},
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: spanWithEvents,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(ctx.save).toHaveBeenCalledTimes(3);
|
||||
expect(ctx.translate).toHaveBeenCalledTimes(2);
|
||||
expect(ctx.fillRect).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
/** Minimal FlamegraphSpan for unit tests */
|
||||
export const MOCK_SPAN: FlamegraphSpan = {
|
||||
timestamp: 1000,
|
||||
durationNano: 50_000_000, // 50ms
|
||||
spanId: 'span-1',
|
||||
parentSpanId: '',
|
||||
traceId: 'trace-1',
|
||||
hasError: false,
|
||||
serviceName: 'test-service',
|
||||
name: 'test-span',
|
||||
level: 0,
|
||||
event: [],
|
||||
};
|
||||
|
||||
/** Nested spans structure for findSpanById tests */
|
||||
export const MOCK_SPANS: FlamegraphSpan[][] = [
|
||||
[
|
||||
{
|
||||
...MOCK_SPAN,
|
||||
spanId: 'root',
|
||||
parentSpanId: '',
|
||||
level: 0,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
...MOCK_SPAN,
|
||||
spanId: 'child-a',
|
||||
parentSpanId: 'root',
|
||||
level: 1,
|
||||
},
|
||||
{
|
||||
...MOCK_SPAN,
|
||||
spanId: 'child-b',
|
||||
parentSpanId: 'root',
|
||||
level: 1,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
...MOCK_SPAN,
|
||||
spanId: 'grandchild',
|
||||
parentSpanId: 'child-a',
|
||||
level: 2,
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
export const MOCK_TRACE_METADATA = {
|
||||
startTime: 0,
|
||||
endTime: 1000,
|
||||
};
|
||||
@@ -1,196 +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('does not set suppressClickRef when movement is below threshold', () => {
|
||||
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseDown(({
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 50,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown) as React.MouseEvent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseMove(({
|
||||
clientX: 102,
|
||||
clientY: 51,
|
||||
} as unknown) as React.MouseEvent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseUp();
|
||||
});
|
||||
|
||||
expect(result.current.suppressClickRef.current).toBe(false);
|
||||
});
|
||||
|
||||
it('sets suppressClickRef when drag exceeds threshold', () => {
|
||||
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseDown(({
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 50,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown) as React.MouseEvent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseMove(({
|
||||
clientX: 150,
|
||||
clientY: 100,
|
||||
} as unknown) as React.MouseEvent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseUp();
|
||||
});
|
||||
|
||||
expect(result.current.suppressClickRef.current).toBe(true);
|
||||
});
|
||||
|
||||
it('resets drag state on mouseup', () => {
|
||||
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseDown(({
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 50,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown) as React.MouseEvent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseUp();
|
||||
});
|
||||
|
||||
expect(result.current.isDraggingRef.current).toBe(false);
|
||||
});
|
||||
|
||||
it('cancels drag on mouseleave', () => {
|
||||
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseDown(({
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 50,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown) as React.MouseEvent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragMouseLeave();
|
||||
});
|
||||
|
||||
expect(result.current.isDraggingRef.current).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,174 +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] },
|
||||
traceMetadata: MOCK_TRACE_METADATA,
|
||||
viewStartTs: MOCK_TRACE_METADATA.startTime,
|
||||
viewEndTs: MOCK_TRACE_METADATA.endTime,
|
||||
isDraggingRef: { current: false },
|
||||
suppressClickRef: { current: false },
|
||||
onSpanClick: jest.fn(),
|
||||
isDarkMode: false,
|
||||
};
|
||||
|
||||
describe('useFlamegraphHover', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'devicePixelRatio', {
|
||||
configurable: true,
|
||||
value: 1,
|
||||
});
|
||||
jest.clearAllMocks();
|
||||
defaultArgs.spanRectsRef.current = [spanRect];
|
||||
defaultArgs.isDraggingRef.current = false;
|
||||
defaultArgs.suppressClickRef.current = false;
|
||||
});
|
||||
|
||||
it('sets hoveredSpanId and tooltipContent when hovering on span', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseMove({
|
||||
clientX: 150,
|
||||
clientY: 61,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(result.current.hoveredSpanId).toBe('hover-span');
|
||||
expect(result.current.tooltipContent).not.toBeNull();
|
||||
expect(result.current.tooltipContent?.spanName).toBe('test-span');
|
||||
expect(result.current.tooltipContent?.clientX).toBe(150);
|
||||
expect(result.current.tooltipContent?.clientY).toBe(61);
|
||||
});
|
||||
|
||||
it('clears hover when moving to empty area', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseMove({
|
||||
clientX: 150,
|
||||
clientY: 61,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(result.current.hoveredSpanId).toBe('hover-span');
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseMove({
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(result.current.hoveredSpanId).toBeNull();
|
||||
expect(result.current.tooltipContent).toBeNull();
|
||||
});
|
||||
|
||||
it('clears hover on mouse leave', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseMove({
|
||||
clientX: 150,
|
||||
clientY: 61,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseLeave();
|
||||
});
|
||||
|
||||
expect(result.current.hoveredSpanId).toBeNull();
|
||||
expect(result.current.tooltipContent).toBeNull();
|
||||
});
|
||||
|
||||
it('suppresses click when suppressClickRef is set', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
defaultArgs.suppressClickRef.current = true;
|
||||
|
||||
act(() => {
|
||||
result.current.handleClick({
|
||||
clientX: 150,
|
||||
clientY: 61,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(defaultArgs.onSpanClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onSpanClick when clicking on span', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleClick({
|
||||
clientX: 150,
|
||||
clientY: 61,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(defaultArgs.onSpanClick).toHaveBeenCalledWith('hover-span');
|
||||
});
|
||||
|
||||
it('uses clientX/clientY for tooltip positioning', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseMove({
|
||||
clientX: 200,
|
||||
clientY: 60,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(result.current.tooltipContent?.clientX).toBe(200);
|
||||
expect(result.current.tooltipContent?.clientY).toBe(60);
|
||||
});
|
||||
|
||||
it('does not update hover during drag', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
defaultArgs.isDraggingRef.current = true;
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseMove({
|
||||
clientX: 150,
|
||||
clientY: 61,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(result.current.hoveredSpanId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,279 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { DEFAULT_ROW_HEIGHT, MIN_VISIBLE_SPAN_MS } from '../constants';
|
||||
import { useFlamegraphZoom } from '../hooks/useFlamegraphZoom';
|
||||
import { MOCK_TRACE_METADATA } from './testUtils';
|
||||
|
||||
function createMockCanvas(): HTMLCanvasElement {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 800;
|
||||
canvas.height = 400;
|
||||
canvas.getBoundingClientRect = jest.fn(
|
||||
(): DOMRect =>
|
||||
({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 400,
|
||||
x: 0,
|
||||
y: 0,
|
||||
bottom: 400,
|
||||
right: 800,
|
||||
toJSON: (): Record<string, unknown> => ({}),
|
||||
} as DOMRect),
|
||||
);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
describe('useFlamegraphZoom', () => {
|
||||
const traceMetadata = { ...MOCK_TRACE_METADATA };
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'devicePixelRatio', {
|
||||
configurable: true,
|
||||
value: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('handleResetZoom restores traceMetadata.startTime/endTime', () => {
|
||||
const setViewStartTs = jest.fn();
|
||||
const setViewEndTs = jest.fn();
|
||||
const setRowHeight = jest.fn();
|
||||
const viewStartRef = { current: 100 };
|
||||
const viewEndRef = { current: 500 };
|
||||
const rowHeightRef = { current: 30 };
|
||||
const canvasRef = { current: createMockCanvas() };
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFlamegraphZoom({
|
||||
canvasRef,
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
rowHeightRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setRowHeight,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleResetZoom();
|
||||
});
|
||||
|
||||
expect(setViewStartTs).toHaveBeenCalledWith(traceMetadata.startTime);
|
||||
expect(setViewEndTs).toHaveBeenCalledWith(traceMetadata.endTime);
|
||||
expect(setRowHeight).toHaveBeenCalledWith(DEFAULT_ROW_HEIGHT);
|
||||
expect(viewStartRef.current).toBe(traceMetadata.startTime);
|
||||
expect(viewEndRef.current).toBe(traceMetadata.endTime);
|
||||
expect(rowHeightRef.current).toBe(DEFAULT_ROW_HEIGHT);
|
||||
});
|
||||
|
||||
it('wheel zoom in decreases visible time range', async () => {
|
||||
const setViewStartTs = jest.fn();
|
||||
const setViewEndTs = jest.fn();
|
||||
const setRowHeight = jest.fn();
|
||||
const viewStartRef = { current: traceMetadata.startTime };
|
||||
const viewEndRef = { current: traceMetadata.endTime };
|
||||
const rowHeightRef = { current: DEFAULT_ROW_HEIGHT };
|
||||
const canvas = createMockCanvas();
|
||||
const canvasRef = { current: canvas };
|
||||
|
||||
renderHook(() =>
|
||||
useFlamegraphZoom({
|
||||
canvasRef,
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
rowHeightRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setRowHeight,
|
||||
}),
|
||||
);
|
||||
|
||||
const initialSpan = viewEndRef.current - viewStartRef.current;
|
||||
|
||||
await act(async () => {
|
||||
canvas.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
clientX: 400,
|
||||
deltaY: -100,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
});
|
||||
|
||||
expect(setViewStartTs).toHaveBeenCalled();
|
||||
expect(setViewEndTs).toHaveBeenCalled();
|
||||
const [newStart] = setViewStartTs.mock.calls[0] ?? [];
|
||||
const [newEnd] = setViewEndTs.mock.calls[0] ?? [];
|
||||
if (newStart != null && newEnd != null) {
|
||||
const newSpan = newEnd - newStart;
|
||||
expect(newSpan).toBeLessThan(initialSpan);
|
||||
}
|
||||
});
|
||||
|
||||
it('wheel zoom out increases visible time range', async () => {
|
||||
const setViewStartTs = jest.fn();
|
||||
const setViewEndTs = jest.fn();
|
||||
const setRowHeight = jest.fn();
|
||||
const halfSpan = (traceMetadata.endTime - traceMetadata.startTime) / 2;
|
||||
const viewStartRef = { current: traceMetadata.startTime + halfSpan * 0.25 };
|
||||
const viewEndRef = { current: traceMetadata.startTime + halfSpan * 0.75 };
|
||||
const rowHeightRef = { current: DEFAULT_ROW_HEIGHT };
|
||||
const canvas = createMockCanvas();
|
||||
const canvasRef = { current: canvas };
|
||||
|
||||
renderHook(() =>
|
||||
useFlamegraphZoom({
|
||||
canvasRef,
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
rowHeightRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setRowHeight,
|
||||
}),
|
||||
);
|
||||
|
||||
const initialSpan = viewEndRef.current - viewStartRef.current;
|
||||
|
||||
await act(async () => {
|
||||
canvas.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
clientX: 400,
|
||||
deltaY: 100,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
});
|
||||
|
||||
expect(setViewStartTs).toHaveBeenCalled();
|
||||
expect(setViewEndTs).toHaveBeenCalled();
|
||||
const [newStart] = setViewStartTs.mock.calls[0] ?? [];
|
||||
const [newEnd] = setViewEndTs.mock.calls[0] ?? [];
|
||||
if (newStart != null && newEnd != null) {
|
||||
const newSpan = newEnd - newStart;
|
||||
expect(newSpan).toBeGreaterThanOrEqual(initialSpan);
|
||||
}
|
||||
});
|
||||
|
||||
it('clamps zoom to MIN_VISIBLE_SPAN_MS', async () => {
|
||||
const setViewStartTs = jest.fn();
|
||||
const setViewEndTs = jest.fn();
|
||||
const setRowHeight = jest.fn();
|
||||
const viewStartRef = { current: traceMetadata.startTime };
|
||||
const viewEndRef = { current: traceMetadata.startTime + 100 };
|
||||
const rowHeightRef = { current: DEFAULT_ROW_HEIGHT };
|
||||
const canvas = createMockCanvas();
|
||||
const canvasRef = { current: canvas };
|
||||
|
||||
renderHook(() =>
|
||||
useFlamegraphZoom({
|
||||
canvasRef,
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
rowHeightRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setRowHeight,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
canvas.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
clientX: 400,
|
||||
deltaY: 10000,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
});
|
||||
|
||||
const [newStart] = setViewStartTs.mock.calls[0] ?? [];
|
||||
const [newEnd] = setViewEndTs.mock.calls[0] ?? [];
|
||||
if (newStart != null && newEnd != null) {
|
||||
const newSpan = newEnd - newStart;
|
||||
expect(newSpan).toBeGreaterThanOrEqual(MIN_VISIBLE_SPAN_MS);
|
||||
}
|
||||
});
|
||||
|
||||
it('clamps viewStart/viewEnd to trace bounds', async () => {
|
||||
const setViewStartTs = jest.fn();
|
||||
const setViewEndTs = jest.fn();
|
||||
const setRowHeight = jest.fn();
|
||||
const viewStartRef = { current: traceMetadata.startTime };
|
||||
const viewEndRef = { current: traceMetadata.endTime };
|
||||
const rowHeightRef = { current: DEFAULT_ROW_HEIGHT };
|
||||
const canvas = createMockCanvas();
|
||||
const canvasRef = { current: canvas };
|
||||
|
||||
renderHook(() =>
|
||||
useFlamegraphZoom({
|
||||
canvasRef,
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
rowHeightRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setRowHeight,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
canvas.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
clientX: 400,
|
||||
deltaY: -5000,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
});
|
||||
|
||||
const [newStart] = setViewStartTs.mock.calls[0] ?? [];
|
||||
const [newEnd] = setViewEndTs.mock.calls[0] ?? [];
|
||||
if (newStart != null && newEnd != null) {
|
||||
expect(newStart).toBeGreaterThanOrEqual(traceMetadata.startTime);
|
||||
expect(newEnd).toBeLessThanOrEqual(traceMetadata.endTime);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns isOverFlamegraphRef', () => {
|
||||
const canvasRef = { current: createMockCanvas() };
|
||||
const { result } = renderHook(() =>
|
||||
useFlamegraphZoom({
|
||||
canvasRef,
|
||||
traceMetadata,
|
||||
viewStartRef: { current: 0 },
|
||||
viewEndRef: { current: 1000 },
|
||||
rowHeightRef: { current: 24 },
|
||||
setViewStartTs: jest.fn(),
|
||||
setViewEndTs: jest.fn(),
|
||||
setRowHeight: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.isOverFlamegraphRef).toBeDefined();
|
||||
expect(result.current.isOverFlamegraphRef.current).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,212 +0,0 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useScrollToSpan } from '../hooks/useScrollToSpan';
|
||||
import { MOCK_SPANS, MOCK_TRACE_METADATA } from './testUtils';
|
||||
|
||||
function TestWrapper({
|
||||
firstSpanAtFetchLevel,
|
||||
spans,
|
||||
traceMetadata,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setScrollTop,
|
||||
}: {
|
||||
firstSpanAtFetchLevel: string;
|
||||
spans: typeof MOCK_SPANS;
|
||||
traceMetadata: typeof MOCK_TRACE_METADATA;
|
||||
setViewStartTs: Dispatch<SetStateAction<number>>;
|
||||
setViewEndTs: Dispatch<SetStateAction<number>>;
|
||||
setScrollTop: Dispatch<SetStateAction<number>>;
|
||||
}): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewStartRef = useRef(traceMetadata.startTime);
|
||||
const viewEndRef = useRef(traceMetadata.endTime);
|
||||
const scrollTopRef = useRef(0);
|
||||
|
||||
useScrollToSpan({
|
||||
firstSpanAtFetchLevel,
|
||||
spans,
|
||||
traceMetadata,
|
||||
containerRef,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
scrollTopRef,
|
||||
rowHeight: 24,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setScrollTop,
|
||||
});
|
||||
|
||||
return <div ref={containerRef} data-testid="container" />;
|
||||
}
|
||||
|
||||
describe('useScrollToSpan', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
|
||||
configurable: true,
|
||||
value: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not update when firstSpanAtFetchLevel is empty', async () => {
|
||||
const setViewStartTs = jest.fn();
|
||||
const setViewEndTs = jest.fn();
|
||||
const setScrollTop = jest.fn();
|
||||
|
||||
render(
|
||||
<TestWrapper
|
||||
firstSpanAtFetchLevel=""
|
||||
spans={MOCK_SPANS}
|
||||
traceMetadata={MOCK_TRACE_METADATA}
|
||||
setViewStartTs={setViewStartTs}
|
||||
setViewEndTs={setViewEndTs}
|
||||
setScrollTop={setScrollTop}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setViewStartTs).not.toHaveBeenCalled();
|
||||
expect(setViewEndTs).not.toHaveBeenCalled();
|
||||
expect(setScrollTop).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not update when spans are empty', async () => {
|
||||
const setViewStartTs = jest.fn();
|
||||
const setViewEndTs = jest.fn();
|
||||
const setScrollTop = jest.fn();
|
||||
|
||||
render(
|
||||
<TestWrapper
|
||||
firstSpanAtFetchLevel="root"
|
||||
spans={[]}
|
||||
traceMetadata={MOCK_TRACE_METADATA}
|
||||
setViewStartTs={setViewStartTs}
|
||||
setViewEndTs={setViewEndTs}
|
||||
setScrollTop={setScrollTop}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setViewStartTs).not.toHaveBeenCalled();
|
||||
expect(setViewEndTs).not.toHaveBeenCalled();
|
||||
expect(setScrollTop).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not update when target span not found', async () => {
|
||||
const setViewStartTs = jest.fn();
|
||||
const setViewEndTs = jest.fn();
|
||||
const setScrollTop = jest.fn();
|
||||
|
||||
render(
|
||||
<TestWrapper
|
||||
firstSpanAtFetchLevel="nonexistent"
|
||||
spans={MOCK_SPANS}
|
||||
traceMetadata={MOCK_TRACE_METADATA}
|
||||
setViewStartTs={setViewStartTs}
|
||||
setViewEndTs={setViewEndTs}
|
||||
setScrollTop={setScrollTop}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setViewStartTs).not.toHaveBeenCalled();
|
||||
expect(setViewEndTs).not.toHaveBeenCalled();
|
||||
expect(setScrollTop).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls setters when target span found', async () => {
|
||||
const setViewStartTs = jest.fn();
|
||||
const setViewEndTs = jest.fn();
|
||||
const setScrollTop = jest.fn();
|
||||
|
||||
const { getByTestId } = render(
|
||||
<TestWrapper
|
||||
firstSpanAtFetchLevel="grandchild"
|
||||
spans={MOCK_SPANS}
|
||||
traceMetadata={MOCK_TRACE_METADATA}
|
||||
setViewStartTs={setViewStartTs}
|
||||
setViewEndTs={setViewEndTs}
|
||||
setScrollTop={setScrollTop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByTestId('container')).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setViewStartTs).toHaveBeenCalled();
|
||||
expect(setViewEndTs).toHaveBeenCalled();
|
||||
expect(setScrollTop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const [viewStart] = setViewStartTs.mock.calls[0];
|
||||
const [viewEnd] = setViewEndTs.mock.calls[0];
|
||||
const [scrollTop] = setScrollTop.mock.calls[0];
|
||||
|
||||
expect(viewEnd - viewStart).toBeGreaterThan(0);
|
||||
expect(viewStart).toBeGreaterThanOrEqual(MOCK_TRACE_METADATA.startTime);
|
||||
expect(viewEnd).toBeLessThanOrEqual(MOCK_TRACE_METADATA.endTime);
|
||||
expect(scrollTop).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('centers span vertically (scrollTop centers span row)', async () => {
|
||||
const setScrollTop = jest.fn();
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestWrapper
|
||||
firstSpanAtFetchLevel="grandchild"
|
||||
spans={MOCK_SPANS}
|
||||
traceMetadata={MOCK_TRACE_METADATA}
|
||||
setViewStartTs={jest.fn()}
|
||||
setViewEndTs={jest.fn()}
|
||||
setScrollTop={setScrollTop}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(setScrollTop).toHaveBeenCalled());
|
||||
|
||||
const [scrollTop] = setScrollTop.mock.calls[0];
|
||||
const levelIndex = 2;
|
||||
const rowHeight = 24;
|
||||
const viewportHeight = 400;
|
||||
const expectedCenter =
|
||||
levelIndex * rowHeight - viewportHeight / 2 + rowHeight / 2;
|
||||
expect(scrollTop).toBeCloseTo(Math.max(0, expectedCenter), -1);
|
||||
});
|
||||
|
||||
it('zooms horizontally to span with 2x duration padding', async () => {
|
||||
const setViewStartTs = jest.fn();
|
||||
const setViewEndTs = jest.fn();
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestWrapper
|
||||
firstSpanAtFetchLevel="root"
|
||||
spans={MOCK_SPANS}
|
||||
traceMetadata={MOCK_TRACE_METADATA}
|
||||
setViewStartTs={setViewStartTs}
|
||||
setViewEndTs={setViewEndTs}
|
||||
setScrollTop={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setViewStartTs).toHaveBeenCalled();
|
||||
expect(setViewEndTs).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const [viewStart] = setViewStartTs.mock.calls[0];
|
||||
const [viewEnd] = setViewEndTs.mock.calls[0];
|
||||
const visibleWindow = viewEnd - viewStart;
|
||||
const rootSpan = MOCK_SPANS[0][0];
|
||||
const spanDurationMs = rootSpan.durationNano / 1e6;
|
||||
expect(visibleWindow).toBeGreaterThanOrEqual(Math.max(spanDurationMs * 2, 5));
|
||||
});
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
import {
|
||||
clamp,
|
||||
findSpanById,
|
||||
formatDuration,
|
||||
getFlamegraphRowMetrics,
|
||||
} from '../utils';
|
||||
import { MOCK_SPANS } from './testUtils';
|
||||
|
||||
jest.mock('container/TraceDetail/utils', () => ({
|
||||
convertTimeToRelevantUnit: (
|
||||
valueMs: number,
|
||||
): { time: number; timeUnitName: string } => {
|
||||
if (valueMs === 0) {
|
||||
return { time: 0, timeUnitName: 'ms' };
|
||||
}
|
||||
if (valueMs < 1) {
|
||||
return { time: valueMs, timeUnitName: 'ms' };
|
||||
}
|
||||
if (valueMs < 1000) {
|
||||
return { time: valueMs, timeUnitName: 'ms' };
|
||||
}
|
||||
if (valueMs < 60_000) {
|
||||
return { time: valueMs / 1000, timeUnitName: 's' };
|
||||
}
|
||||
if (valueMs < 3_600_000) {
|
||||
return { time: valueMs / 60_000, timeUnitName: 'm' };
|
||||
}
|
||||
return { time: valueMs / 3_600_000, timeUnitName: 'hr' };
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Pure Math and Data Utils', () => {
|
||||
describe('clamp', () => {
|
||||
it('returns value when within range', () => {
|
||||
expect(clamp(5, 0, 10)).toBe(5);
|
||||
expect(clamp(-3, -5, 5)).toBe(-3);
|
||||
});
|
||||
|
||||
it('returns min when value is below min', () => {
|
||||
expect(clamp(-1, 0, 10)).toBe(0);
|
||||
expect(clamp(2, 5, 10)).toBe(5);
|
||||
});
|
||||
|
||||
it('returns max when value is above max', () => {
|
||||
expect(clamp(11, 0, 10)).toBe(10);
|
||||
expect(clamp(100, 0, 50)).toBe(50);
|
||||
});
|
||||
|
||||
it('handles min === max', () => {
|
||||
expect(clamp(5, 7, 7)).toBe(7);
|
||||
expect(clamp(7, 7, 7)).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSpanById', () => {
|
||||
it('finds span in first level', () => {
|
||||
const result = findSpanById(MOCK_SPANS, 'root');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.span.spanId).toBe('root');
|
||||
expect(result?.levelIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('finds span in nested level', () => {
|
||||
const result = findSpanById(MOCK_SPANS, 'grandchild');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.span.spanId).toBe('grandchild');
|
||||
expect(result?.levelIndex).toBe(2);
|
||||
});
|
||||
|
||||
it('returns null when span not found', () => {
|
||||
expect(findSpanById(MOCK_SPANS, 'nonexistent')).toBeNull();
|
||||
});
|
||||
|
||||
it('handles empty spans', () => {
|
||||
expect(findSpanById([], 'root')).toBeNull();
|
||||
expect(findSpanById([[], []], 'root')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFlamegraphRowMetrics', () => {
|
||||
it('computes normal row height metrics (24px)', () => {
|
||||
const m = getFlamegraphRowMetrics(24);
|
||||
expect(m.ROW_HEIGHT).toBe(24);
|
||||
expect(m.SPAN_BAR_HEIGHT).toBe(22);
|
||||
expect(m.SPAN_BAR_Y_OFFSET).toBe(1);
|
||||
expect(m.EVENT_DOT_SIZE).toBe(6);
|
||||
});
|
||||
|
||||
it('clamps span bar height to max for large row heights', () => {
|
||||
const m = getFlamegraphRowMetrics(100);
|
||||
expect(m.SPAN_BAR_HEIGHT).toBe(22);
|
||||
expect(m.SPAN_BAR_Y_OFFSET).toBe(39);
|
||||
});
|
||||
|
||||
it('clamps span bar height to min for small row heights', () => {
|
||||
const m = getFlamegraphRowMetrics(6);
|
||||
expect(m.SPAN_BAR_HEIGHT).toBe(8);
|
||||
// spanBarYOffset = floor((6-8)/2) = -1 when bar exceeds row height
|
||||
expect(m.SPAN_BAR_Y_OFFSET).toBe(-1);
|
||||
});
|
||||
|
||||
it('clamps event dot size within min/max', () => {
|
||||
const mSmall = getFlamegraphRowMetrics(6);
|
||||
expect(mSmall.EVENT_DOT_SIZE).toBe(4);
|
||||
|
||||
const mLarge = getFlamegraphRowMetrics(24);
|
||||
expect(mLarge.EVENT_DOT_SIZE).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDuration', () => {
|
||||
it('formats nanos as ms', () => {
|
||||
// 1e6 nanos = 1ms
|
||||
expect(formatDuration(1_000_000)).toBe('1ms');
|
||||
});
|
||||
|
||||
it('formats larger durations as s/m/hr', () => {
|
||||
// 2e9 nanos = 2000ms = 2s
|
||||
expect(formatDuration(2_000_000_000)).toBe('2s');
|
||||
});
|
||||
|
||||
it('formats zero duration', () => {
|
||||
expect(formatDuration(0)).toBe('0ms');
|
||||
});
|
||||
|
||||
it('formats very small values', () => {
|
||||
// 1000 nanos = 0.001ms → mock returns { time: 0.001, timeUnitName: 'ms' }
|
||||
expect(formatDuration(1000)).toBe('0ms');
|
||||
});
|
||||
|
||||
it('formats decimal seconds correctly', () => {
|
||||
expect(formatDuration(1_500_000_000)).toBe('1.5s');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import { getSpanColor } from '../utils';
|
||||
import { MOCK_SPAN } from './testUtils';
|
||||
|
||||
const mockGenerateColor = jest.fn();
|
||||
|
||||
jest.mock('lib/uPlotLib/utils/generateColor', () => ({
|
||||
generateColor: (key: string, colorMap: Record<string, string>): string =>
|
||||
mockGenerateColor(key, colorMap),
|
||||
}));
|
||||
|
||||
describe('Presentation / Styling Utils', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGenerateColor.mockReturnValue('#2F80ED');
|
||||
});
|
||||
|
||||
describe('getSpanColor', () => {
|
||||
it('uses generated service color for normal span', () => {
|
||||
mockGenerateColor.mockReturnValue('#1890ff');
|
||||
|
||||
const color = getSpanColor({
|
||||
span: { ...MOCK_SPAN, hasError: false },
|
||||
isDarkMode: false,
|
||||
});
|
||||
|
||||
expect(mockGenerateColor).toHaveBeenCalledWith(
|
||||
MOCK_SPAN.serviceName,
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(color).toBe('#1890ff');
|
||||
});
|
||||
|
||||
it('overrides with error color in light mode when span has error', () => {
|
||||
mockGenerateColor.mockReturnValue('#1890ff');
|
||||
|
||||
const color = getSpanColor({
|
||||
span: { ...MOCK_SPAN, hasError: true },
|
||||
isDarkMode: false,
|
||||
});
|
||||
|
||||
expect(color).toBe('rgb(220, 38, 38)');
|
||||
});
|
||||
|
||||
it('overrides with error color in dark mode when span has error', () => {
|
||||
mockGenerateColor.mockReturnValue('#1890ff');
|
||||
|
||||
const color = getSpanColor({
|
||||
span: { ...MOCK_SPAN, hasError: true },
|
||||
isDarkMode: true,
|
||||
});
|
||||
|
||||
expect(color).toBe('rgb(239, 68, 68)');
|
||||
});
|
||||
|
||||
it('passes serviceName to generateColor', () => {
|
||||
getSpanColor({
|
||||
span: { ...MOCK_SPAN, serviceName: 'my-service' },
|
||||
isDarkMode: false,
|
||||
});
|
||||
|
||||
expect(mockGenerateColor).toHaveBeenCalledWith(
|
||||
'my-service',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,370 +0,0 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
export interface ConnectorLine {
|
||||
parentRow: number;
|
||||
childRow: number;
|
||||
timestampMs: number;
|
||||
serviceName: string;
|
||||
}
|
||||
|
||||
export interface VisualLayout {
|
||||
visualRows: FlamegraphSpan[][];
|
||||
spanToVisualRow: Record<string, number>;
|
||||
connectors: ConnectorLine[];
|
||||
totalVisualRows: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes an overlap-safe visual layout for flamegraph spans using DFS ordering.
|
||||
*
|
||||
* Builds a parent→children tree from parentSpanId, then traverses in DFS pre-order.
|
||||
* Each span is placed at parentRow+1 if free, otherwise scans upward row-by-row
|
||||
* until finding a non-overlapping row. This keeps children visually close to their
|
||||
* parents and avoids the BFS problem where distant siblings push children far down.
|
||||
*/
|
||||
export function computeVisualLayout(spans: FlamegraphSpan[][]): VisualLayout {
|
||||
const spanToVisualRow = new Map<string, number>();
|
||||
const visualRowsMap = new Map<number, FlamegraphSpan[]>();
|
||||
let maxRow = -1;
|
||||
|
||||
// Per-row interval list for overlap detection
|
||||
// Each entry: [startTime, endTime]
|
||||
const rowIntervals = new Map<number, Array<[number, number]>>();
|
||||
|
||||
// function hasOverlap(row: number, startTime: number, endTime: number): boolean {
|
||||
// const intervals = rowIntervals.get(row);
|
||||
// if (!intervals) {
|
||||
// return false;
|
||||
// }
|
||||
// for (const [s, e] of intervals) {
|
||||
// if (startTime < e && endTime > s) {
|
||||
// return true;
|
||||
// }
|
||||
// }
|
||||
// return false;
|
||||
// }
|
||||
|
||||
function addToRow(row: number, span: FlamegraphSpan): void {
|
||||
spanToVisualRow.set(span.spanId, row);
|
||||
let arr = visualRowsMap.get(row);
|
||||
if (!arr) {
|
||||
arr = [];
|
||||
visualRowsMap.set(row, arr);
|
||||
}
|
||||
arr.push(span);
|
||||
|
||||
const startTime = span.timestamp;
|
||||
const endTime = span.timestamp + span.durationNano / 1e6;
|
||||
let intervals = rowIntervals.get(row);
|
||||
if (!intervals) {
|
||||
intervals = [];
|
||||
rowIntervals.set(row, intervals);
|
||||
}
|
||||
intervals.push([startTime, endTime]);
|
||||
|
||||
if (row > maxRow) {
|
||||
maxRow = row;
|
||||
}
|
||||
}
|
||||
|
||||
// Flatten all spans and build lookup + children map
|
||||
const spanMap = new Map<string, FlamegraphSpan>();
|
||||
const childrenMap = new Map<string, FlamegraphSpan[]>();
|
||||
const allSpans: FlamegraphSpan[] = [];
|
||||
|
||||
for (const level of spans) {
|
||||
for (const span of level) {
|
||||
allSpans.push(span);
|
||||
spanMap.set(span.spanId, span);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract parentSpanId — the field may be missing at runtime when the API
|
||||
// returns `references` instead. Fall back to the first CHILD_OF reference.
|
||||
function getParentId(span: FlamegraphSpan): string {
|
||||
if (span.parentSpanId) {
|
||||
return span.parentSpanId;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const refs = (span as any).references as
|
||||
| Array<{ spanId?: string; refType?: string }>
|
||||
| undefined;
|
||||
if (refs) {
|
||||
for (const ref of refs) {
|
||||
if (ref.refType === 'CHILD_OF' && ref.spanId) {
|
||||
return ref.spanId;
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Build children map and identify roots
|
||||
const roots: FlamegraphSpan[] = [];
|
||||
|
||||
for (const span of allSpans) {
|
||||
const parentId = getParentId(span);
|
||||
if (!parentId || !spanMap.has(parentId)) {
|
||||
roots.push(span);
|
||||
} else {
|
||||
let children = childrenMap.get(parentId);
|
||||
if (!children) {
|
||||
children = [];
|
||||
childrenMap.set(parentId, children);
|
||||
}
|
||||
children.push(span);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort children by timestamp for deterministic ordering
|
||||
for (const [, children] of childrenMap) {
|
||||
children.sort((a, b) => b.timestamp - a.timestamp);
|
||||
}
|
||||
|
||||
// --- Subtree-unit placement ---
|
||||
// Compute each subtree's layout in isolation, then place as a unit
|
||||
// to guarantee parent-child adjacency within subtrees.
|
||||
|
||||
interface ShapeEntry {
|
||||
span: FlamegraphSpan;
|
||||
relativeRow: number;
|
||||
}
|
||||
|
||||
function hasOverlapIn(
|
||||
intervals: Map<number, Array<[number, number]>>,
|
||||
row: number,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
): boolean {
|
||||
const rowIntervals = intervals.get(row);
|
||||
if (!rowIntervals) {
|
||||
return false;
|
||||
}
|
||||
for (const [s, e] of rowIntervals) {
|
||||
if (startTime < e && endTime > s) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function addIntervalTo(
|
||||
intervals: Map<number, Array<[number, number]>>,
|
||||
row: number,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
): void {
|
||||
let arr = intervals.get(row);
|
||||
if (!arr) {
|
||||
arr = [];
|
||||
intervals.set(row, arr);
|
||||
}
|
||||
arr.push([startTime, endTime]);
|
||||
}
|
||||
|
||||
function hasConnectorConflict(
|
||||
intervals: Map<number, Array<[number, number]>>,
|
||||
row: number,
|
||||
point: number,
|
||||
): boolean {
|
||||
const rowIntervals = intervals.get(row);
|
||||
if (!rowIntervals) {
|
||||
return false;
|
||||
}
|
||||
for (const [s, e] of rowIntervals) {
|
||||
if (point >= s && point < e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasPointInSpan(
|
||||
connectorPoints: Map<number, number[]>,
|
||||
row: number,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
): boolean {
|
||||
const points = connectorPoints.get(row);
|
||||
if (!points) {
|
||||
return false;
|
||||
}
|
||||
for (const p of points) {
|
||||
if (p >= startTime && p < endTime) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function addConnectorPoint(
|
||||
connectorPoints: Map<number, number[]>,
|
||||
row: number,
|
||||
point: number,
|
||||
): void {
|
||||
let arr = connectorPoints.get(row);
|
||||
if (!arr) {
|
||||
arr = [];
|
||||
connectorPoints.set(row, arr);
|
||||
}
|
||||
arr.push(point);
|
||||
}
|
||||
|
||||
function computeSubtreeShape(rootSpan: FlamegraphSpan): ShapeEntry[] {
|
||||
const localIntervals = new Map<number, Array<[number, number]>>();
|
||||
const localConnectorPoints = new Map<number, number[]>();
|
||||
const shape: ShapeEntry[] = [];
|
||||
|
||||
// Place root span at relative row 0
|
||||
const rootStart = rootSpan.timestamp;
|
||||
const rootEnd = rootSpan.timestamp + rootSpan.durationNano / 1e6;
|
||||
shape.push({ span: rootSpan, relativeRow: 0 });
|
||||
addIntervalTo(localIntervals, 0, rootStart, rootEnd);
|
||||
|
||||
const children = childrenMap.get(rootSpan.spanId);
|
||||
if (children) {
|
||||
for (const child of children) {
|
||||
const childShape = computeSubtreeShape(child);
|
||||
const connectorX = child.timestamp;
|
||||
const offset = findPlacement(
|
||||
childShape,
|
||||
1,
|
||||
localIntervals,
|
||||
localConnectorPoints,
|
||||
connectorX,
|
||||
);
|
||||
|
||||
// Record connector points for intermediate rows (1 to offset-1)
|
||||
for (let r = 1; r < offset; r++) {
|
||||
addConnectorPoint(localConnectorPoints, r, connectorX);
|
||||
}
|
||||
|
||||
// Place child shape into local state at offset
|
||||
for (const entry of childShape) {
|
||||
const actualRow = entry.relativeRow + offset;
|
||||
shape.push({ span: entry.span, relativeRow: actualRow });
|
||||
const s = entry.span.timestamp;
|
||||
const e = entry.span.timestamp + entry.span.durationNano / 1e6;
|
||||
addIntervalTo(localIntervals, actualRow, s, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return shape;
|
||||
}
|
||||
|
||||
function findPlacement(
|
||||
shape: ShapeEntry[],
|
||||
minOffset: number,
|
||||
intervals: Map<number, Array<[number, number]>>,
|
||||
connectorPoints?: Map<number, number[]>,
|
||||
connectorX?: number,
|
||||
): number {
|
||||
// Track the first offset that passes Checks 1 & 2 as a fallback.
|
||||
// Check 3 (connector vs span) is monotonically failing: once it fails
|
||||
// at offset K, all offsets > K also fail (more intermediate rows).
|
||||
// If we can't satisfy Check 3, fall back to the best offset without it.
|
||||
let fallbackOffset = -1;
|
||||
|
||||
for (let offset = minOffset; ; offset++) {
|
||||
let passesSpanChecks = true;
|
||||
|
||||
// Check 1: span vs span (existing)
|
||||
for (const entry of shape) {
|
||||
const targetRow = entry.relativeRow + offset;
|
||||
const s = entry.span.timestamp;
|
||||
const e = entry.span.timestamp + entry.span.durationNano / 1e6;
|
||||
if (hasOverlapIn(intervals, targetRow, s, e)) {
|
||||
passesSpanChecks = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check 2: span vs existing connector points
|
||||
if (passesSpanChecks && connectorPoints) {
|
||||
for (const entry of shape) {
|
||||
const targetRow = entry.relativeRow + offset;
|
||||
const s = entry.span.timestamp;
|
||||
const e = entry.span.timestamp + entry.span.durationNano / 1e6;
|
||||
if (hasPointInSpan(connectorPoints, targetRow, s, e)) {
|
||||
passesSpanChecks = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!passesSpanChecks) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// This offset passes Checks 1 & 2 — record as fallback
|
||||
if (fallbackOffset === -1) {
|
||||
fallbackOffset = offset;
|
||||
}
|
||||
|
||||
// Check 3: new connector vs existing spans
|
||||
if (connectorX !== undefined) {
|
||||
let connectorClear = true;
|
||||
for (let r = 1; r < offset; r++) {
|
||||
if (hasConnectorConflict(intervals, r, connectorX)) {
|
||||
connectorClear = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!connectorClear) {
|
||||
// Check 3 will fail for all larger offsets too.
|
||||
// Fall back to the first offset that passed Checks 1 & 2.
|
||||
return fallbackOffset;
|
||||
}
|
||||
}
|
||||
|
||||
return offset;
|
||||
}
|
||||
}
|
||||
|
||||
// Process roots sorted by timestamp
|
||||
roots.sort((a, b) => a.timestamp - b.timestamp);
|
||||
for (const root of roots) {
|
||||
const shape = computeSubtreeShape(root);
|
||||
const offset = findPlacement(shape, 0, rowIntervals);
|
||||
for (const entry of shape) {
|
||||
addToRow(entry.relativeRow + offset, entry.span);
|
||||
}
|
||||
}
|
||||
|
||||
// Build the visualRows array
|
||||
const totalVisualRows = maxRow + 1;
|
||||
const visualRows: FlamegraphSpan[][] = [];
|
||||
for (let i = 0; i < totalVisualRows; i++) {
|
||||
visualRows.push(visualRowsMap.get(i) || []);
|
||||
}
|
||||
|
||||
// Build connector lines for parent-child pairs with row gap > 1
|
||||
const connectors: ConnectorLine[] = [];
|
||||
for (const [parentId, children] of childrenMap) {
|
||||
const parentRow = spanToVisualRow.get(parentId);
|
||||
if (parentRow === undefined) {
|
||||
continue;
|
||||
}
|
||||
for (const child of children) {
|
||||
const childRow = spanToVisualRow.get(child.spanId);
|
||||
if (childRow === undefined || childRow - parentRow <= 1) {
|
||||
continue;
|
||||
}
|
||||
connectors.push({
|
||||
parentRow,
|
||||
childRow,
|
||||
timestampMs: child.timestamp,
|
||||
serviceName: child.serviceName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
visualRows,
|
||||
spanToVisualRow: Object.fromEntries(spanToVisualRow),
|
||||
connectors,
|
||||
totalVisualRows,
|
||||
};
|
||||
}
|
||||
@@ -1,36 +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];
|
||||
@@ -1,55 +0,0 @@
|
||||
import { RefObject, useCallback, useEffect } from 'react';
|
||||
|
||||
export function useCanvasSetup(
|
||||
canvasRef: RefObject<HTMLCanvasElement>,
|
||||
containerRef: RefObject<HTMLDivElement>,
|
||||
onDraw: () => void,
|
||||
): void {
|
||||
const updateCanvasSize = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const viewportHeight = container.clientHeight;
|
||||
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${viewportHeight}px`;
|
||||
|
||||
const newWidth = Math.floor(rect.width * dpr);
|
||||
const newHeight = Math.floor(viewportHeight * dpr);
|
||||
|
||||
if (canvas.width !== newWidth || canvas.height !== newHeight) {
|
||||
canvas.width = newWidth;
|
||||
canvas.height = newHeight;
|
||||
onDraw();
|
||||
}
|
||||
}, [canvasRef, containerRef, onDraw]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return (): void => {};
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateCanvasSize);
|
||||
resizeObserver.observe(container);
|
||||
updateCanvasSize();
|
||||
|
||||
// when dpr changes, update the canvas size
|
||||
const dprQuery = window.matchMedia('(resolution: 1dppx)');
|
||||
dprQuery.addEventListener('change', updateCanvasSize);
|
||||
|
||||
return (): void => {
|
||||
resizeObserver.disconnect();
|
||||
dprQuery.removeEventListener('change', updateCanvasSize);
|
||||
};
|
||||
}, [containerRef, updateCanvasSize]);
|
||||
|
||||
useEffect(() => {
|
||||
onDraw();
|
||||
}, [onDraw]);
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import {
|
||||
Dispatch,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
MutableRefObject,
|
||||
RefObject,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
import { ITraceMetadata } from '../types';
|
||||
import { clamp } from '../utils';
|
||||
|
||||
interface UseFlamegraphDragArgs {
|
||||
canvasRef: RefObject<HTMLCanvasElement>;
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
traceMetadata: ITraceMetadata;
|
||||
viewStartRef: MutableRefObject<number>;
|
||||
viewEndRef: MutableRefObject<number>;
|
||||
setViewStartTs: Dispatch<SetStateAction<number>>;
|
||||
setViewEndTs: Dispatch<SetStateAction<number>>;
|
||||
scrollTopRef: MutableRefObject<number>;
|
||||
setScrollTop: Dispatch<SetStateAction<number>>;
|
||||
totalHeight: number;
|
||||
}
|
||||
|
||||
interface UseFlamegraphDragResult {
|
||||
handleMouseDown: (e: ReactMouseEvent) => void;
|
||||
handleMouseMove: (e: ReactMouseEvent) => void;
|
||||
handleMouseUp: () => void;
|
||||
handleDragMouseLeave: () => void;
|
||||
suppressClickRef: MutableRefObject<boolean>;
|
||||
isDraggingRef: MutableRefObject<boolean>;
|
||||
}
|
||||
|
||||
const DRAG_THRESHOLD = 5;
|
||||
|
||||
export function useFlamegraphDrag(
|
||||
args: UseFlamegraphDragArgs,
|
||||
): UseFlamegraphDragResult {
|
||||
const {
|
||||
canvasRef,
|
||||
containerRef,
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
scrollTopRef,
|
||||
setScrollTop,
|
||||
totalHeight,
|
||||
} = args;
|
||||
|
||||
const isDraggingRef = useRef(false);
|
||||
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const dragDistanceRef = useRef(0);
|
||||
const suppressClickRef = useRef(false);
|
||||
|
||||
const clampScrollTop = useCallback(
|
||||
(next: number): number => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return 0;
|
||||
}
|
||||
const viewportHeight = container.clientHeight;
|
||||
const maxScroll = Math.max(0, totalHeight - viewportHeight);
|
||||
return clamp(next, 0, maxScroll);
|
||||
},
|
||||
[containerRef, totalHeight],
|
||||
);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(event: ReactMouseEvent): void => {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
|
||||
isDraggingRef.current = true;
|
||||
dragStartRef.current = { x: event.clientX, y: event.clientY };
|
||||
dragDistanceRef.current = 0;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
canvas.style.cursor = 'grabbing';
|
||||
}
|
||||
},
|
||||
[canvasRef],
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(event: ReactMouseEvent): void => {
|
||||
if (!isDraggingRef.current || !dragStartRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const deltaX = event.clientX - dragStartRef.current.x;
|
||||
const deltaY = event.clientY - dragStartRef.current.y;
|
||||
|
||||
dragDistanceRef.current = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||
|
||||
// --- Horizontal pan ---
|
||||
const timeSpan = viewEndRef.current - viewStartRef.current;
|
||||
const deltaTime = (deltaX / rect.width) * timeSpan;
|
||||
|
||||
const newStart = viewStartRef.current - deltaTime;
|
||||
const clampedStart = clamp(
|
||||
newStart,
|
||||
traceMetadata.startTime,
|
||||
traceMetadata.endTime - timeSpan,
|
||||
);
|
||||
const clampedEnd = clampedStart + timeSpan;
|
||||
|
||||
viewStartRef.current = clampedStart;
|
||||
viewEndRef.current = clampedEnd;
|
||||
setViewStartTs(clampedStart);
|
||||
setViewEndTs(clampedEnd);
|
||||
|
||||
// --- Vertical scroll pan ---
|
||||
const nextScrollTop = clampScrollTop(scrollTopRef.current - deltaY);
|
||||
scrollTopRef.current = nextScrollTop;
|
||||
setScrollTop(nextScrollTop);
|
||||
|
||||
dragStartRef.current = { x: event.clientX, y: event.clientY };
|
||||
},
|
||||
[
|
||||
canvasRef,
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
scrollTopRef,
|
||||
setScrollTop,
|
||||
clampScrollTop,
|
||||
],
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback((): void => {
|
||||
const wasDrag = dragDistanceRef.current > DRAG_THRESHOLD;
|
||||
suppressClickRef.current = wasDrag;
|
||||
|
||||
isDraggingRef.current = false;
|
||||
dragStartRef.current = null;
|
||||
dragDistanceRef.current = 0;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
canvas.style.cursor = 'grab';
|
||||
}
|
||||
}, [canvasRef]);
|
||||
|
||||
const handleDragMouseLeave = useCallback((): void => {
|
||||
isDraggingRef.current = false;
|
||||
dragStartRef.current = null;
|
||||
dragDistanceRef.current = 0;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
canvas.style.cursor = 'grab';
|
||||
}
|
||||
}, [canvasRef]);
|
||||
|
||||
return {
|
||||
handleMouseDown,
|
||||
handleMouseMove,
|
||||
handleMouseUp,
|
||||
handleDragMouseLeave,
|
||||
suppressClickRef,
|
||||
isDraggingRef,
|
||||
};
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
import React, { RefObject, useCallback, useRef } from 'react';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
import { ConnectorLine } from '../computeVisualLayout';
|
||||
import { SpanRect } from '../types';
|
||||
import {
|
||||
clamp,
|
||||
drawSpanBar,
|
||||
FlamegraphRowMetrics,
|
||||
getFlamegraphRowMetrics,
|
||||
getSpanColor,
|
||||
} from '../utils';
|
||||
|
||||
interface UseFlamegraphDrawArgs {
|
||||
canvasRef: RefObject<HTMLCanvasElement>;
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
spans: FlamegraphSpan[][];
|
||||
connectors: ConnectorLine[];
|
||||
viewStartTs: number;
|
||||
viewEndTs: number;
|
||||
scrollTop: number;
|
||||
rowHeight: number;
|
||||
selectedSpanId: string | undefined;
|
||||
hoveredSpanId: string;
|
||||
isDarkMode: boolean;
|
||||
spanRectsRef?: React.MutableRefObject<SpanRect[]>;
|
||||
}
|
||||
|
||||
interface UseFlamegraphDrawResult {
|
||||
drawFlamegraph: () => void;
|
||||
spanRectsRef: RefObject<SpanRect[]>;
|
||||
}
|
||||
|
||||
const OVERSCAN_ROWS = 4;
|
||||
|
||||
interface DrawLevelArgs {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
levelSpans: FlamegraphSpan[];
|
||||
levelIndex: number;
|
||||
y: number;
|
||||
viewStartTs: number;
|
||||
timeSpan: number;
|
||||
cssWidth: number;
|
||||
selectedSpanId: string | undefined;
|
||||
hoveredSpanId: string;
|
||||
isDarkMode: boolean;
|
||||
spanRectsArray: SpanRect[];
|
||||
metrics: FlamegraphRowMetrics;
|
||||
}
|
||||
|
||||
function drawLevel(args: DrawLevelArgs): void {
|
||||
const {
|
||||
ctx,
|
||||
levelSpans,
|
||||
levelIndex,
|
||||
y,
|
||||
viewStartTs,
|
||||
timeSpan,
|
||||
cssWidth,
|
||||
selectedSpanId,
|
||||
hoveredSpanId,
|
||||
isDarkMode,
|
||||
spanRectsArray,
|
||||
metrics,
|
||||
} = args;
|
||||
|
||||
const viewEndTs = viewStartTs + timeSpan;
|
||||
|
||||
for (let i = 0; i < levelSpans.length; i++) {
|
||||
const span = levelSpans[i];
|
||||
const spanStartMs = span.timestamp;
|
||||
const spanEndMs = span.timestamp + span.durationNano / 1e6;
|
||||
|
||||
// Time culling -- skip spans entirely outside the visible time window
|
||||
if (spanEndMs < viewStartTs || spanStartMs > viewEndTs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const leftOffset = ((spanStartMs - viewStartTs) / timeSpan) * cssWidth;
|
||||
const rightEdge = ((spanEndMs - viewStartTs) / timeSpan) * cssWidth;
|
||||
let width = rightEdge - leftOffset;
|
||||
|
||||
// Clamp to visible x-range
|
||||
if (leftOffset < 0) {
|
||||
width += leftOffset;
|
||||
if (width <= 0) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (rightEdge > cssWidth) {
|
||||
width = cssWidth - Math.max(0, leftOffset);
|
||||
if (width <= 0) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Minimum 1px width so tiny spans remain visible
|
||||
width = clamp(width, 1, Infinity);
|
||||
|
||||
const color = getSpanColor({ span, isDarkMode });
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span,
|
||||
x: Math.max(0, leftOffset),
|
||||
y,
|
||||
width,
|
||||
levelIndex,
|
||||
spanRectsArray,
|
||||
color,
|
||||
isDarkMode,
|
||||
metrics,
|
||||
selectedSpanId,
|
||||
hoveredSpanId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface DrawConnectorLinesArgs {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
connectors: ConnectorLine[];
|
||||
scrollTop: number;
|
||||
viewStartTs: number;
|
||||
timeSpan: number;
|
||||
cssWidth: number;
|
||||
viewportHeight: number;
|
||||
metrics: FlamegraphRowMetrics;
|
||||
}
|
||||
|
||||
function drawConnectorLines(args: DrawConnectorLinesArgs): void {
|
||||
const {
|
||||
ctx,
|
||||
connectors,
|
||||
scrollTop,
|
||||
viewStartTs,
|
||||
timeSpan,
|
||||
cssWidth,
|
||||
viewportHeight,
|
||||
metrics,
|
||||
} = args;
|
||||
|
||||
ctx.save();
|
||||
ctx.lineWidth = 1;
|
||||
ctx.globalAlpha = 0.6;
|
||||
|
||||
for (const conn of connectors) {
|
||||
const xFrac = (conn.timestampMs - viewStartTs) / timeSpan;
|
||||
if (xFrac < -0.01 || xFrac > 1.01) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parentY =
|
||||
conn.parentRow * metrics.ROW_HEIGHT -
|
||||
scrollTop +
|
||||
metrics.SPAN_BAR_Y_OFFSET +
|
||||
metrics.SPAN_BAR_HEIGHT;
|
||||
const childY =
|
||||
conn.childRow * metrics.ROW_HEIGHT - scrollTop + metrics.SPAN_BAR_Y_OFFSET;
|
||||
|
||||
// Skip if entirely outside viewport
|
||||
if (parentY > viewportHeight || childY < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const color = generateColor(
|
||||
conn.serviceName,
|
||||
themeColors.traceDetailColorsV3,
|
||||
);
|
||||
ctx.strokeStyle = color;
|
||||
|
||||
const x = clamp(xFrac * cssWidth, 0, cssWidth);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, parentY);
|
||||
ctx.lineTo(x, childY);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export function useFlamegraphDraw(
|
||||
args: UseFlamegraphDrawArgs,
|
||||
): UseFlamegraphDrawResult {
|
||||
const {
|
||||
canvasRef,
|
||||
containerRef,
|
||||
spans,
|
||||
connectors,
|
||||
viewStartTs,
|
||||
viewEndTs,
|
||||
scrollTop,
|
||||
rowHeight,
|
||||
selectedSpanId,
|
||||
hoveredSpanId,
|
||||
isDarkMode,
|
||||
spanRectsRef: spanRectsRefProp,
|
||||
} = args;
|
||||
|
||||
const spanRectsRefInternal = useRef<SpanRect[]>([]);
|
||||
const spanRectsRef = spanRectsRefProp ?? spanRectsRefInternal;
|
||||
|
||||
const drawFlamegraph = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const timeSpan = viewEndTs - viewStartTs;
|
||||
if (timeSpan <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cssWidth = canvas.width / dpr;
|
||||
const metrics = getFlamegraphRowMetrics(rowHeight);
|
||||
|
||||
// ---- Vertical clipping window ----
|
||||
const viewportHeight = container.clientHeight;
|
||||
|
||||
//starts drawing OVERSCAN_ROWS(4) rows above the visible area.
|
||||
const firstLevel = Math.max(
|
||||
0,
|
||||
Math.floor(scrollTop / metrics.ROW_HEIGHT) - OVERSCAN_ROWS,
|
||||
);
|
||||
// adds 2*OVERSCAN_ROWS extra rows above and below the visible area.
|
||||
const visibleLevelCount =
|
||||
Math.ceil(viewportHeight / metrics.ROW_HEIGHT) + 2 * OVERSCAN_ROWS;
|
||||
|
||||
const lastLevel = Math.min(spans.length - 1, firstLevel + visibleLevelCount);
|
||||
|
||||
ctx.clearRect(0, 0, cssWidth, viewportHeight);
|
||||
|
||||
// ---- Draw connector lines (behind span bars) ----
|
||||
drawConnectorLines({
|
||||
ctx,
|
||||
connectors,
|
||||
scrollTop,
|
||||
viewStartTs,
|
||||
timeSpan,
|
||||
cssWidth,
|
||||
viewportHeight,
|
||||
metrics,
|
||||
});
|
||||
|
||||
const spanRectsArray: SpanRect[] = [];
|
||||
|
||||
// ---- Draw only visible levels ----
|
||||
for (let levelIndex = firstLevel; levelIndex <= lastLevel; levelIndex++) {
|
||||
const levelSpans = spans[levelIndex];
|
||||
if (!levelSpans) {
|
||||
continue;
|
||||
}
|
||||
|
||||
drawLevel({
|
||||
ctx,
|
||||
levelSpans,
|
||||
levelIndex,
|
||||
y: levelIndex * metrics.ROW_HEIGHT - scrollTop,
|
||||
viewStartTs,
|
||||
timeSpan,
|
||||
cssWidth,
|
||||
selectedSpanId,
|
||||
hoveredSpanId,
|
||||
isDarkMode,
|
||||
spanRectsArray,
|
||||
metrics,
|
||||
});
|
||||
}
|
||||
|
||||
spanRectsRef.current = spanRectsArray;
|
||||
}, [
|
||||
canvasRef,
|
||||
containerRef,
|
||||
spanRectsRef,
|
||||
spans,
|
||||
connectors,
|
||||
viewStartTs,
|
||||
viewEndTs,
|
||||
scrollTop,
|
||||
rowHeight,
|
||||
selectedSpanId,
|
||||
hoveredSpanId,
|
||||
isDarkMode,
|
||||
]);
|
||||
|
||||
// TODO: spanRectsRef is a flat array — hover scans all visible rects O(N).
|
||||
// Upgrade to per-level buckets: spanRects[levelIndex] = [...] so hover can
|
||||
// compute level from mouseY / ROW_HEIGHT and scan only that row.
|
||||
// Further: binary search within a level by x (spans are sorted by start time)
|
||||
// to reduce hover cost from O(N) to O(log N).
|
||||
return { drawFlamegraph, spanRectsRef };
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
import {
|
||||
Dispatch,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
MutableRefObject,
|
||||
RefObject,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
import { SpanRect } from '../types';
|
||||
import { ITraceMetadata } from '../types';
|
||||
import { getSpanColor } from '../utils';
|
||||
|
||||
function getCanvasPointer(
|
||||
canvas: HTMLCanvasElement,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): { cssX: number; cssY: number } | null {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cssWidth = canvas.width / dpr;
|
||||
const cssHeight = canvas.height / dpr;
|
||||
const cssX = (clientX - rect.left) * (cssWidth / rect.width);
|
||||
const cssY = (clientY - rect.top) * (cssHeight / rect.height);
|
||||
return { cssX, cssY };
|
||||
}
|
||||
|
||||
function findSpanAtPosition(
|
||||
cssX: number,
|
||||
cssY: number,
|
||||
spanRects: SpanRect[],
|
||||
): FlamegraphSpan | null {
|
||||
for (let i = spanRects.length - 1; i >= 0; i--) {
|
||||
const r = spanRects[i];
|
||||
if (
|
||||
cssX >= r.x &&
|
||||
cssX <= r.x + r.width &&
|
||||
cssY >= r.y &&
|
||||
cssY <= r.y + r.height
|
||||
) {
|
||||
return r.span;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface TooltipContent {
|
||||
serviceName: string;
|
||||
spanName: string;
|
||||
status: 'ok' | 'warning' | 'error';
|
||||
startMs: number;
|
||||
durationMs: number;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
spanColor: string;
|
||||
}
|
||||
|
||||
interface UseFlamegraphHoverArgs {
|
||||
canvasRef: RefObject<HTMLCanvasElement>;
|
||||
spanRectsRef: MutableRefObject<SpanRect[]>;
|
||||
traceMetadata: ITraceMetadata;
|
||||
viewStartTs: number;
|
||||
viewEndTs: number;
|
||||
isDraggingRef: MutableRefObject<boolean>;
|
||||
suppressClickRef: MutableRefObject<boolean>;
|
||||
onSpanClick: (spanId: string) => void;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
interface UseFlamegraphHoverResult {
|
||||
hoveredSpanId: string | null;
|
||||
setHoveredSpanId: Dispatch<SetStateAction<string | null>>;
|
||||
handleHoverMouseMove: (e: ReactMouseEvent) => void;
|
||||
handleHoverMouseLeave: () => void;
|
||||
handleClick: (e: ReactMouseEvent) => void;
|
||||
tooltipContent: TooltipContent | null;
|
||||
}
|
||||
|
||||
export function useFlamegraphHover(
|
||||
args: UseFlamegraphHoverArgs,
|
||||
): UseFlamegraphHoverResult {
|
||||
const {
|
||||
canvasRef,
|
||||
spanRectsRef,
|
||||
traceMetadata,
|
||||
viewStartTs,
|
||||
viewEndTs,
|
||||
isDraggingRef,
|
||||
suppressClickRef,
|
||||
onSpanClick,
|
||||
isDarkMode,
|
||||
} = args;
|
||||
|
||||
const [hoveredSpanId, setHoveredSpanId] = useState<string | null>(null);
|
||||
const [tooltipContent, setTooltipContent] = useState<TooltipContent | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const isZoomed =
|
||||
viewStartTs !== traceMetadata.startTime ||
|
||||
viewEndTs !== traceMetadata.endTime;
|
||||
|
||||
const updateCursor = useCallback(
|
||||
(canvas: HTMLCanvasElement, span: FlamegraphSpan | null): void => {
|
||||
if (span) {
|
||||
canvas.style.cursor = 'pointer';
|
||||
} else if (isZoomed) {
|
||||
canvas.style.cursor = 'grab';
|
||||
} else {
|
||||
canvas.style.cursor = 'default';
|
||||
}
|
||||
},
|
||||
[isZoomed],
|
||||
);
|
||||
|
||||
const handleHoverMouseMove = useCallback(
|
||||
(e: ReactMouseEvent): void => {
|
||||
if (isDraggingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointer = getCanvasPointer(canvas, e.clientX, e.clientY);
|
||||
if (!pointer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const span = findSpanAtPosition(
|
||||
pointer.cssX,
|
||||
pointer.cssY,
|
||||
spanRectsRef.current,
|
||||
);
|
||||
|
||||
if (span) {
|
||||
setHoveredSpanId(span.spanId);
|
||||
setTooltipContent({
|
||||
serviceName: span.serviceName || '',
|
||||
spanName: span.name || 'unknown',
|
||||
status: span.hasError ? 'error' : 'ok',
|
||||
startMs: span.timestamp - traceMetadata.startTime,
|
||||
durationMs: span.durationNano / 1e6,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
spanColor: getSpanColor({ span, isDarkMode }),
|
||||
});
|
||||
updateCursor(canvas, span);
|
||||
} else {
|
||||
setHoveredSpanId(null);
|
||||
setTooltipContent(null);
|
||||
updateCursor(canvas, null);
|
||||
}
|
||||
},
|
||||
[
|
||||
canvasRef,
|
||||
spanRectsRef,
|
||||
traceMetadata.startTime,
|
||||
isDraggingRef,
|
||||
updateCursor,
|
||||
isDarkMode,
|
||||
],
|
||||
);
|
||||
|
||||
const handleHoverMouseLeave = useCallback((): void => {
|
||||
setHoveredSpanId(null);
|
||||
setTooltipContent(null);
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
updateCursor(canvas, null);
|
||||
}
|
||||
}, [canvasRef, updateCursor]);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: ReactMouseEvent): void => {
|
||||
if (suppressClickRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointer = getCanvasPointer(canvas, e.clientX, e.clientY);
|
||||
if (!pointer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const span = findSpanAtPosition(
|
||||
pointer.cssX,
|
||||
pointer.cssY,
|
||||
spanRectsRef.current,
|
||||
);
|
||||
|
||||
if (span) {
|
||||
onSpanClick(span.spanId);
|
||||
}
|
||||
},
|
||||
[canvasRef, spanRectsRef, suppressClickRef, onSpanClick],
|
||||
);
|
||||
|
||||
return {
|
||||
hoveredSpanId,
|
||||
setHoveredSpanId,
|
||||
handleHoverMouseMove,
|
||||
handleHoverMouseLeave,
|
||||
handleClick,
|
||||
tooltipContent,
|
||||
};
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
import {
|
||||
Dispatch,
|
||||
MutableRefObject,
|
||||
RefObject,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
DEFAULT_ROW_HEIGHT,
|
||||
MAX_ROW_HEIGHT,
|
||||
MIN_ROW_HEIGHT,
|
||||
MIN_VISIBLE_SPAN_MS,
|
||||
PINCH_ZOOM_INTENSITY_H,
|
||||
PINCH_ZOOM_INTENSITY_V,
|
||||
SCROLL_ZOOM_INTENSITY_H,
|
||||
SCROLL_ZOOM_INTENSITY_V,
|
||||
} from '../constants';
|
||||
import { ITraceMetadata } from '../types';
|
||||
import { clamp } from '../utils';
|
||||
|
||||
interface UseFlamegraphZoomArgs {
|
||||
canvasRef: RefObject<HTMLCanvasElement>;
|
||||
traceMetadata: ITraceMetadata;
|
||||
viewStartRef: MutableRefObject<number>;
|
||||
viewEndRef: MutableRefObject<number>;
|
||||
rowHeightRef: MutableRefObject<number>;
|
||||
setViewStartTs: Dispatch<SetStateAction<number>>;
|
||||
setViewEndTs: Dispatch<SetStateAction<number>>;
|
||||
setRowHeight: Dispatch<SetStateAction<number>>;
|
||||
}
|
||||
|
||||
interface UseFlamegraphZoomResult {
|
||||
handleResetZoom: () => void;
|
||||
isOverFlamegraphRef: MutableRefObject<boolean>;
|
||||
}
|
||||
|
||||
function getCanvasPointer(
|
||||
canvasRef: RefObject<HTMLCanvasElement>,
|
||||
clientX: number,
|
||||
): { cssX: number; cssWidth: number } | null {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cssWidth = canvas.width / dpr;
|
||||
const cssX = (clientX - rect.left) * (cssWidth / rect.width);
|
||||
|
||||
return { cssX, cssWidth };
|
||||
}
|
||||
|
||||
export function useFlamegraphZoom(
|
||||
args: UseFlamegraphZoomArgs,
|
||||
): UseFlamegraphZoomResult {
|
||||
const {
|
||||
canvasRef,
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
rowHeightRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setRowHeight,
|
||||
} = args;
|
||||
|
||||
const isOverFlamegraphRef = useRef(false);
|
||||
const wheelDeltaRef = useRef(0);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const lastCursorXRef = useRef(0);
|
||||
const lastCssWidthRef = useRef(1);
|
||||
const lastIsPinchRef = useRef(false);
|
||||
const lastWheelClientXRef = useRef<number | null>(null);
|
||||
|
||||
// Prevent browser zoom when pinching over the flamegraph
|
||||
useEffect(() => {
|
||||
const onWheel = (e: WheelEvent): void => {
|
||||
if (isOverFlamegraphRef.current && e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('wheel', onWheel, { passive: false, capture: true });
|
||||
|
||||
return (): void => {
|
||||
window.removeEventListener('wheel', onWheel, {
|
||||
capture: true,
|
||||
} as EventListenerOptions);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const applyWheelZoom = useCallback(() => {
|
||||
rafRef.current = null;
|
||||
|
||||
const cssWidth = lastCssWidthRef.current || 1;
|
||||
const cursorX = lastCursorXRef.current;
|
||||
const fullSpanMs = traceMetadata.endTime - traceMetadata.startTime;
|
||||
|
||||
const oldStart = viewStartRef.current;
|
||||
const oldEnd = viewEndRef.current;
|
||||
const oldSpan = oldEnd - oldStart;
|
||||
|
||||
const deltaY = wheelDeltaRef.current;
|
||||
wheelDeltaRef.current = 0;
|
||||
if (deltaY === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const zoomH = lastIsPinchRef.current
|
||||
? PINCH_ZOOM_INTENSITY_H
|
||||
: SCROLL_ZOOM_INTENSITY_H;
|
||||
const zoomV = lastIsPinchRef.current
|
||||
? PINCH_ZOOM_INTENSITY_V
|
||||
: SCROLL_ZOOM_INTENSITY_V;
|
||||
|
||||
const factorH = Math.exp(deltaY * zoomH);
|
||||
const factorV = Math.exp(deltaY * zoomV);
|
||||
|
||||
// --- Horizontal zoom ---
|
||||
const desiredSpan = oldSpan * factorH;
|
||||
const minSpanMs = Math.max(
|
||||
MIN_VISIBLE_SPAN_MS,
|
||||
oldSpan / Math.max(cssWidth, 1),
|
||||
);
|
||||
const clampedSpan = clamp(desiredSpan, minSpanMs, fullSpanMs);
|
||||
|
||||
const cursorRatio = clamp(cursorX / cssWidth, 0, 1);
|
||||
const anchorTs = oldStart + cursorRatio * oldSpan;
|
||||
|
||||
let nextStart = anchorTs - cursorRatio * clampedSpan;
|
||||
nextStart = clamp(
|
||||
nextStart,
|
||||
traceMetadata.startTime,
|
||||
traceMetadata.endTime - clampedSpan,
|
||||
);
|
||||
const nextEnd = nextStart + clampedSpan;
|
||||
|
||||
// --- Vertical zoom (row height) ---
|
||||
const desiredRow = rowHeightRef.current * (1 / factorV);
|
||||
const nextRow = clamp(desiredRow, MIN_ROW_HEIGHT, MAX_ROW_HEIGHT);
|
||||
|
||||
// Write refs immediately so rapid wheel events read fresh values
|
||||
viewStartRef.current = nextStart;
|
||||
viewEndRef.current = nextEnd;
|
||||
rowHeightRef.current = nextRow;
|
||||
|
||||
setViewStartTs(nextStart);
|
||||
setViewEndTs(nextEnd);
|
||||
setRowHeight(nextRow);
|
||||
}, [
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
rowHeightRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setRowHeight,
|
||||
]);
|
||||
|
||||
// Native wheel listener on the canvas (passive: false for reliable preventDefault)
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
return (): void => {};
|
||||
}
|
||||
|
||||
const onWheel = (e: WheelEvent): void => {
|
||||
e.preventDefault();
|
||||
|
||||
const pointer = getCanvasPointer(canvasRef, e.clientX);
|
||||
if (!pointer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Flush accumulated delta if cursor moved significantly
|
||||
if (lastWheelClientXRef.current !== null) {
|
||||
const moved = Math.abs(e.clientX - lastWheelClientXRef.current);
|
||||
if (moved > 6) {
|
||||
wheelDeltaRef.current = 0;
|
||||
}
|
||||
}
|
||||
lastWheelClientXRef.current = e.clientX;
|
||||
|
||||
lastIsPinchRef.current = e.ctrlKey;
|
||||
lastCssWidthRef.current = pointer.cssWidth;
|
||||
lastCursorXRef.current = pointer.cssX;
|
||||
wheelDeltaRef.current += e.deltaY;
|
||||
|
||||
if (rafRef.current == null) {
|
||||
rafRef.current = requestAnimationFrame(applyWheelZoom);
|
||||
}
|
||||
};
|
||||
|
||||
canvas.addEventListener('wheel', onWheel, { passive: false });
|
||||
|
||||
return (): void => {
|
||||
canvas.removeEventListener('wheel', onWheel);
|
||||
};
|
||||
}, [canvasRef, applyWheelZoom]);
|
||||
|
||||
const handleResetZoom = useCallback(() => {
|
||||
viewStartRef.current = traceMetadata.startTime;
|
||||
viewEndRef.current = traceMetadata.endTime;
|
||||
rowHeightRef.current = DEFAULT_ROW_HEIGHT;
|
||||
|
||||
setViewStartTs(traceMetadata.startTime);
|
||||
setViewEndTs(traceMetadata.endTime);
|
||||
setRowHeight(DEFAULT_ROW_HEIGHT);
|
||||
}, [
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
rowHeightRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setRowHeight,
|
||||
]);
|
||||
|
||||
return { handleResetZoom, isOverFlamegraphRef };
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import {
|
||||
Dispatch,
|
||||
MutableRefObject,
|
||||
RefObject,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
import { MIN_VISIBLE_SPAN_MS } from '../constants';
|
||||
import { ITraceMetadata } from '../types';
|
||||
import { clamp, findSpanById, getFlamegraphRowMetrics } from '../utils';
|
||||
|
||||
interface UseScrollToSpanArgs {
|
||||
firstSpanAtFetchLevel: string;
|
||||
spans: FlamegraphSpan[][];
|
||||
traceMetadata: ITraceMetadata;
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
viewStartRef: MutableRefObject<number>;
|
||||
viewEndRef: MutableRefObject<number>;
|
||||
scrollTopRef: MutableRefObject<number>;
|
||||
rowHeight: number;
|
||||
setViewStartTs: Dispatch<SetStateAction<number>>;
|
||||
setViewEndTs: Dispatch<SetStateAction<number>>;
|
||||
setScrollTop: Dispatch<SetStateAction<number>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* When firstSpanAtFetchLevel (from URL spanId) changes, scroll and zoom the
|
||||
* flamegraph so the selected span is centered in view.
|
||||
*/
|
||||
export function useScrollToSpan(args: UseScrollToSpanArgs): void {
|
||||
const {
|
||||
firstSpanAtFetchLevel,
|
||||
spans,
|
||||
traceMetadata,
|
||||
containerRef,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
scrollTopRef,
|
||||
rowHeight,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setScrollTop,
|
||||
} = args;
|
||||
|
||||
useEffect(() => {
|
||||
if (!firstSpanAtFetchLevel || spans.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = findSpanById(spans, firstSpanAtFetchLevel);
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { span, levelIndex } = result;
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metrics = getFlamegraphRowMetrics(rowHeight);
|
||||
const viewportHeight = container.clientHeight;
|
||||
const totalHeight = spans.length * metrics.ROW_HEIGHT;
|
||||
const maxScroll = Math.max(0, totalHeight - viewportHeight);
|
||||
|
||||
// Vertical: center the span's row in the viewport
|
||||
const targetScrollTop = clamp(
|
||||
levelIndex * metrics.ROW_HEIGHT -
|
||||
viewportHeight / 2 +
|
||||
metrics.ROW_HEIGHT / 2,
|
||||
0,
|
||||
maxScroll,
|
||||
);
|
||||
|
||||
// Horizontal: zoom to span with padding (2x span duration), center it
|
||||
const spanStartMs = span.timestamp;
|
||||
const spanEndMs = span.timestamp + span.durationNano / 1e6;
|
||||
const spanDurationMs = spanEndMs - spanStartMs;
|
||||
const spanCenterMs = (spanStartMs + spanEndMs) / 2;
|
||||
|
||||
const visibleWindowMs = Math.max(spanDurationMs * 2, MIN_VISIBLE_SPAN_MS);
|
||||
const fullSpanMs = traceMetadata.endTime - traceMetadata.startTime;
|
||||
const clampedWindow = clamp(visibleWindowMs, MIN_VISIBLE_SPAN_MS, fullSpanMs);
|
||||
|
||||
let targetViewStart = spanCenterMs - clampedWindow / 2;
|
||||
let targetViewEnd = spanCenterMs + clampedWindow / 2;
|
||||
|
||||
targetViewStart = clamp(
|
||||
targetViewStart,
|
||||
traceMetadata.startTime,
|
||||
traceMetadata.endTime - clampedWindow,
|
||||
);
|
||||
targetViewEnd = targetViewStart + clampedWindow;
|
||||
|
||||
// Apply immediately (instant jump)
|
||||
viewStartRef.current = targetViewStart;
|
||||
viewEndRef.current = targetViewEnd;
|
||||
scrollTopRef.current = targetScrollTop;
|
||||
|
||||
setViewStartTs(targetViewStart);
|
||||
setViewEndTs(targetViewEnd);
|
||||
setScrollTop(targetScrollTop);
|
||||
}, [
|
||||
firstSpanAtFetchLevel,
|
||||
spans,
|
||||
traceMetadata,
|
||||
containerRef,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
scrollTopRef,
|
||||
rowHeight,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setScrollTop,
|
||||
]);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
import { computeVisualLayout, VisualLayout } from '../computeVisualLayout';
|
||||
import { LayoutWorkerResponse } from '../visualLayoutWorkerTypes';
|
||||
|
||||
const EMPTY_LAYOUT: VisualLayout = {
|
||||
visualRows: [],
|
||||
spanToVisualRow: {},
|
||||
connectors: [],
|
||||
totalVisualRows: 0,
|
||||
};
|
||||
|
||||
function computeLayoutOrEmpty(spans: FlamegraphSpan[][]): VisualLayout {
|
||||
return spans.length ? computeVisualLayout(spans) : EMPTY_LAYOUT;
|
||||
}
|
||||
|
||||
function createLayoutWorker(): Worker {
|
||||
return new Worker(new URL('../visualLayout.worker.ts', import.meta.url), {
|
||||
type: 'module',
|
||||
});
|
||||
}
|
||||
|
||||
export function useVisualLayoutWorker(
|
||||
spans: FlamegraphSpan[][],
|
||||
): { layout: VisualLayout; isComputing: boolean } {
|
||||
const [layout, setLayout] = useState<VisualLayout>(EMPTY_LAYOUT);
|
||||
const [isComputing, setIsComputing] = useState(false);
|
||||
const workerRef = useRef<Worker | null>(null);
|
||||
const requestIdRef = useRef(0);
|
||||
const fallbackRef = useRef(typeof Worker === 'undefined');
|
||||
|
||||
// Effect: post message to worker when spans change
|
||||
useEffect(() => {
|
||||
if (fallbackRef.current) {
|
||||
setLayout(computeLayoutOrEmpty(spans));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!workerRef.current) {
|
||||
try {
|
||||
workerRef.current = createLayoutWorker();
|
||||
} catch {
|
||||
fallbackRef.current = true;
|
||||
setLayout(computeLayoutOrEmpty(spans));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!spans.length) {
|
||||
setLayout(EMPTY_LAYOUT);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentId = ++requestIdRef.current;
|
||||
setIsComputing(true);
|
||||
|
||||
const worker = workerRef.current;
|
||||
|
||||
const onMessage = (e: MessageEvent<LayoutWorkerResponse>): void => {
|
||||
if (e.data.requestId !== requestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
if (e.data.type === 'result') {
|
||||
setLayout(e.data.layout);
|
||||
} else {
|
||||
setLayout(computeVisualLayout(spans));
|
||||
}
|
||||
setIsComputing(false);
|
||||
};
|
||||
|
||||
const onError = (): void => {
|
||||
if (requestIdRef.current === currentId) {
|
||||
setLayout(computeVisualLayout(spans));
|
||||
setIsComputing(false);
|
||||
}
|
||||
};
|
||||
|
||||
worker.addEventListener('message', onMessage);
|
||||
worker.addEventListener('error', onError);
|
||||
worker.postMessage({ type: 'compute', requestId: currentId, spans });
|
||||
|
||||
return (): void => {
|
||||
worker.removeEventListener('message', onMessage);
|
||||
worker.removeEventListener('error', onError);
|
||||
};
|
||||
}, [spans]);
|
||||
|
||||
// Cleanup worker on unmount
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
workerRef.current?.terminate();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { layout, isComputing };
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
export interface ITraceMetadata {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
export interface FlamegraphCanvasProps {
|
||||
spans: FlamegraphSpan[][];
|
||||
firstSpanAtFetchLevel: string;
|
||||
setFirstSpanAtFetchLevel: Dispatch<SetStateAction<string>>;
|
||||
onSpanClick: (spanId: string) => void;
|
||||
traceMetadata: ITraceMetadata;
|
||||
}
|
||||
|
||||
export interface SpanRect {
|
||||
span: FlamegraphSpan;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
import {
|
||||
DASHED_BORDER_LINE_DASH,
|
||||
EVENT_DOT_SIZE_RATIO,
|
||||
LABEL_FONT,
|
||||
LABEL_PADDING_X,
|
||||
MAX_EVENT_DOT_SIZE,
|
||||
MAX_SPAN_BAR_HEIGHT,
|
||||
MIN_EVENT_DOT_SIZE,
|
||||
MIN_SPAN_BAR_HEIGHT,
|
||||
MIN_WIDTH_FOR_NAME,
|
||||
MIN_WIDTH_FOR_NAME_AND_DURATION,
|
||||
SPAN_BAR_HEIGHT_RATIO,
|
||||
} from './constants';
|
||||
import { SpanRect } from './types';
|
||||
|
||||
export function clamp(v: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, v));
|
||||
}
|
||||
|
||||
/** Create diagonal stripe pattern for selected/hovered span (repeating-linear-gradient -45deg style). */
|
||||
function createStripePattern(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
color: string,
|
||||
): CanvasPattern | null {
|
||||
const size = 20;
|
||||
const patternCanvas = document.createElement('canvas');
|
||||
patternCanvas.width = size;
|
||||
patternCanvas.height = size;
|
||||
const pCtx = patternCanvas.getContext('2d');
|
||||
if (!pCtx) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Diagonal stripes at -45deg: 10px transparent, 10px colored (0.04 opacity), repeat
|
||||
pCtx.globalAlpha = 0.04;
|
||||
pCtx.strokeStyle = color;
|
||||
pCtx.lineWidth = 10;
|
||||
pCtx.lineCap = 'butt';
|
||||
for (let i = -size; i < size * 2; i += size) {
|
||||
pCtx.beginPath();
|
||||
pCtx.moveTo(i + size, 0);
|
||||
pCtx.lineTo(i, size);
|
||||
pCtx.stroke();
|
||||
}
|
||||
pCtx.globalAlpha = 1;
|
||||
|
||||
return ctx.createPattern(patternCanvas, 'repeat');
|
||||
}
|
||||
|
||||
export function findSpanById(
|
||||
spans: FlamegraphSpan[][],
|
||||
spanId: string,
|
||||
): { span: FlamegraphSpan; levelIndex: number } | null {
|
||||
for (let levelIndex = 0; levelIndex < spans.length; levelIndex++) {
|
||||
const span = spans[levelIndex]?.find((s) => s.spanId === spanId);
|
||||
if (span) {
|
||||
return { span, levelIndex };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface FlamegraphRowMetrics {
|
||||
ROW_HEIGHT: number;
|
||||
SPAN_BAR_HEIGHT: number;
|
||||
SPAN_BAR_Y_OFFSET: number;
|
||||
EVENT_DOT_SIZE: number;
|
||||
}
|
||||
|
||||
export function getFlamegraphRowMetrics(
|
||||
rowHeight: number,
|
||||
): FlamegraphRowMetrics {
|
||||
const spanBarHeight = clamp(
|
||||
Math.round(rowHeight * SPAN_BAR_HEIGHT_RATIO),
|
||||
MIN_SPAN_BAR_HEIGHT,
|
||||
MAX_SPAN_BAR_HEIGHT,
|
||||
);
|
||||
const spanBarYOffset = Math.floor((rowHeight - spanBarHeight) / 2);
|
||||
const eventDotSize = clamp(
|
||||
Math.round(spanBarHeight * EVENT_DOT_SIZE_RATIO),
|
||||
MIN_EVENT_DOT_SIZE,
|
||||
MAX_EVENT_DOT_SIZE,
|
||||
);
|
||||
|
||||
return {
|
||||
ROW_HEIGHT: rowHeight,
|
||||
SPAN_BAR_HEIGHT: spanBarHeight,
|
||||
SPAN_BAR_Y_OFFSET: spanBarYOffset,
|
||||
EVENT_DOT_SIZE: eventDotSize,
|
||||
};
|
||||
}
|
||||
|
||||
interface GetSpanColorArgs {
|
||||
span: FlamegraphSpan;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export function getSpanColor(args: GetSpanColorArgs): string {
|
||||
const { span, isDarkMode } = args;
|
||||
let color = generateColor(span.serviceName, themeColors.traceDetailColorsV3);
|
||||
|
||||
if (span.hasError) {
|
||||
color = isDarkMode ? 'rgb(239, 68, 68)' : 'rgb(220, 38, 38)';
|
||||
}
|
||||
|
||||
return color;
|
||||
}
|
||||
|
||||
interface DrawEventDotArgs {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
x: number;
|
||||
y: number;
|
||||
isError: boolean;
|
||||
isDarkMode: boolean;
|
||||
eventDotSize: number;
|
||||
}
|
||||
|
||||
export function drawEventDot(args: DrawEventDotArgs): void {
|
||||
const { ctx, x, y, isError, isDarkMode, eventDotSize } = args;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.rotate(Math.PI / 4);
|
||||
|
||||
if (isError) {
|
||||
ctx.fillStyle = isDarkMode ? 'rgb(239, 68, 68)' : 'rgb(220, 38, 38)';
|
||||
ctx.strokeStyle = isDarkMode ? 'rgb(185, 28, 28)' : 'rgb(153, 27, 27)';
|
||||
} else {
|
||||
ctx.fillStyle = isDarkMode ? 'rgb(14, 165, 233)' : 'rgb(6, 182, 212)';
|
||||
ctx.strokeStyle = isDarkMode ? 'rgb(2, 132, 199)' : 'rgb(8, 145, 178)';
|
||||
}
|
||||
|
||||
ctx.lineWidth = 1;
|
||||
const half = eventDotSize / 2;
|
||||
ctx.fillRect(-half, -half, eventDotSize, eventDotSize);
|
||||
ctx.strokeRect(-half, -half, eventDotSize, eventDotSize);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
interface DrawSpanBarArgs {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
span: FlamegraphSpan;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
levelIndex: number;
|
||||
spanRectsArray: SpanRect[];
|
||||
color: string;
|
||||
isDarkMode: boolean;
|
||||
metrics: FlamegraphRowMetrics;
|
||||
selectedSpanId?: string | null;
|
||||
hoveredSpanId?: string | null;
|
||||
}
|
||||
|
||||
export function drawSpanBar(args: DrawSpanBarArgs): void {
|
||||
const {
|
||||
ctx,
|
||||
span,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
levelIndex,
|
||||
spanRectsArray,
|
||||
color,
|
||||
isDarkMode,
|
||||
metrics,
|
||||
selectedSpanId,
|
||||
hoveredSpanId,
|
||||
} = args;
|
||||
|
||||
const spanY = y + metrics.SPAN_BAR_Y_OFFSET;
|
||||
const isSelected = selectedSpanId === span.spanId;
|
||||
const isHovered = hoveredSpanId === span.spanId;
|
||||
const isSelectedOrHovered = isSelected || isHovered;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, spanY, width, metrics.SPAN_BAR_HEIGHT, 2);
|
||||
|
||||
if (isSelectedOrHovered) {
|
||||
// Diagonal stripe pattern (repeating-linear-gradient -45deg style) + border in span color
|
||||
const pattern = createStripePattern(ctx, color);
|
||||
if (pattern) {
|
||||
ctx.fillStyle = pattern;
|
||||
ctx.fill();
|
||||
}
|
||||
if (isSelected) {
|
||||
ctx.setLineDash(DASHED_BORDER_LINE_DASH);
|
||||
}
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = isSelected ? 2 : 1;
|
||||
ctx.stroke();
|
||||
if (isSelected) {
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
} else {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
spanRectsArray.push({
|
||||
span,
|
||||
x,
|
||||
y: spanY,
|
||||
width,
|
||||
height: metrics.SPAN_BAR_HEIGHT,
|
||||
level: levelIndex,
|
||||
});
|
||||
|
||||
span.event?.forEach((event) => {
|
||||
const spanDurationMs = span.durationNano / 1e6;
|
||||
if (spanDurationMs <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventTimeMs = event.timeUnixNano / 1e6;
|
||||
const eventOffsetPercent =
|
||||
((eventTimeMs - span.timestamp) / spanDurationMs) * 100;
|
||||
const clampedOffset = clamp(eventOffsetPercent, 1, 99);
|
||||
const eventX = x + (clampedOffset / 100) * width;
|
||||
const eventY = spanY + metrics.SPAN_BAR_HEIGHT / 2;
|
||||
|
||||
drawEventDot({
|
||||
ctx,
|
||||
x: eventX,
|
||||
y: eventY,
|
||||
isError: event.isError,
|
||||
isDarkMode,
|
||||
eventDotSize: metrics.EVENT_DOT_SIZE,
|
||||
});
|
||||
});
|
||||
|
||||
drawSpanLabel({
|
||||
ctx,
|
||||
span,
|
||||
x,
|
||||
y: spanY,
|
||||
width,
|
||||
color,
|
||||
isSelectedOrHovered,
|
||||
isDarkMode,
|
||||
spanBarHeight: metrics.SPAN_BAR_HEIGHT,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDuration(durationNano: number): string {
|
||||
const durationMs = durationNano / 1e6;
|
||||
const { time, timeUnitName } = convertTimeToRelevantUnit(durationMs);
|
||||
return `${parseFloat(time.toFixed(2))}${timeUnitName}`;
|
||||
}
|
||||
|
||||
interface DrawSpanLabelArgs {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
span: FlamegraphSpan;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
color: string;
|
||||
isSelectedOrHovered: boolean;
|
||||
isDarkMode: boolean;
|
||||
spanBarHeight: number;
|
||||
}
|
||||
|
||||
function drawSpanLabel(args: DrawSpanLabelArgs): void {
|
||||
const {
|
||||
ctx,
|
||||
span,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
color,
|
||||
isSelectedOrHovered,
|
||||
isDarkMode,
|
||||
spanBarHeight,
|
||||
} = args;
|
||||
|
||||
if (width < MIN_WIDTH_FOR_NAME) {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = span.name;
|
||||
|
||||
ctx.save();
|
||||
|
||||
// Clip text to span bar bounds
|
||||
ctx.beginPath();
|
||||
ctx.rect(x, y, width, spanBarHeight);
|
||||
ctx.clip();
|
||||
|
||||
ctx.font = LABEL_FONT;
|
||||
ctx.fillStyle = isSelectedOrHovered
|
||||
? color
|
||||
: isDarkMode
|
||||
? 'rgba(0, 0, 0, 0.9)'
|
||||
: 'rgba(255, 255, 255, 0.9)';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const textY = y + spanBarHeight / 2;
|
||||
const leftX = x + LABEL_PADDING_X;
|
||||
const rightX = x + width - LABEL_PADDING_X;
|
||||
const availableWidth = width - LABEL_PADDING_X * 2;
|
||||
|
||||
if (width >= MIN_WIDTH_FOR_NAME_AND_DURATION) {
|
||||
const duration = formatDuration(span.durationNano);
|
||||
const durationWidth = ctx.measureText(duration).width;
|
||||
const minGap = 6;
|
||||
const nameSpace = availableWidth - durationWidth - minGap;
|
||||
|
||||
// Duration right-aligned
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(duration, rightX, textY);
|
||||
|
||||
// Name left-aligned, truncated to fit remaining space
|
||||
if (nameSpace > 20) {
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(truncateText(ctx, name, nameSpace), leftX, textY);
|
||||
}
|
||||
} else {
|
||||
// Name only, truncated to fit
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(truncateText(ctx, name, availableWidth), leftX, textY);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function truncateText(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
maxWidth: number,
|
||||
): string {
|
||||
const ellipsis = '...';
|
||||
const ellipsisWidth = ctx.measureText(ellipsis).width;
|
||||
|
||||
if (ctx.measureText(text).width <= maxWidth) {
|
||||
return text;
|
||||
}
|
||||
|
||||
let lo = 0;
|
||||
let hi = text.length;
|
||||
while (lo < hi) {
|
||||
const mid = Math.ceil((lo + hi) / 2);
|
||||
if (ctx.measureText(text.slice(0, mid)).width + ellipsisWidth <= maxWidth) {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return lo > 0 ? `${text.slice(0, lo)}${ellipsis}` : ellipsis;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
/// <reference lib="webworker" />
|
||||
import { computeVisualLayout } from './computeVisualLayout';
|
||||
import {
|
||||
LayoutWorkerRequest,
|
||||
LayoutWorkerResponse,
|
||||
} from './visualLayoutWorkerTypes';
|
||||
|
||||
self.onmessage = (event: MessageEvent<LayoutWorkerRequest>): void => {
|
||||
const { requestId, spans } = event.data;
|
||||
try {
|
||||
const layout = computeVisualLayout(spans);
|
||||
const response: LayoutWorkerResponse = {
|
||||
type: 'result',
|
||||
requestId,
|
||||
layout,
|
||||
};
|
||||
self.postMessage(response);
|
||||
} catch (err) {
|
||||
const response: LayoutWorkerResponse = {
|
||||
type: 'error',
|
||||
requestId,
|
||||
message: String(err),
|
||||
};
|
||||
self.postMessage(response);
|
||||
}
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
import { VisualLayout } from './computeVisualLayout';
|
||||
|
||||
export interface LayoutWorkerRequest {
|
||||
type: 'compute';
|
||||
requestId: number;
|
||||
spans: FlamegraphSpan[][];
|
||||
}
|
||||
|
||||
export type LayoutWorkerResponse =
|
||||
| { type: 'result'; requestId: number; layout: VisualLayout }
|
||||
| { type: 'error'; requestId: number; message: string };
|
||||
@@ -1,297 +0,0 @@
|
||||
// Modal base styles
|
||||
.add-span-to-funnel-modal {
|
||||
&__loading-spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 400px;
|
||||
}
|
||||
&-container {
|
||||
.ant-modal {
|
||||
&-content,
|
||||
&-header {
|
||||
background: var(--bg-ink-500);
|
||||
}
|
||||
|
||||
&-header {
|
||||
border-bottom: none;
|
||||
|
||||
.ant-modal-title {
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
|
||||
&-body {
|
||||
padding: 14px 16px !important;
|
||||
padding-bottom: 0 !important;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
&-footer {
|
||||
margin-top: 0;
|
||||
background: var(--bg-ink-400);
|
||||
border-top: 1px solid var(--bg-slate-500);
|
||||
padding: 16px !important;
|
||||
.add-span-to-funnel-modal {
|
||||
&__save-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
width: 135px;
|
||||
|
||||
.ant-btn-icon {
|
||||
display: flex;
|
||||
}
|
||||
&:disabled {
|
||||
color: var(--bg-vanilla-400);
|
||||
.ant-btn-icon {
|
||||
svg {
|
||||
stroke: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&__discard-button {
|
||||
background: var(--bg-slate-500);
|
||||
}
|
||||
}
|
||||
.ant-btn {
|
||||
border-radius: 2px;
|
||||
padding: 4px 8px;
|
||||
margin: 0 !important;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main modal styles
|
||||
.add-span-to-funnel-modal {
|
||||
// Common button styles
|
||||
%button-base {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-family: Inter;
|
||||
}
|
||||
|
||||
// Details view styles
|
||||
&--details {
|
||||
.traces-funnel-details {
|
||||
height: unset;
|
||||
|
||||
&__steps-config {
|
||||
width: unset;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.funnel-step-wrapper {
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.steps-content {
|
||||
max-height: 500px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search section
|
||||
&__search {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
align-items: center;
|
||||
|
||||
&-input {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
background: var(--bg-ink-300);
|
||||
|
||||
.ant-input-prefix {
|
||||
height: 18px;
|
||||
margin-inline-end: 6px;
|
||||
svg {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
&,
|
||||
input {
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
font-weight: 400;
|
||||
background: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--bg-vanilla-400);
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create button
|
||||
&__create-button {
|
||||
@extend %button-base;
|
||||
width: 153px;
|
||||
padding: 4px 8px;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-slate-500);
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
.funnel-item {
|
||||
padding: 8px 8px 12px 16px;
|
||||
&,
|
||||
&:first-child {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
&__header {
|
||||
line-height: 20px;
|
||||
}
|
||||
&__details {
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
// List section
|
||||
&__list {
|
||||
max-height: 400px;
|
||||
overflow-y: scroll;
|
||||
.funnels-empty {
|
||||
&__content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.funnels-list {
|
||||
gap: 8px;
|
||||
|
||||
.funnel-item {
|
||||
padding: 8px 16px 12px;
|
||||
|
||||
&__details {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__spinner {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
// Back button
|
||||
&__back-button {
|
||||
@extend %button-base;
|
||||
gap: 6px;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
// Details section
|
||||
&__details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
|
||||
.funnel-configuration__steps {
|
||||
padding: 0;
|
||||
|
||||
.funnel-step {
|
||||
&__content .filters__service-and-span .ant-select {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
&__footer .error {
|
||||
width: 25%;
|
||||
}
|
||||
}
|
||||
|
||||
.inter-step-config {
|
||||
width: calc(100% - 104px);
|
||||
}
|
||||
}
|
||||
.funnel-item__actions-popover {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Light mode styles
|
||||
.lightMode {
|
||||
.add-span-to-funnel-modal-container {
|
||||
.ant-modal {
|
||||
&-content,
|
||||
&-header {
|
||||
background: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
&-title {
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
|
||||
&-footer {
|
||||
border-top-color: var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-100);
|
||||
.add-span-to-funnel-modal__discard-button {
|
||||
background: var(--bg-vanilla-200);
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add-span-to-funnel-modal {
|
||||
&__search-input {
|
||||
background: var(--bg-vanilla-100);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-500);
|
||||
|
||||
input {
|
||||
color: var(--bg-ink-500);
|
||||
background: var(--bg-vanilla-100);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__create-button {
|
||||
background: var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
&__back-button {
|
||||
color: var(--bg-ink-500);
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
&__details h3 {
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
import { ChangeEvent, useEffect, useMemo, useState } from 'react';
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { Button, Input, Spin } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import SignozModal from 'components/SignozModal/SignozModal';
|
||||
import {
|
||||
useFunnelDetails,
|
||||
useFunnelsList,
|
||||
} from 'hooks/TracesFunnels/useFunnels';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { ArrowLeft, Check, Plus, Search } from 'lucide-react';
|
||||
import FunnelConfiguration from 'pages/TracesFunnelDetails/components/FunnelConfiguration/FunnelConfiguration';
|
||||
import { TracesFunnelsContentRenderer } from 'pages/TracesFunnels';
|
||||
import CreateFunnel from 'pages/TracesFunnels/components/CreateFunnel/CreateFunnel';
|
||||
import { FunnelListItem } from 'pages/TracesFunnels/components/FunnelsList/FunnelsList';
|
||||
import {
|
||||
FunnelProvider,
|
||||
useFunnelContext,
|
||||
} from 'pages/TracesFunnels/FunnelContext';
|
||||
import { filterFunnelsByQuery } from 'pages/TracesFunnels/utils';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { FunnelData } from 'types/api/traceFunnels';
|
||||
|
||||
import './AddSpanToFunnelModal.styles.scss';
|
||||
|
||||
enum ModalView {
|
||||
LIST = 'list',
|
||||
DETAILS = 'details',
|
||||
}
|
||||
|
||||
function FunnelDetailsView({
|
||||
funnel,
|
||||
span,
|
||||
triggerAutoSave,
|
||||
showNotifications,
|
||||
onChangesDetected,
|
||||
triggerDiscard,
|
||||
}: {
|
||||
funnel: FunnelData;
|
||||
span: Span;
|
||||
triggerAutoSave: boolean;
|
||||
showNotifications: boolean;
|
||||
onChangesDetected: (hasChanges: boolean) => void;
|
||||
triggerDiscard: boolean;
|
||||
}): JSX.Element {
|
||||
const { handleRestoreSteps, steps } = useFunnelContext();
|
||||
|
||||
// Track changes between current steps and original steps
|
||||
useEffect(() => {
|
||||
const hasChanges = !isEqual(steps, funnel.steps);
|
||||
if (onChangesDetected) {
|
||||
onChangesDetected(hasChanges);
|
||||
}
|
||||
}, [steps, funnel.steps, onChangesDetected]);
|
||||
|
||||
// Handle discard when triggered from parent
|
||||
useEffect(() => {
|
||||
if (triggerDiscard && funnel.steps) {
|
||||
handleRestoreSteps(funnel.steps);
|
||||
}
|
||||
}, [triggerDiscard, funnel.steps, handleRestoreSteps]);
|
||||
|
||||
return (
|
||||
<div className="add-span-to-funnel-modal__details">
|
||||
<FunnelListItem
|
||||
funnel={funnel}
|
||||
shouldRedirectToTracesListOnDeleteSuccess={false}
|
||||
isSpanDetailsPage
|
||||
/>
|
||||
<FunnelConfiguration
|
||||
funnel={funnel}
|
||||
isTraceDetailsPage
|
||||
span={span}
|
||||
triggerAutoSave={triggerAutoSave}
|
||||
showNotifications={showNotifications}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AddSpanToFunnelModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
span: Span;
|
||||
}
|
||||
|
||||
function AddSpanToFunnelModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
span,
|
||||
}: AddSpanToFunnelModalProps): JSX.Element {
|
||||
const [activeView, setActiveView] = useState<ModalView>(ModalView.LIST);
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [selectedFunnelId, setSelectedFunnelId] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState<boolean>(false);
|
||||
const [triggerSave, setTriggerSave] = useState<boolean>(false);
|
||||
const [isUnsavedChanges, setIsUnsavedChanges] = useState<boolean>(false);
|
||||
const [triggerDiscard, setTriggerDiscard] = useState<boolean>(false);
|
||||
const [isCreatedFromSpan, setIsCreatedFromSpan] = useState<boolean>(false);
|
||||
|
||||
const handleSearch = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setSearchQuery(e.target.value);
|
||||
};
|
||||
|
||||
const { data, isLoading, isError, isFetching } = useFunnelsList();
|
||||
|
||||
const filteredData = useMemo(
|
||||
() =>
|
||||
filterFunnelsByQuery(data?.payload || [], searchQuery).sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||
),
|
||||
[data?.payload, searchQuery],
|
||||
);
|
||||
|
||||
const {
|
||||
data: funnelDetails,
|
||||
isLoading: isFunnelDetailsLoading,
|
||||
isFetching: isFunnelDetailsFetching,
|
||||
} = useFunnelDetails({
|
||||
funnelId: selectedFunnelId,
|
||||
});
|
||||
|
||||
const handleFunnelClick = (funnel: FunnelData): void => {
|
||||
setSelectedFunnelId(funnel.funnel_id);
|
||||
setActiveView(ModalView.DETAILS);
|
||||
setIsCreatedFromSpan(false);
|
||||
};
|
||||
|
||||
const handleBack = (): void => {
|
||||
setActiveView(ModalView.LIST);
|
||||
setSelectedFunnelId(undefined);
|
||||
setIsUnsavedChanges(false);
|
||||
setTriggerSave(false);
|
||||
setIsCreatedFromSpan(false);
|
||||
};
|
||||
|
||||
const handleCreateNewClick = (): void => {
|
||||
setIsCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveFunnel = (): void => {
|
||||
setTriggerSave(true);
|
||||
// Reset trigger after a brief moment to allow the save to be processed
|
||||
setTimeout(() => {
|
||||
setTriggerSave(false);
|
||||
onClose();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleDiscard = (): void => {
|
||||
setTriggerDiscard(true);
|
||||
// Reset trigger after a brief moment
|
||||
setTimeout(() => {
|
||||
setTriggerDiscard(false);
|
||||
onClose();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const renderListView = (): JSX.Element => (
|
||||
<div className="add-span-to-funnel-modal">
|
||||
{!!filteredData?.length && (
|
||||
<div className="add-span-to-funnel-modal__search">
|
||||
<Input
|
||||
className="add-span-to-funnel-modal__search-input"
|
||||
placeholder="Search by name, description, or tags..."
|
||||
prefix={<Search size={12} />}
|
||||
value={searchQuery}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="add-span-to-funnel-modal__list">
|
||||
<OverlayScrollbar>
|
||||
<TracesFunnelsContentRenderer
|
||||
isError={isError}
|
||||
isLoading={isLoading || isFetching}
|
||||
data={filteredData || []}
|
||||
onCreateFunnel={handleCreateNewClick}
|
||||
onFunnelClick={(funnel: FunnelData): void => handleFunnelClick(funnel)}
|
||||
shouldRedirectToTracesListOnDeleteSuccess={false}
|
||||
/>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
<CreateFunnel
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={(funnelId): void => {
|
||||
if (funnelId) {
|
||||
setSelectedFunnelId(funnelId);
|
||||
setActiveView(ModalView.DETAILS);
|
||||
setIsCreatedFromSpan(true);
|
||||
}
|
||||
setIsCreateModalOpen(false);
|
||||
}}
|
||||
redirectToDetails={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderDetailsView = ({ span }: { span: Span }): JSX.Element => (
|
||||
<div className="add-span-to-funnel-modal add-span-to-funnel-modal--details">
|
||||
<Button
|
||||
type="text"
|
||||
className="add-span-to-funnel-modal__back-button"
|
||||
onClick={handleBack}
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
All funnels
|
||||
</Button>
|
||||
<div className="traces-funnel-details">
|
||||
<div className="traces-funnel-details__steps-config">
|
||||
<Spin
|
||||
className="add-span-to-funnel-modal__loading-spinner"
|
||||
spinning={isFunnelDetailsLoading || isFunnelDetailsFetching}
|
||||
indicator={<LoadingOutlined spin />}
|
||||
>
|
||||
{selectedFunnelId && funnelDetails?.payload && (
|
||||
<FunnelProvider
|
||||
funnelId={selectedFunnelId}
|
||||
hasSingleStep={isCreatedFromSpan}
|
||||
>
|
||||
<FunnelDetailsView
|
||||
funnel={funnelDetails.payload}
|
||||
span={span}
|
||||
triggerAutoSave={triggerSave}
|
||||
showNotifications
|
||||
onChangesDetected={setIsUnsavedChanges}
|
||||
triggerDiscard={triggerDiscard}
|
||||
/>
|
||||
</FunnelProvider>
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<SignozModal
|
||||
open={isOpen}
|
||||
onCancel={onClose}
|
||||
width={570}
|
||||
title="Add span to funnel"
|
||||
className={cx('add-span-to-funnel-modal-container', {
|
||||
'add-span-to-funnel-modal-container--details':
|
||||
activeView === ModalView.DETAILS,
|
||||
})}
|
||||
footer={
|
||||
activeView === ModalView.DETAILS
|
||||
? [
|
||||
<Button
|
||||
type="default"
|
||||
key="discard"
|
||||
onClick={handleDiscard}
|
||||
className="add-span-to-funnel-modal__discard-button"
|
||||
disabled={!isUnsavedChanges}
|
||||
>
|
||||
Discard
|
||||
</Button>,
|
||||
<Button
|
||||
key="save"
|
||||
type="primary"
|
||||
className="add-span-to-funnel-modal__save-button"
|
||||
onClick={handleSaveFunnel}
|
||||
disabled={!isUnsavedChanges}
|
||||
icon={<Check size={14} color="var(--bg-vanilla-100)" />}
|
||||
>
|
||||
Save Funnel
|
||||
</Button>,
|
||||
]
|
||||
: [
|
||||
<Button
|
||||
key="create"
|
||||
type="default"
|
||||
className="add-span-to-funnel-modal__create-button"
|
||||
onClick={handleCreateNewClick}
|
||||
icon={<Plus size={14} />}
|
||||
>
|
||||
Create new funnel
|
||||
</Button>,
|
||||
]
|
||||
}
|
||||
>
|
||||
{activeView === ModalView.LIST
|
||||
? renderListView()
|
||||
: renderDetailsView({ span })}
|
||||
</SignozModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddSpanToFunnelModal;
|
||||
@@ -1,39 +0,0 @@
|
||||
.span-line-action-buttons {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
top: 50%;
|
||||
right: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-400);
|
||||
|
||||
.ant-btn-default {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 9px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
|
||||
&.active-tab {
|
||||
background-color: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
|
||||
.copy-span-btn {
|
||||
border-color: var(--bg-slate-400) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.span-line-action-buttons {
|
||||
border: 1px solid var(--bg-vanilla-400);
|
||||
background: var(--bg-vanilla-400);
|
||||
|
||||
.copy-span-btn {
|
||||
border-color: var(--bg-vanilla-400) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
|
||||
import { render } from 'tests/test-utils';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import SpanLineActionButtons from '../index';
|
||||
|
||||
// Mock the useCopySpanLink hook
|
||||
jest.mock('hooks/trace/useCopySpanLink');
|
||||
|
||||
const mockSpan: Span = {
|
||||
spanId: 'test-span-id',
|
||||
name: 'test-span',
|
||||
serviceName: 'test-service',
|
||||
durationNano: 1000,
|
||||
timestamp: 1234567890,
|
||||
rootSpanId: 'test-root-span-id',
|
||||
parentSpanId: 'test-parent-span-id',
|
||||
traceId: 'test-trace-id',
|
||||
hasError: false,
|
||||
kind: 0,
|
||||
references: [],
|
||||
tagMap: {},
|
||||
event: [],
|
||||
rootName: 'test-root-name',
|
||||
statusMessage: 'test-status-message',
|
||||
statusCodeString: 'test-status-code-string',
|
||||
spanKind: 'test-span-kind',
|
||||
hasChildren: false,
|
||||
hasSibling: false,
|
||||
subTreeNodeCount: 0,
|
||||
level: 0,
|
||||
};
|
||||
|
||||
describe('SpanLineActionButtons', () => {
|
||||
beforeEach(() => {
|
||||
// Clear mock before each test
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders copy link button with correct icon', () => {
|
||||
(useCopySpanLink as jest.Mock).mockReturnValue({
|
||||
onSpanCopy: jest.fn(),
|
||||
});
|
||||
|
||||
render(<SpanLineActionButtons span={mockSpan} />);
|
||||
|
||||
// Check if the button is rendered
|
||||
const copyButton = screen.getByRole('button');
|
||||
expect(copyButton).toBeInTheDocument();
|
||||
|
||||
// Check if the link icon is rendered
|
||||
const linkIcon = screen.getByRole('img', { hidden: true });
|
||||
expect(linkIcon).toHaveClass('anticon anticon-link');
|
||||
});
|
||||
|
||||
it('calls onSpanCopy when copy button is clicked', () => {
|
||||
const mockOnSpanCopy = jest.fn();
|
||||
(useCopySpanLink as jest.Mock).mockReturnValue({
|
||||
onSpanCopy: mockOnSpanCopy,
|
||||
});
|
||||
|
||||
render(<SpanLineActionButtons span={mockSpan} />);
|
||||
|
||||
// Click the copy button
|
||||
const copyButton = screen.getByRole('button');
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
// Verify the copy function was called
|
||||
expect(mockOnSpanCopy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('applies correct styling classes', () => {
|
||||
(useCopySpanLink as jest.Mock).mockReturnValue({
|
||||
onSpanCopy: jest.fn(),
|
||||
});
|
||||
|
||||
render(<SpanLineActionButtons span={mockSpan} />);
|
||||
|
||||
// Check if the main container has the correct class
|
||||
const container = screen
|
||||
.getByRole('button')
|
||||
.closest('.span-line-action-buttons');
|
||||
expect(container).toHaveClass('span-line-action-buttons');
|
||||
|
||||
// Check if the button has the correct class
|
||||
const copyButton = screen.getByRole('button');
|
||||
expect(copyButton).toHaveClass('copy-span-btn');
|
||||
});
|
||||
|
||||
it('copies span link to clipboard when copy button is clicked', () => {
|
||||
const mockSetCopy = jest.fn();
|
||||
const mockUrlQuery = {
|
||||
delete: jest.fn(),
|
||||
set: jest.fn(),
|
||||
toString: jest.fn().mockReturnValue('spanId=test-span-id'),
|
||||
};
|
||||
const mockPathname = '/test-path';
|
||||
const mockLocation = {
|
||||
origin: 'http://localhost:3000',
|
||||
};
|
||||
|
||||
// Mock window.location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: mockLocation,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Mock useCopySpanLink hook
|
||||
(useCopySpanLink as jest.Mock).mockReturnValue({
|
||||
onSpanCopy: (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
mockUrlQuery.delete('spanId');
|
||||
mockUrlQuery.set('spanId', mockSpan.spanId);
|
||||
const link = `${
|
||||
window.location.origin
|
||||
}${mockPathname}?${mockUrlQuery.toString()}`;
|
||||
mockSetCopy(link);
|
||||
},
|
||||
});
|
||||
|
||||
render(<SpanLineActionButtons span={mockSpan} />);
|
||||
|
||||
// Click the copy button
|
||||
const copyButton = screen.getByRole('button');
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
// Verify the copy function was called with correct link
|
||||
expect(mockSetCopy).toHaveBeenCalledWith(
|
||||
'http://localhost:3000/test-path?spanId=test-span-id',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import { LinkOutlined } from '@ant-design/icons';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import './SpanLineActionButtons.styles.scss';
|
||||
|
||||
export interface SpanLineActionButtonsProps {
|
||||
span: Span;
|
||||
}
|
||||
export default function SpanLineActionButtons({
|
||||
span,
|
||||
}: SpanLineActionButtonsProps): JSX.Element {
|
||||
const { onSpanCopy } = useCopySpanLink(span);
|
||||
|
||||
return (
|
||||
<div className="span-line-action-buttons">
|
||||
<Tooltip title="Copy Span Link">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<LinkOutlined size={14} />}
|
||||
onClick={onSpanCopy}
|
||||
className="copy-span-btn"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
.trace-waterfall {
|
||||
height: calc(70vh - 236px);
|
||||
|
||||
.loading-skeleton {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { Dispatch, SetStateAction, useMemo } from 'react';
|
||||
import { Skeleton } from 'antd';
|
||||
import { AxiosError } from 'axios';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { GetTraceV2SuccessResponse, Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import { TraceWaterfallStates } from './constants';
|
||||
import Error from './TraceWaterfallStates/Error/Error';
|
||||
import NoData from './TraceWaterfallStates/NoData/NoData';
|
||||
import Success from './TraceWaterfallStates/Success/Success';
|
||||
|
||||
import './TraceWaterfall.styles.scss';
|
||||
|
||||
export interface IInterestedSpan {
|
||||
spanId: string;
|
||||
isUncollapsed: boolean;
|
||||
}
|
||||
|
||||
interface ITraceWaterfallProps {
|
||||
traceId: string;
|
||||
uncollapsedNodes: string[];
|
||||
traceData:
|
||||
| SuccessResponse<GetTraceV2SuccessResponse, unknown>
|
||||
| ErrorResponse
|
||||
| undefined;
|
||||
isFetchingTraceData: boolean;
|
||||
errorFetchingTraceData: unknown;
|
||||
interestedSpanId: IInterestedSpan;
|
||||
setInterestedSpanId: Dispatch<SetStateAction<IInterestedSpan>>;
|
||||
setTraceFlamegraphStatsWidth: Dispatch<SetStateAction<number>>;
|
||||
selectedSpan: Span | undefined;
|
||||
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
|
||||
hoveredSpanId: string | null;
|
||||
setHoveredSpanId: Dispatch<SetStateAction<string | null>>;
|
||||
}
|
||||
|
||||
function TraceWaterfall(props: ITraceWaterfallProps): JSX.Element {
|
||||
const {
|
||||
traceData,
|
||||
isFetchingTraceData,
|
||||
errorFetchingTraceData,
|
||||
interestedSpanId,
|
||||
traceId,
|
||||
uncollapsedNodes,
|
||||
setInterestedSpanId,
|
||||
setTraceFlamegraphStatsWidth,
|
||||
setSelectedSpan,
|
||||
selectedSpan,
|
||||
hoveredSpanId,
|
||||
setHoveredSpanId,
|
||||
} = props;
|
||||
// get the current state of trace waterfall based on the API lifecycle
|
||||
const traceWaterfallState = useMemo(() => {
|
||||
if (isFetchingTraceData) {
|
||||
if (
|
||||
traceData &&
|
||||
traceData.payload &&
|
||||
traceData.payload.spans &&
|
||||
traceData.payload.spans.length > 0
|
||||
) {
|
||||
return TraceWaterfallStates.FETCHING_WITH_OLD_DATA_PRESENT;
|
||||
}
|
||||
return TraceWaterfallStates.LOADING;
|
||||
}
|
||||
if (errorFetchingTraceData) {
|
||||
return TraceWaterfallStates.ERROR;
|
||||
}
|
||||
if (
|
||||
traceData &&
|
||||
traceData.payload &&
|
||||
traceData.payload.spans &&
|
||||
traceData.payload.spans.length === 0
|
||||
) {
|
||||
return TraceWaterfallStates.NO_DATA;
|
||||
}
|
||||
|
||||
return TraceWaterfallStates.SUCCESS;
|
||||
}, [errorFetchingTraceData, isFetchingTraceData, traceData]);
|
||||
|
||||
// capture the spans from the response, since we do not need to do any manipulation on the same we will keep this as a simple constant [ memoized ]
|
||||
const spans = useMemo(() => traceData?.payload?.spans || [], [
|
||||
traceData?.payload?.spans,
|
||||
]);
|
||||
|
||||
// get the content based on the current state of the trace waterfall
|
||||
const getContent = useMemo(() => {
|
||||
switch (traceWaterfallState) {
|
||||
case TraceWaterfallStates.LOADING:
|
||||
return (
|
||||
<div className="loading-skeleton">
|
||||
<Skeleton active paragraph={{ rows: 6 }} />
|
||||
</div>
|
||||
);
|
||||
case TraceWaterfallStates.ERROR:
|
||||
return <Error error={errorFetchingTraceData as AxiosError} />;
|
||||
case TraceWaterfallStates.NO_DATA:
|
||||
return <NoData id={traceId} />;
|
||||
case TraceWaterfallStates.SUCCESS:
|
||||
case TraceWaterfallStates.FETCHING_WITH_OLD_DATA_PRESENT:
|
||||
return (
|
||||
<Success
|
||||
spans={spans}
|
||||
traceMetadata={{
|
||||
traceId,
|
||||
startTime: traceData?.payload?.startTimestampMillis || 0,
|
||||
endTime: traceData?.payload?.endTimestampMillis || 0,
|
||||
hasMissingSpans: traceData?.payload?.hasMissingSpans || false,
|
||||
}}
|
||||
interestedSpanId={interestedSpanId || ''}
|
||||
uncollapsedNodes={uncollapsedNodes}
|
||||
setInterestedSpanId={setInterestedSpanId}
|
||||
setTraceFlamegraphStatsWidth={setTraceFlamegraphStatsWidth}
|
||||
selectedSpan={selectedSpan}
|
||||
setSelectedSpan={setSelectedSpan}
|
||||
hoveredSpanId={hoveredSpanId}
|
||||
setHoveredSpanId={setHoveredSpanId}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <Spinner tip="Fetching the trace!" />;
|
||||
}
|
||||
}, [
|
||||
errorFetchingTraceData,
|
||||
interestedSpanId,
|
||||
selectedSpan,
|
||||
setInterestedSpanId,
|
||||
setSelectedSpan,
|
||||
setTraceFlamegraphStatsWidth,
|
||||
spans,
|
||||
traceData?.payload?.endTimestampMillis,
|
||||
traceData?.payload?.hasMissingSpans,
|
||||
traceData?.payload?.startTimestampMillis,
|
||||
traceId,
|
||||
traceWaterfallState,
|
||||
uncollapsedNodes,
|
||||
hoveredSpanId,
|
||||
setHoveredSpanId,
|
||||
]);
|
||||
|
||||
return <div className="trace-waterfall">{getContent}</div>;
|
||||
}
|
||||
|
||||
export default TraceWaterfall;
|
||||
@@ -1,30 +0,0 @@
|
||||
.error-waterfall {
|
||||
display: flex;
|
||||
padding: 12px;
|
||||
margin: 20px;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-cherry-500);
|
||||
|
||||
.text {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Tooltip, Typography } from 'antd';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
import './Error.styles.scss';
|
||||
|
||||
interface IErrorProps {
|
||||
error: AxiosError;
|
||||
}
|
||||
|
||||
function Error(props: IErrorProps): JSX.Element {
|
||||
const { error } = props;
|
||||
|
||||
return (
|
||||
<div className="error-waterfall">
|
||||
<Typography.Text className="text">Something went wrong!</Typography.Text>
|
||||
<Tooltip title={error?.message}>
|
||||
<Typography.Text className="value" ellipsis>
|
||||
{error?.message}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Error;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user