Compare commits

..

33 Commits

Author SHA1 Message Date
Abhi Kumar
52eedce12f fix: failing test 2026-03-13 19:54:49 +05:30
Abhi Kumar
b2c0291c11 fix: transform react-resizable-panels in jest config 2026-03-13 19:38:04 +05:30
Abhi Kumar
83bf21fb6f chore: prettier fmt fix 2026-03-13 19:23:08 +05:30
Abhi Kumar
3443b25791 chore: disabled chart apperance section 2026-03-13 19:21:32 +05:30
Abhi Kumar
6ac88d2a17 Merge branch 'main' of https://github.com/SigNoz/signoz into feat/chart-apperance-section 2026-03-13 19:16:47 +05:30
Abhi Kumar
0c1078c494 chore: fixed styles 2026-03-13 18:59:43 +05:30
Abhi kumar
937ebc1582 feat: added section in panel settings (#10569)
* feat: added section in panel settings

* chore: minor changes

* fix: fixed failing tests

* fix: minor style fixes

* chore: updated the categorisation

* chore: updated styles

* chore: minor styles improvements

* chore: formatting unit section fix
2026-03-13 13:22:10 +00:00
Abhi Kumar
d0ab05a84d chore: formatting unit section fix 2026-03-13 18:28:59 +05:30
Abhi Kumar
d7b681eaf8 chore: minor styles improvements 2026-03-13 18:24:01 +05:30
Abhi Kumar
fa1620f4da chore: updated styles 2026-03-13 18:20:31 +05:30
Abhi Kumar
69a3d214fb chore: updated styles + made panel config resizable 2026-03-13 18:12:47 +05:30
Ashwin Bhatkal
dcc8173c79 fix: variables initial url state (#10579)
* fix: variables-initial-url-state

* chore: add tests
2026-03-13 11:16:47 +00:00
Ashwin Bhatkal
4b4ef5ce58 fix: edit mode variables not persisting value (#10576)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix: edit mode variables not persisting value

* chore: move into hook

* chore: add tests

* chore: fix tests

* chore: move functions
2026-03-13 07:49:40 +00:00
Yunus M
5b8d5fbfd3 Revert "feat: Option to zoom out OR reset zoom in the explorer pages (#10464)" (#10574)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
This reverts commit 557451ed81.
2026-03-12 19:24:49 +00:00
Abhi Kumar
4affdeda56 feat: added fill mode in timeseries 2026-03-13 00:37:25 +05:30
Abhi Kumar
99944cc1de feat: added chart appearance settings in panel 2026-03-12 21:25:58 +05:30
Ashwin Bhatkal
0271be11e6 chore: remove dashboard provider from the root (#10526)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: remove dashboard provider from the root

* chore: fix tests

* chore: fix tests

* chore: remove dashboardId from provider

* chore: remove old instances of dashboard provider

* chore: separate dashboard widget fully

* chore: fix tests

* chore: resolve self comments
2026-03-12 14:51:49 +00:00
Abhi Kumar
d1bd36e88a chore: updated the categorisation 2026-03-12 19:33:54 +05:30
Abhi Kumar
d26d4ebd31 fix: minor style fixes 2026-03-12 18:44:20 +05:30
Abhi Kumar
771e5bd287 fix: fixed failing tests 2026-03-12 17:22:15 +05:30
Abhi Kumar
bd33304912 chore: minor changes 2026-03-12 17:18:19 +05:30
Vikrant Gupta
92d220c4d9 feat(serviceaccount): domain changes for service account (#10568)
* feat(serviceaccount): domain type changes

* feat(serviceaccount): domain type changes

* feat(serviceaccount): domain type changes
2026-03-12 11:06:04 +00:00
Abhi Kumar
ca1cc0a4ac feat: added section in panel settings 2026-03-12 15:41:56 +05:30
Vikrant Gupta
0ed8169bad feat(authz): add service account authz changes (#10567) 2026-03-12 09:42:50 +00:00
SagarRajput-7
ed553fb02e feat: removed plan name and added copiable license info in custom domain card (#10558)
* feat: removed plan name and added copiable license info in custom domain card

* feat: added condition on the license row in custom domain card

* feat: code refactor and making license row a common component

* feat: added test case and addressed feedback

* feat: style improvement

* feat: added maskedkey util and refactored code

* feat: updated test case
2026-03-12 09:24:41 +00:00
Ashwin Bhatkal
47daba3c17 chore: link session url with sentry alert (#10566) 2026-03-12 09:19:31 +00:00
Srikanth Chekuri
2b3310809a fix: newServer uses the stored config hash for mismatch (#10563) 2026-03-12 08:26:22 +00:00
Ashwin Bhatkal
542a648cc3 chore: remove toScrollWidgetId from dashboard provider (#10562)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: remove toScrollWidgetId from dashboard provider

* chore: remove dead files

* chore: fix tests
2026-03-12 06:03:02 +00:00
Naman Verma
61df12d126 test: integration tests for percentile aggregation (#10555)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix: make histogramQuantile function work for devenv setup

* fix: make histogramQuantile function work for integration tests

* test: histogram percentile integration tests

* chore: explicitly mention user_scripts_path

* chore: fail tests if download of UDF fails
2026-03-11 14:27:01 +00:00
Vinicius Lourenço
b846faa1fa fix(app-routes): do not render old route, redirect instead (#10553) 2026-03-11 12:53:32 +00:00
Ishan
557451ed81 feat: Option to zoom out OR reset zoom in the explorer pages (#10464)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat: zoom out func ladder added

* feat: zoom out feature with testcases

* fix: comments resolved moved to signoz btn added testcase for querydelete

* feat: updated btn compoent to use prefix icon

* feat: historical enddate preset as null to preserve custom

* fix: cursor bot callback and localstorage

* feat: common util for local storage

* feat: rename and testcase

* feat: avoid persist for non preset
2026-03-11 07:23:30 +00:00
Ishan
25c513ec2f fix: updated fallback color (#10525)
* fix: updated fallback color

* fix: updated testcase
2026-03-11 07:23:21 +00:00
primus-bot[bot]
ae71f2608a chore(release): bump to v0.115.0 (#10556)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2026-03-11 07:11:28 +00:00
151 changed files with 3210 additions and 8974 deletions

View File

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

View File

@@ -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
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,29 @@
import { PropsWithChildren } from 'react';
type CommonProps = PropsWithChildren<{
className?: string;
minSize?: number;
maxSize?: number;
defaultSize?: number;
direction?: 'horizontal' | 'vertical';
autoSaveId?: string;
withHandle?: boolean;
}>;
export function ResizablePanelGroup({
children,
className,
}: CommonProps): JSX.Element {
return <div className={className}>{children}</div>;
}
export function ResizablePanel({
children,
className,
}: CommonProps): JSX.Element {
return <div className={className}>{children}</div>;
}
export function ResizableHandle({ className }: CommonProps): JSX.Element {
return <div className={className} />;
}

View File

@@ -14,6 +14,7 @@ const config: Config.InitialOptions = {
'\\.(css|less|scss)$': '<rootDir>/__mocks__/cssMock.ts',
'\\.md$': '<rootDir>/__mocks__/cssMock.ts',
'^uplot$': '<rootDir>/__mocks__/uplotMock.ts',
'^@signozhq/resizable$': '<rootDir>/__mocks__/resizableMock.tsx',
'^hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^src/hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^.*/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,

View File

@@ -64,7 +64,7 @@
"@signozhq/sonner": "0.1.0",
"@signozhq/switch": "0.0.2",
"@signozhq/table": "0.3.7",
"@signozhq/toggle-group": "^0.0.1",
"@signozhq/toggle-group": "0.0.1",
"@signozhq/tooltip": "0.0.2",
"@tanstack/react-table": "8.20.6",
"@tanstack/react-virtual": "3.11.2",

View File

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

View File

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

View File

@@ -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
*/

View File

@@ -1,4 +0,0 @@
.timeline-v3-container {
// flex: 1;
overflow: visible;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
@@ -199,8 +194,6 @@ describe('Dashboard landing page actions header tests', () => {
setLayouts: jest.fn(),
setSelectedDashboard: jest.fn(),
updatedTimeRef: { current: null },
toScrollWidgetId: '',
setToScrollWidgetId: jest.fn(),
updateLocalStorageDashboardVariables: jest.fn(),
dashboardQueryRangeCalled: false,
setDashboardQueryRangeCalled: jest.fn(),

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

@@ -224,7 +224,7 @@ describe('TimeSeriesPanel utils', () => {
});
});
it('uses DrawStyle.Line and VisibilityMode.Never when series has multiple valid points', () => {
it('uses DrawStyle.Line and showPoints false when series has multiple valid points', () => {
const apiResponse = createApiResponse([
{
metric: {},

View File

@@ -10,9 +10,9 @@ import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import {
DrawStyle,
FillMode,
LineInterpolation,
LineStyle,
VisibilityMode,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { isInvalidPlotValue } from 'lib/uPlotV2/utils/dataUtils';
@@ -124,12 +124,12 @@ export const prepareUPlotConfig = ({
label: label,
colorMapping: widget.customLegendColors ?? {},
spanGaps: true,
lineStyle: LineStyle.Solid,
lineInterpolation: LineInterpolation.Spline,
showPoints: hasSingleValidPoint
? VisibilityMode.Always
: VisibilityMode.Never,
lineStyle: widget.lineStyle || LineStyle.Solid,
lineInterpolation: widget.lineInterpolation || LineInterpolation.Spline,
showPoints:
widget.showPoints || hasSingleValidPoint ? true : !!widget.showPoints,
pointSize: 5,
fillMode: widget.fillMode || FillMode.None,
isDarkMode,
});
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 />
) : (

View File

@@ -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', () => {

View File

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

View File

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

View File

@@ -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({

View File

@@ -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', () => {

View File

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

View File

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

View File

@@ -65,6 +65,22 @@
}
}
.new-widget-container {
.resizable-panel-left-container {
overflow-x: hidden;
overflow-y: auto;
}
.resizable-panel-right-container {
overflow-y: auto;
min-width: 350px;
}
.widget-resizable-handle {
height: 100vh;
}
}
.lightMode {
.edit-header {
border-bottom: 1px solid var(--bg-vanilla-300);

View File

@@ -1,5 +1,7 @@
.column-unit-selector {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 8px;
.heading {
color: var(--bg-vanilla-400);
@@ -30,6 +32,11 @@
width: 100%;
}
}
&-content {
display: flex;
flex-direction: column;
gap: 12px;
}
}
.lightMode {

View File

@@ -72,22 +72,24 @@ export function ColumnUnitSelector(
return (
<section className="column-unit-selector">
<Typography.Text className="heading">Column Units</Typography.Text>
{aggregationQueries.map(({ value, label }) => {
const baseQueryName = value.split('.')[0];
return (
<YAxisUnitSelectorV2
value={columnUnits[value] || ''}
onSelect={(unitValue: string): void =>
handleColumnUnitSelect(value, unitValue)
}
fieldLabel={label}
key={value}
selectedQueryName={baseQueryName}
// Update the column unit value automatically only in create mode
shouldUpdateYAxisUnit={isNewDashboard}
/>
);
})}
<div className="column-unit-selector-content">
{aggregationQueries.map(({ value, label }) => {
const baseQueryName = value.split('.')[0];
return (
<YAxisUnitSelectorV2
value={columnUnits[value] || ''}
onSelect={(unitValue: string): void =>
handleColumnUnitSelect(value, unitValue)
}
fieldLabel={label}
key={value}
selectedQueryName={baseQueryName}
// Update the column unit value automatically only in create mode
shouldUpdateYAxisUnit={isNewDashboard}
/>
);
})}
</div>
</section>
);
}

View File

@@ -56,9 +56,6 @@ describe('ContextLinks Component', () => {
/>,
);
// Check that the component renders
expect(screen.getByText('Context Links')).toBeInTheDocument();
// Check that the add button is present
expect(
screen.getByRole('button', { name: /context link/i }),

View File

@@ -14,7 +14,7 @@ import {
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Button, Modal, Typography } from 'antd';
import { Button, Modal } from 'antd';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { GripVertical, Pencil, Plus, Trash2 } from 'lucide-react';
import {
@@ -134,11 +134,16 @@ function ContextLinks({
return (
<div className="context-links-container">
<Typography.Text className="context-links-text">
Context Links
</Typography.Text>
<div className="context-links-list">
<Button
type="default"
className="add-context-link-button"
icon={<Plus size={12} />}
style={{ width: '100%' }}
onClick={handleAddContextLink}
>
Add Context Link
</Button>
<OverlayScrollbar>
<DndContext
sensors={sensors}
@@ -160,16 +165,6 @@ function ContextLinks({
</SortableContext>
</DndContext>
</OverlayScrollbar>
{/* button to add context link */}
<Button
type="primary"
className="add-context-link-button"
icon={<Plus size={12} />}
onClick={handleAddContextLink}
>
Context Link
</Button>
</div>
<Modal

View File

@@ -2,7 +2,6 @@
display: flex;
flex-direction: column;
gap: 16px;
margin: 12px;
}
.context-links-text {
@@ -110,10 +109,7 @@
}
.add-context-link-button {
display: flex;
align-items: center;
margin: auto;
width: fit-content;
width: 100%;
}
.lightMode {

View File

@@ -0,0 +1,21 @@
.fill-mode-selector {
.fill-mode-icon {
width: 24px;
height: 24px;
}
.fill-mode-label {
text-transform: uppercase;
font-size: 12px;
font-weight: 500;
color: var(--bg-vanilla-400);
}
}
.lightMode {
.fill-mode-selector {
.fill-mode-label {
color: var(--bg-ink-400);
}
}
}

View File

@@ -0,0 +1,94 @@
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
import { Typography } from 'antd';
import { FillMode } from 'lib/uPlotV2/config/types';
import './FillModeSelector.styles.scss';
interface FillModeSelectorProps {
value: FillMode;
onChange: (value: FillMode) => void;
}
export function FillModeSelector({
value,
onChange,
}: FillModeSelectorProps): JSX.Element {
return (
<section className="fill-mode-selector control-container">
<Typography.Text className="section-heading">Fill mode</Typography.Text>
<ToggleGroup
type="single"
value={value}
variant="outline"
size="lg"
onValueChange={(newValue): void => {
if (newValue) {
onChange(newValue as FillMode);
}
}}
>
<ToggleGroupItem value={FillMode.None} aria-label="None" title="None">
<svg
className="fill-mode-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="8" y="16" width="32" height="16" stroke="#888" fill="none" />
</svg>
<Typography.Text className="section-heading-small">None</Typography.Text>
</ToggleGroupItem>
<ToggleGroupItem value={FillMode.Solid} aria-label="Solid" title="Solid">
<svg
className="fill-mode-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="8" y="16" width="32" height="16" fill="#888" />
</svg>
<Typography.Text className="section-heading-small">Solid</Typography.Text>
</ToggleGroupItem>
<ToggleGroupItem
value={FillMode.Gradient}
aria-label="Gradient"
title="Gradient"
>
<svg
className="fill-mode-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<defs>
<linearGradient id="fill-gradient" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="#888" stopOpacity="0.2" />
<stop offset="100%" stopColor="#888" stopOpacity="0.8" />
</linearGradient>
</defs>
<rect
x="8"
y="16"
width="32"
height="16"
fill="url(#fill-gradient)"
stroke="#888"
/>
</svg>
<Typography.Text className="section-heading-small">
Gradient
</Typography.Text>
</ToggleGroupItem>
</ToggleGroup>
</section>
);
}

View File

@@ -0,0 +1,21 @@
.line-interpolation-selector {
.line-interpolation-icon {
width: 24px;
height: 24px;
}
.line-interpolation-label {
text-transform: uppercase;
font-size: 12px;
font-weight: 500;
color: var(--bg-vanilla-400);
}
}
.lightMode {
.line-interpolation-selector {
.line-interpolation-label {
color: var(--bg-ink-400);
}
}
}

View File

@@ -0,0 +1,110 @@
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
import { Typography } from 'antd';
import { LineInterpolation } from 'lib/uPlotV2/config/types';
import './LineInterpolationSelector.styles.scss';
interface LineInterpolationSelectorProps {
value: LineInterpolation;
onChange: (value: LineInterpolation) => void;
}
export function LineInterpolationSelector({
value,
onChange,
}: LineInterpolationSelectorProps): JSX.Element {
return (
<section className="line-interpolation-selector control-container">
<Typography.Text className="section-heading">
Line interpolation
</Typography.Text>
<ToggleGroup
type="single"
value={value}
variant="outline"
size="lg"
onValueChange={(newValue): void => {
if (newValue) {
onChange(newValue as LineInterpolation);
}
}}
>
<ToggleGroupItem
value={LineInterpolation.Linear}
aria-label="Linear"
title="Linear"
>
<svg
className="line-interpolation-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="8" cy="32" r="3" fill="#888" />
<circle cx="24" cy="16" r="3" fill="#888" />
<circle cx="40" cy="32" r="3" fill="#888" />
<path d="M8 32 L24 16 L40 32" stroke="#888" />
</svg>
</ToggleGroupItem>
<ToggleGroupItem value={LineInterpolation.Spline} aria-label="Spline">
<svg
className="line-interpolation-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="8" cy="32" r="3" fill="#888" />
<circle cx="24" cy="16" r="3" fill="#888" />
<circle cx="40" cy="32" r="3" fill="#888" />
<path d="M8 32 C16 8, 32 8, 40 32" />
</svg>
</ToggleGroupItem>
<ToggleGroupItem
value={LineInterpolation.StepAfter}
aria-label="Step After"
>
<svg
className="line-interpolation-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="8" cy="32" r="3" fill="#888" />
<circle cx="24" cy="16" r="3" fill="#888" />
<circle cx="40" cy="32" r="3" fill="#888" />
<path d="M8 32 V16 H24 V32 H40" />
</svg>
</ToggleGroupItem>
<ToggleGroupItem
value={LineInterpolation.StepBefore}
aria-label="Step Before"
>
<svg
className="line-interpolation-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="8" cy="32" r="3" fill="#888" />
<circle cx="24" cy="16" r="3" fill="#888" />
<circle cx="40" cy="32" r="3" fill="#888" />
<path d="M8 32 H24 V16 H40 V32" />
</svg>
</ToggleGroupItem>
</ToggleGroup>
</section>
);
}

View File

@@ -0,0 +1,21 @@
.line-style-selector {
.line-style-icon {
width: 24px;
height: 24px;
}
.line-style-label {
text-transform: uppercase;
font-size: 12px;
font-weight: 500;
color: var(--bg-vanilla-400);
}
}
.lightMode {
.line-style-selector {
.line-style-label {
color: var(--bg-ink-400);
}
}
}

View File

@@ -0,0 +1,66 @@
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
import { Typography } from 'antd';
import { LineStyle } from 'lib/uPlotV2/config/types';
import './LineStyleSelector.styles.scss';
interface LineStyleSelectorProps {
value: LineStyle;
onChange: (value: LineStyle) => void;
}
export function LineStyleSelector({
value,
onChange,
}: LineStyleSelectorProps): JSX.Element {
return (
<section className="line-style-selector control-container">
<Typography.Text className="section-heading">Line style</Typography.Text>
<ToggleGroup
type="single"
value={value}
variant="outline"
size="lg"
onValueChange={(newValue): void => {
if (newValue) {
onChange(newValue as LineStyle);
}
}}
>
<ToggleGroupItem value={LineStyle.Solid} aria-label="Solid" title="Solid">
<svg
className="line-style-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M8 24 L40 24" />
</svg>
<Typography.Text className="section-heading-small">Solid</Typography.Text>
</ToggleGroupItem>
<ToggleGroupItem
value={LineStyle.Dashed}
aria-label="Dashed"
title="Dashed"
>
<svg
className="line-style-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray="6 4"
>
<path d="M8 24 L40 24" />
</svg>
<Typography.Text className="section-heading-small">Dashed</Typography.Text>
</ToggleGroupItem>
</ToggleGroup>
</section>
);
}

View File

@@ -1,6 +1,28 @@
.right-container {
display: flex;
flex-direction: column;
font-family: 'Space Mono';
.section-heading {
color: var(--bg-vanilla-400);
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 138.462% */
letter-spacing: 0.52px;
text-transform: uppercase;
}
.section-heading-small {
font-family: 'Space Mono';
color: var(--bg-vanilla-400);
font-size: 12px;
font-style: normal;
font-weight: 400;
word-break: initial;
line-height: 16px; /* 133.333% */
letter-spacing: 0.48px;
}
.header {
display: flex;
@@ -24,25 +46,14 @@
letter-spacing: -0.07px;
}
}
.name-description {
.control-container {
display: flex;
flex-direction: column;
padding: 12px 12px 16px 12px;
border-top: 1px solid var(--bg-slate-500);
border-bottom: 1px solid var(--bg-slate-500);
gap: 8px;
}
.typography {
color: var(--bg-vanilla-400);
font-family: 'Space Mono';
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 138.462% */
letter-spacing: 0.52px;
text-transform: uppercase;
}
.name-description {
padding: 0 0 4px 0;
.name-input {
display: flex;
@@ -88,22 +99,26 @@
.panel-config {
display: flex;
flex-direction: column;
padding: 12px 12px 16px 12px;
gap: 8px;
border-bottom: 1px solid var(--bg-slate-500);
.typography {
color: var(--bg-vanilla-400);
font-family: 'Space Mono';
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 138.462% */
letter-spacing: 0.52px;
text-transform: uppercase;
.toggle-card {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 12px;
border-radius: 2px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-400);
}
.toggle-card-text-container {
display: flex;
flex-direction: column;
gap: 4px;
}
.panel-type-select {
width: 100%;
.ant-select-selector {
display: flex;
height: 32px;
@@ -115,98 +130,32 @@
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
}
.select-option {
display: flex;
align-items: center;
gap: 6px;
.icon {
display: flex;
align-items: center;
}
.display {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px; /* 133.333% */
}
}
}
.fill-gaps {
margin-top: 16px;
display: flex;
padding: 12px;
justify-content: space-between;
align-items: center;
border-radius: 2px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-400);
.fill-gaps-text {
color: var(--bg-vanilla-400);
font-family: 'Space Mono';
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 138.462% */
letter-spacing: 0.52px;
text-transform: uppercase;
}
.toggle-card-description {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
opacity: 0.6;
line-height: 16px; /* 133.333% */
}
.log-scale,
.decimal-precision-selector {
margin-top: 16px;
display: flex;
justify-content: space-between;
flex-direction: column;
gap: 8px;
}
.decimal-precision-selector,
.legend-position {
margin-top: 16px;
display: flex;
justify-content: space-between;
flex-direction: column;
gap: 8px;
}
.legend-colors {
margin-top: 16px;
}
.panel-time-text {
margin-top: 16px;
color: var(--bg-vanilla-400);
font-family: 'Space Mono';
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 138.462% */
letter-spacing: 0.52px;
text-transform: uppercase;
}
.y-axis-unit-selector,
.y-axis-unit-selector-v2 {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 8px;
.heading {
color: var(--bg-vanilla-400);
font-family: 'Space Mono';
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 138.462% */
letter-spacing: 0.52px;
text-transform: uppercase;
@extend .section-heading;
}
.input {
@@ -259,7 +208,6 @@
.text {
color: var(--bg-vanilla-400);
font-family: 'Space Mono';
font-size: 12px;
font-style: normal;
font-weight: 400;
@@ -278,40 +226,11 @@
}
.stack-chart {
margin-top: 16px;
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 8px;
.label {
color: var(--bg-vanilla-400);
font-family: 'Space Mono';
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 138.462% */
letter-spacing: 0.52px;
text-transform: uppercase;
}
}
.bucket-config {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 8px;
.label {
color: var(--bg-vanilla-400);
font-family: 'Space Mono';
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 138.462% */
letter-spacing: 0.52px;
text-transform: uppercase;
}
.bucket-size-label {
margin-top: 8px;
}
@@ -340,7 +259,6 @@
.label {
color: var(--bg-vanilla-400);
font-family: 'Space Mono';
font-size: 13px;
font-style: normal;
font-weight: 400;
@@ -352,16 +270,13 @@
}
}
.context-links {
border-bottom: 1px solid var(--bg-slate-500);
}
.alerts {
display: flex;
padding: 12px;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--bg-slate-500);
padding: 12px;
min-height: 44px;
border-top: 1px solid var(--bg-slate-500);
cursor: pointer;
.left-section {
@@ -375,7 +290,6 @@
.alerts-text {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
@@ -387,6 +301,16 @@
color: var(--bg-vanilla-400);
}
}
.context-links {
padding: 12px 12px 16px 12px;
border-bottom: 1px solid var(--bg-slate-500);
}
.thresholds-section {
padding: 12px 12px 16px 12px;
border-top: 1px solid var(--bg-slate-500);
}
}
.select-option {
@@ -411,6 +335,9 @@
.lightMode {
.right-container {
background-color: var(--bg-vanilla-100);
.section-heading {
color: var(--bg-ink-400);
}
.header {
.header-text {
color: var(--bg-ink-400);
@@ -418,9 +345,6 @@
}
.name-description {
border-top: 1px solid var(--bg-vanilla-300);
border-bottom: 1px solid var(--bg-vanilla-300);
.typography {
color: var(--bg-ink-400);
}
@@ -441,12 +365,6 @@
}
.panel-config {
border-bottom: 1px solid var(--bg-vanilla-300);
.typography {
color: var(--bg-ink-400);
}
.panel-type-select {
.ant-select-selector {
border: 1px solid var(--bg-vanilla-300);
@@ -471,13 +389,16 @@
}
}
.fill-gaps {
.toggle-card {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-300);
.fill-gaps-text {
color: var(--bg-ink-400);
}
.toggle-card-description {
color: var(--bg-ink-400);
}
}
.bucket-config {
@@ -530,7 +451,7 @@
}
.alerts {
border-bottom: 1px solid var(--bg-vanilla-300);
border-top: 1px solid var(--bg-vanilla-300);
.left-section {
.bell-icon {
@@ -549,6 +470,10 @@
.context-links {
border-bottom: 1px solid var(--bg-vanilla-300);
}
.thresholds-section {
border-top: 1px solid var(--bg-vanilla-300);
}
}
.select-option {

View File

@@ -1,5 +1,4 @@
.threshold-selector-container {
padding: 12px;
padding-bottom: 80px;
.threshold-select {

View File

@@ -1,10 +1,10 @@
import { useCallback } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { Typography } from 'antd';
import { Button } from 'antd';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetQueryLabels } from 'hooks/useGetQueryLabels';
import { Antenna, Plus } from 'lucide-react';
import { Plus } from 'lucide-react';
import { v4 as uuid } from 'uuid';
import Threshold from './Threshold';
@@ -68,11 +68,14 @@ function ThresholdSelector({
<DndProvider backend={HTML5Backend}>
<div className="threshold-selector-container">
<div className="threshold-select" onClick={addThresholdHandler}>
<div className="left-section">
<Antenna size={14} className="icon" />
<Typography.Text className="text">Thresholds</Typography.Text>
</div>
<Plus size={14} onClick={addThresholdHandler} className="icon" />
<Button
type="default"
icon={<Plus size={14} />}
style={{ width: '100%' }}
onClick={addThresholdHandler}
>
Add Threshold
</Button>
</div>
{thresholds.map((threshold, idx) => (
<Threshold

View File

@@ -6,9 +6,13 @@ import { MemoryRouter } from 'react-router-dom';
import { render as rtlRender, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
FillMode,
LineInterpolation,
LineStyle,
} from 'lib/uPlotV2/config/types';
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 +100,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>
@@ -168,6 +170,14 @@ describe('RightContainer - Alerts Section', () => {
setContextLinks: jest.fn(),
enableDrillDown: false,
isNewDashboard: false,
lineInterpolation: LineInterpolation.Spline,
fillMode: FillMode.None,
lineStyle: LineStyle.Solid,
setLineInterpolation: jest.fn(),
setFillMode: jest.fn(),
setLineStyle: jest.fn(),
showPoints: false,
setShowPoints: jest.fn(),
};
beforeEach(() => {

View File

@@ -0,0 +1,68 @@
.settings-section {
border-top: 1px solid var(--bg-slate-500);
}
.settings-section-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 12px 12px;
min-height: 44px;
background: transparent;
border: none;
outline: none;
cursor: pointer;
.settings-section-title {
display: flex;
align-items: center;
gap: 8px;
color: var(--bg-vanilla-400);
font-family: 'Space Mono';
font-size: 13px;
font-weight: 400;
text-transform: uppercase;
}
.chevron-icon {
color: var(--bg-vanilla-400);
transition: transform 0.2s ease-in-out;
&.open {
transform: rotate(180deg);
}
}
}
.settings-section-content {
padding: 0 12px 0 12px;
display: flex;
flex-direction: column;
gap: 20px;
max-height: 0;
overflow: hidden;
opacity: 0;
transition: max-height 0.25s ease, opacity 0.25s ease, padding 0.25s ease;
&.open {
padding-bottom: 24px;
max-height: 1000px;
opacity: 1;
}
}
.lightMode {
.settings-section-header {
.chevron-icon {
color: var(--bg-ink-400);
}
.settings-section-title {
color: var(--bg-ink-400);
}
}
.settings-section {
border-top: 1px solid var(--bg-vanilla-300);
}
}

View File

@@ -0,0 +1,51 @@
import { ReactNode, useState } from 'react';
import { ChevronDown } from 'lucide-react';
import './SettingsSection.styles.scss';
export interface SettingsSectionProps {
title: string;
defaultOpen?: boolean;
children: ReactNode;
icon?: ReactNode;
}
function SettingsSection({
title,
defaultOpen = false,
children,
icon,
}: SettingsSectionProps): JSX.Element {
const [isOpen, setIsOpen] = useState(defaultOpen);
const toggleOpen = (): void => {
setIsOpen((prev) => !prev);
};
return (
<section className="settings-section">
<button
type="button"
className="settings-section-header"
onClick={toggleOpen}
>
<span className="settings-section-title">
{icon ? icon : null} {title}
</span>
<ChevronDown
size={16}
className={isOpen ? 'chevron-icon open' : 'chevron-icon'}
/>
</button>
<div
className={
isOpen ? 'settings-section-content open' : 'settings-section-content'
}
>
{children}
</div>
</section>
);
}
export default SettingsSection;

View File

@@ -206,3 +206,59 @@ export const panelTypeVsDecimalPrecision: {
[PANEL_TYPES.TRACE]: false,
[PANEL_TYPES.EMPTY_WIDGET]: false,
} as const;
export const panelTypeVsLineInterpolation: {
[key in PANEL_TYPES]: boolean;
} = {
[PANEL_TYPES.TIME_SERIES]: true,
[PANEL_TYPES.VALUE]: false,
[PANEL_TYPES.TABLE]: false,
[PANEL_TYPES.LIST]: false,
[PANEL_TYPES.PIE]: false,
[PANEL_TYPES.BAR]: false,
[PANEL_TYPES.HISTOGRAM]: false,
[PANEL_TYPES.TRACE]: false,
[PANEL_TYPES.EMPTY_WIDGET]: false,
} as const;
export const panelTypeVsLineStyle: {
[key in PANEL_TYPES]: boolean;
} = {
[PANEL_TYPES.TIME_SERIES]: true,
[PANEL_TYPES.VALUE]: false,
[PANEL_TYPES.TABLE]: false,
[PANEL_TYPES.LIST]: false,
[PANEL_TYPES.PIE]: false,
[PANEL_TYPES.BAR]: false,
[PANEL_TYPES.HISTOGRAM]: false,
[PANEL_TYPES.TRACE]: false,
[PANEL_TYPES.EMPTY_WIDGET]: false,
} as const;
export const panelTypeVsFillMode: {
[key in PANEL_TYPES]: boolean;
} = {
[PANEL_TYPES.TIME_SERIES]: true,
[PANEL_TYPES.VALUE]: false,
[PANEL_TYPES.TABLE]: false,
[PANEL_TYPES.LIST]: false,
[PANEL_TYPES.PIE]: false,
[PANEL_TYPES.BAR]: false,
[PANEL_TYPES.HISTOGRAM]: false,
[PANEL_TYPES.TRACE]: false,
[PANEL_TYPES.EMPTY_WIDGET]: false,
} as const;
export const panelTypeVsShowPoints: {
[key in PANEL_TYPES]: boolean;
} = {
[PANEL_TYPES.TIME_SERIES]: true,
[PANEL_TYPES.VALUE]: false,
[PANEL_TYPES.TABLE]: false,
[PANEL_TYPES.LIST]: false,
[PANEL_TYPES.PIE]: false,
[PANEL_TYPES.BAR]: false,
[PANEL_TYPES.HISTOGRAM]: false,
[PANEL_TYPES.TRACE]: false,
[PANEL_TYPES.EMPTY_WIDGET]: false,
} as const;

View File

@@ -14,7 +14,6 @@ import {
Input,
InputNumber,
Select,
Space,
Switch,
Typography,
} from 'antd';
@@ -28,9 +27,22 @@ import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import useCreateAlerts from 'hooks/queryBuilder/useCreateAlerts';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
FillMode,
LineInterpolation,
LineStyle,
} from 'lib/uPlotV2/config/types';
import {
Antenna,
Axis3D,
ConciergeBell,
Layers,
LayoutDashboard,
LineChart,
Link,
Paintbrush,
Pencil,
Plus,
SlidersHorizontal,
Spline,
SquareArrowOutUpRight,
} from 'lucide-react';
@@ -46,17 +58,22 @@ import { DataSource } from 'types/common/queryBuilder';
import { popupContainer } from 'utils/selectPopupContainer';
import { ColumnUnitSelector } from './ColumnUnitSelector/ColumnUnitSelector';
import SettingsSection from './components/SettingsSection/SettingsSection';
import {
panelTypeVsBucketConfig,
panelTypeVsColumnUnitPreferences,
panelTypeVsContextLinks,
panelTypeVsCreateAlert,
panelTypeVsDecimalPrecision,
panelTypeVsFillMode,
panelTypeVsFillSpan,
panelTypeVsLegendColors,
panelTypeVsLegendPosition,
panelTypeVsLineInterpolation,
panelTypeVsLineStyle,
panelTypeVsLogScale,
panelTypeVsPanelTimePreferences,
panelTypeVsShowPoints,
panelTypeVsSoftMinMax,
panelTypeVsStackingChartPreferences,
panelTypeVsThreshold,
@@ -64,7 +81,10 @@ import {
} from './constants';
import ContextLinks from './ContextLinks';
import DashboardYAxisUnitSelectorWrapper from './DashboardYAxisUnitSelectorWrapper';
import { FillModeSelector } from './FillModeSelector';
import LegendColors from './LegendColors/LegendColors';
import { LineInterpolationSelector } from './LineInterpolationSelector';
import { LineStyleSelector } from './LineStyleSelector';
import ThresholdSelector from './Threshold/ThresholdSelector';
import { ThresholdProps } from './Threshold/types';
import { timePreferance } from './timeItems';
@@ -91,6 +111,14 @@ function RightContainer({
setTitle,
title,
selectedGraph,
lineInterpolation,
setLineInterpolation,
fillMode,
setFillMode,
lineStyle,
setLineStyle,
showPoints,
setShowPoints,
bucketCount,
bucketWidth,
stackedBarChart,
@@ -167,6 +195,11 @@ function RightContainer({
panelTypeVsContextLinks[selectedGraph] && enableDrillDown;
const allowDecimalPrecision = panelTypeVsDecimalPrecision[selectedGraph];
const allowLineInterpolation = panelTypeVsLineInterpolation[selectedGraph];
const allowLineStyle = panelTypeVsLineStyle[selectedGraph];
const allowFillMode = panelTypeVsFillMode[selectedGraph];
const allowShowPoints = panelTypeVsShowPoints[selectedGraph];
const { currentQuery } = useQueryBuilder();
const [graphTypes, setGraphTypes] = useState<ItemsProps[]>(GraphTypes);
@@ -178,6 +211,37 @@ function RightContainer({
}));
}, [dashboardVariables]);
const isAxisSectionVisible = useMemo(() => allowSoftMinMax || allowLogScale, [
allowSoftMinMax,
allowLogScale,
]);
const isFormattingSectionVisible = useMemo(
() => allowYAxisUnit || allowDecimalPrecision || allowPanelColumnPreference,
[allowYAxisUnit, allowDecimalPrecision, allowPanelColumnPreference],
);
const isLegendSectionVisible = useMemo(
() => allowLegendPosition || allowLegendColors,
[allowLegendPosition, allowLegendColors],
);
const isChartAppearanceSectionVisible = useMemo(
() =>
/**
* Disabled for now as we are not done with other settings in chart appearance section
* TODO: @ahrefabhi Enable this after we are done other settings in chart appearance section
*/
// eslint-disable-next-line sonarjs/no-redundant-boolean
false &&
(allowFillMode ||
allowLineStyle ||
allowLineInterpolation ||
allowShowPoints),
[allowFillMode, allowLineStyle, allowLineInterpolation, allowShowPoints],
);
const updateCursorAndDropdown = (value: string, pos: number): void => {
setCursorPos(pos);
const lastDollar = value.lastIndexOf('$', pos - 1);
@@ -193,6 +257,15 @@ function RightContainer({
}, 0);
};
const decimapPrecisionOptions = useMemo(() => {
return [
{ label: '0 decimals', value: PrecisionOptionsEnum.ZERO },
{ label: '1 decimal', value: PrecisionOptionsEnum.ONE },
{ label: '2 decimals', value: PrecisionOptionsEnum.TWO },
{ label: '3 decimals', value: PrecisionOptionsEnum.THREE },
];
}, []);
const handleInputCursor = (): void => {
const pos = inputRef.current?.input?.selectionStart ?? 0;
updateCursorAndDropdown(inputValue, pos);
@@ -263,269 +336,333 @@ function RightContainer({
<div className="right-container">
<section className="header">
<div className="purple-dot" />
<Typography.Text className="header-text">Panel details</Typography.Text>
<Typography.Text className="header-text">Panel Settings</Typography.Text>
</section>
<section className="name-description">
<Typography.Text className="typography">Name</Typography.Text>
<AutoComplete
options={dashboardVariableOptions}
value={inputValue}
onChange={onInputChange}
onSelect={onSelect}
filterOption={filterOption}
style={{ width: '100%' }}
getPopupContainer={popupContainer}
placeholder="Enter the panel name here..."
open={autoCompleteOpen}
>
<Input
rootClassName="name-input"
ref={inputRef}
onSelect={handleInputCursor}
onClick={handleInputCursor}
onBlur={(): void => setAutoCompleteOpen(false)}
/>
</AutoComplete>
<Typography.Text className="typography">Description</Typography.Text>
<TextArea
placeholder="Enter the panel description here..."
bordered
allowClear
value={description}
onChange={(event): void =>
onChangeHandler(setDescription, event.target.value)
}
rootClassName="description-input"
/>
</section>
<section className="panel-config">
<Typography.Text className="typography">Panel Type</Typography.Text>
<Select
onChange={setGraphHandler}
value={selectedGraph}
style={{ width: '100%' }}
className="panel-type-select"
data-testid="panel-change-select"
data-stacking-state={stackedBarChart ? 'true' : 'false'}
>
{graphTypes.map((item) => (
<Option key={item.name} value={item.name}>
<div className="select-option">
<div className="icon">{item.icon}</div>
<Typography.Text className="display">{item.display}</Typography.Text>
</div>
</Option>
))}
</Select>
{allowFillSpans && (
<Space className="fill-gaps">
<Typography className="fill-gaps-text">Fill gaps</Typography>
<Switch
checked={isFillSpans}
size="small"
onChange={(checked): void => setIsFillSpans(checked)}
<SettingsSection title="General" defaultOpen icon={<Pencil size={14} />}>
<section className="name-description control-container">
<Typography.Text className="section-heading">Name</Typography.Text>
<AutoComplete
options={dashboardVariableOptions}
value={inputValue}
onChange={onInputChange}
onSelect={onSelect}
filterOption={filterOption}
style={{ width: '100%' }}
getPopupContainer={popupContainer}
placeholder="Enter the panel name here..."
open={autoCompleteOpen}
>
<Input
rootClassName="name-input"
ref={inputRef}
onSelect={handleInputCursor}
onClick={handleInputCursor}
onBlur={(): void => setAutoCompleteOpen(false)}
/>
</Space>
)}
{allowPanelTimePreference && (
<>
<Typography.Text className="panel-time-text">
Panel Time Preference
</Typography.Text>
<TimePreference
{...{
selectedTime,
setSelectedTime,
}}
/>
</>
)}
{allowPanelColumnPreference && (
<ColumnUnitSelector
columnUnits={columnUnits}
setColumnUnits={setColumnUnits}
isNewDashboard={isNewDashboard}
/>
)}
{allowYAxisUnit && (
<DashboardYAxisUnitSelectorWrapper
onSelect={setYAxisUnit}
value={yAxisUnit || ''}
fieldLabel={
selectedGraphType === PanelDisplay.VALUE ||
selectedGraphType === PanelDisplay.PIE
? 'Unit'
: 'Y Axis Unit'
</AutoComplete>
<Typography.Text className="section-heading">Description</Typography.Text>
<TextArea
placeholder="Enter the panel description here..."
bordered
allowClear
value={description}
onChange={(event): void =>
onChangeHandler(setDescription, event.target.value)
}
// Only update the y-axis unit value automatically in create mode
shouldUpdateYAxisUnit={isNewDashboard}
rootClassName="description-input"
/>
)}
</section>
</SettingsSection>
{allowDecimalPrecision && (
<section className="decimal-precision-selector">
<Typography.Text className="typography">
Decimal Precision
</Typography.Text>
<section className="panel-config">
<SettingsSection
title="Visualization"
defaultOpen
icon={<LayoutDashboard size={14} />}
>
<section className="panel-type control-container">
<Typography.Text className="section-heading">Panel Type</Typography.Text>
<Select
options={[
{ label: '0 decimals', value: PrecisionOptionsEnum.ZERO },
{ label: '1 decimal', value: PrecisionOptionsEnum.ONE },
{ label: '2 decimals', value: PrecisionOptionsEnum.TWO },
{ label: '3 decimals', value: PrecisionOptionsEnum.THREE },
{ label: '4 decimals', value: PrecisionOptionsEnum.FOUR },
{ label: 'Full Precision', value: PrecisionOptionsEnum.FULL },
]}
value={decimalPrecision}
style={{ width: '100%' }}
onChange={setGraphHandler}
value={selectedGraph}
className="panel-type-select"
defaultValue={PrecisionOptionsEnum.TWO}
onChange={(val: PrecisionOption): void => setDecimalPrecision(val)}
/>
data-testid="panel-change-select"
data-stacking-state={stackedBarChart ? 'true' : 'false'}
>
{graphTypes.map((item) => (
<Option key={item.name} value={item.name}>
<div className="select-option">
<div className="icon">{item.icon}</div>
<Typography.Text className="display">{item.display}</Typography.Text>
</div>
</Option>
))}
</Select>
</section>
)}
{allowSoftMinMax && (
<section className="soft-min-max">
<section className="container">
<Typography.Text className="text">Soft Min</Typography.Text>
<InputNumber
type="number"
value={softMin}
onChange={softMinHandler}
rootClassName="input"
{allowPanelTimePreference && (
<section className="panel-time-preference control-container">
<Typography.Text className="section-heading">
Panel Time Preference
</Typography.Text>
<TimePreference
{...{
selectedTime,
setSelectedTime,
}}
/>
</section>
<section className="container">
<Typography.Text className="text">Soft Max</Typography.Text>
<InputNumber
value={softMax}
type="number"
rootClassName="input"
onChange={softMaxHandler}
)}
{allowStackingBarChart && (
<section className="stack-chart control-container">
<Typography.Text className="section-heading">
Stack series
</Typography.Text>
<Switch
checked={stackedBarChart}
size="small"
onChange={(checked): void => setStackedBarChart(checked)}
/>
</section>
</section>
)}
{allowFillSpans && (
<section className="fill-gaps toggle-card">
<div className="toggle-card-text-container">
<Typography className="section-heading">Fill gaps</Typography>
<Typography.Text className="toggle-card-description">
Fill gaps in data with 0 for continuity
</Typography.Text>
</div>
<Switch
checked={isFillSpans}
size="small"
onChange={(checked): void => setIsFillSpans(checked)}
/>
</section>
)}
</SettingsSection>
{isFormattingSectionVisible && (
<SettingsSection
title="Formatting & Units"
icon={<SlidersHorizontal size={14} />}
>
{allowYAxisUnit && (
<DashboardYAxisUnitSelectorWrapper
onSelect={setYAxisUnit}
value={yAxisUnit || ''}
fieldLabel={
selectedGraphType === PanelDisplay.VALUE ||
selectedGraphType === PanelDisplay.PIE
? 'Unit'
: 'Y Axis Unit'
}
// Only update the y-axis unit value automatically in create mode
shouldUpdateYAxisUnit={isNewDashboard}
/>
)}
{allowDecimalPrecision && (
<section className="decimal-precision-selector control-container">
<Typography.Text className="typography">
Decimal Precision
</Typography.Text>
<Select
options={decimapPrecisionOptions}
value={decimalPrecision}
style={{ width: '100%' }}
className="panel-type-select"
defaultValue={PrecisionOptionsEnum.TWO}
onChange={(val: PrecisionOption): void => setDecimalPrecision(val)}
/>
</section>
)}
{allowPanelColumnPreference && (
<ColumnUnitSelector
columnUnits={columnUnits}
setColumnUnits={setColumnUnits}
isNewDashboard={isNewDashboard}
/>
)}
</SettingsSection>
)}
{allowStackingBarChart && (
<section className="stack-chart">
<Typography.Text className="label">Stack series</Typography.Text>
<Switch
checked={stackedBarChart}
size="small"
onChange={(checked): void => setStackedBarChart(checked)}
/>
</section>
{isChartAppearanceSectionVisible && (
<SettingsSection title="Chart Appearance" icon={<Paintbrush size={14} />}>
{allowFillMode && (
<FillModeSelector value={fillMode} onChange={setFillMode} />
)}
{allowLineStyle && (
<LineStyleSelector value={lineStyle} onChange={setLineStyle} />
)}
{allowLineInterpolation && (
<LineInterpolationSelector
value={lineInterpolation}
onChange={setLineInterpolation}
/>
)}
{allowShowPoints && (
<section className="show-points toggle-card">
<div className="toggle-card-text-container">
<Typography.Text className="section-heading">
Show points
</Typography.Text>
<Typography.Text className="toggle-card-description">
Display individual data points on the chart
</Typography.Text>
</div>
<Switch size="small" checked={showPoints} onChange={setShowPoints} />
</section>
)}
</SettingsSection>
)}
{isAxisSectionVisible && (
<SettingsSection title="Axes" icon={<Axis3D size={14} />}>
{allowSoftMinMax && (
<section className="soft-min-max">
<section className="container">
<Typography.Text className="text">Soft Min</Typography.Text>
<InputNumber
type="number"
value={softMin}
onChange={softMinHandler}
rootClassName="input"
/>
</section>
<section className="container">
<Typography.Text className="text">Soft Max</Typography.Text>
<InputNumber
value={softMax}
type="number"
rootClassName="input"
onChange={softMaxHandler}
/>
</section>
</section>
)}
{allowLogScale && (
<section className="log-scale control-container">
<Typography.Text className="section-heading">
Y Axis Scale
</Typography.Text>
<Select
onChange={(value): void =>
setIsLogScale(value === LogScale.LOGARITHMIC)
}
value={isLogScale ? LogScale.LOGARITHMIC : LogScale.LINEAR}
style={{ width: '100%' }}
className="panel-type-select"
defaultValue={LogScale.LINEAR}
>
<Option value={LogScale.LINEAR}>
<div className="select-option">
<div className="icon">
<LineChart size={16} />
</div>
<Typography.Text className="display">Linear</Typography.Text>
</div>
</Option>
<Option value={LogScale.LOGARITHMIC}>
<div className="select-option">
<div className="icon">
<Spline size={16} />
</div>
<Typography.Text className="display">Logarithmic</Typography.Text>
</div>
</Option>
</Select>
</section>
)}
</SettingsSection>
)}
{isLegendSectionVisible && (
<SettingsSection title="Legend" icon={<Layers size={14} />}>
{allowLegendPosition && (
<section className="legend-position control-container">
<Typography.Text className="section-heading">Position</Typography.Text>
<Select
onChange={(value: LegendPosition): void => setLegendPosition(value)}
value={legendPosition}
style={{ width: '100%' }}
className="panel-type-select"
defaultValue={LegendPosition.BOTTOM}
>
<Option value={LegendPosition.BOTTOM}>
<div className="select-option">
<Typography.Text className="display">Bottom</Typography.Text>
</div>
</Option>
<Option value={LegendPosition.RIGHT}>
<div className="select-option">
<Typography.Text className="display">Right</Typography.Text>
</div>
</Option>
</Select>
</section>
)}
{allowLegendColors && (
<section className="legend-colors">
<LegendColors
customLegendColors={customLegendColors}
setCustomLegendColors={setCustomLegendColors}
queryResponse={queryResponse}
/>
</section>
)}
</SettingsSection>
)}
{allowBucketConfig && (
<section className="bucket-config">
<Typography.Text className="label">Number of buckets</Typography.Text>
<InputNumber
value={bucketCount || null}
type="number"
min={0}
rootClassName="bucket-input"
placeholder="Default: 30"
onChange={(val): void => {
setBucketCount(val || 0);
}}
/>
<Typography.Text className="label bucket-size-label">
Bucket width
</Typography.Text>
<InputNumber
value={bucketWidth || null}
type="number"
precision={2}
placeholder="Default: Auto"
step={0.1}
min={0.0}
rootClassName="bucket-input"
onChange={(val): void => {
setBucketWidth(val || 0);
}}
/>
<section className="combine-hist">
<Typography.Text className="label">
Merge all series into one
<SettingsSection title="Histogram / Buckets">
<section className="bucket-config control-container">
<Typography.Text className="section-heading">
Number of buckets
</Typography.Text>
<Switch
checked={combineHistogram}
size="small"
onChange={(checked): void => setCombineHistogram(checked)}
<InputNumber
value={bucketCount || null}
type="number"
min={0}
rootClassName="bucket-input"
placeholder="Default: 30"
onChange={(val): void => {
setBucketCount(val || 0);
}}
/>
<Typography.Text className="section-heading bucket-size-label">
Bucket width
</Typography.Text>
<InputNumber
value={bucketWidth || null}
type="number"
precision={2}
placeholder="Default: Auto"
step={0.1}
min={0.0}
rootClassName="bucket-input"
onChange={(val): void => {
setBucketWidth(val || 0);
}}
/>
<section className="combine-hist">
<Typography.Text className="section-heading">
Merge all series into one
</Typography.Text>
<Switch
checked={combineHistogram}
size="small"
onChange={(checked): void => setCombineHistogram(checked)}
/>
</section>
</section>
</section>
)}
{allowLogScale && (
<section className="log-scale">
<Typography.Text className="typography">Y Axis Scale</Typography.Text>
<Select
onChange={(value): void => setIsLogScale(value === LogScale.LOGARITHMIC)}
value={isLogScale ? LogScale.LOGARITHMIC : LogScale.LINEAR}
style={{ width: '100%' }}
className="panel-type-select"
defaultValue={LogScale.LINEAR}
>
<Option value={LogScale.LINEAR}>
<div className="select-option">
<div className="icon">
<LineChart size={16} />
</div>
<Typography.Text className="display">Linear</Typography.Text>
</div>
</Option>
<Option value={LogScale.LOGARITHMIC}>
<div className="select-option">
<div className="icon">
<Spline size={16} />
</div>
<Typography.Text className="display">Logarithmic</Typography.Text>
</div>
</Option>
</Select>
</section>
)}
{allowLegendPosition && (
<section className="legend-position">
<Typography.Text className="typography">Legend Position</Typography.Text>
<Select
onChange={(value: LegendPosition): void => setLegendPosition(value)}
value={legendPosition}
style={{ width: '100%' }}
className="panel-type-select"
defaultValue={LegendPosition.BOTTOM}
>
<Option value={LegendPosition.BOTTOM}>
<div className="select-option">
<Typography.Text className="display">Bottom</Typography.Text>
</div>
</Option>
<Option value={LegendPosition.RIGHT}>
<div className="select-option">
<Typography.Text className="display">Right</Typography.Text>
</div>
</Option>
</Select>
</section>
)}
{allowLegendColors && (
<section className="legend-colors">
<LegendColors
customLegendColors={customLegendColors}
setCustomLegendColors={setCustomLegendColors}
queryResponse={queryResponse}
/>
</section>
</SettingsSection>
)}
</section>
@@ -541,17 +678,25 @@ function RightContainer({
)}
{allowContextLinks && (
<section className="context-links">
<SettingsSection
title="Context Links"
icon={<Link size={14} />}
defaultOpen={!!contextLinks.linksData.length}
>
<ContextLinks
contextLinks={contextLinks}
setContextLinks={setContextLinks}
selectedWidget={selectedWidget}
/>
</section>
</SettingsSection>
)}
{allowThreshold && (
<section>
<SettingsSection
title="Thresholds"
icon={<Antenna size={14} />}
defaultOpen={!!thresholds.length}
>
<ThresholdSelector
thresholds={thresholds}
setThresholds={setThresholds}
@@ -559,7 +704,7 @@ function RightContainer({
selectedGraph={selectedGraph}
columnUnits={columnUnits}
/>
</section>
</SettingsSection>
)}
</div>
);
@@ -615,6 +760,14 @@ export interface RightContainerProps {
setContextLinks: Dispatch<SetStateAction<ContextLinksData>>;
enableDrillDown?: boolean;
isNewDashboard: boolean;
lineInterpolation: LineInterpolation;
setLineInterpolation: Dispatch<SetStateAction<LineInterpolation>>;
fillMode: FillMode;
setFillMode: Dispatch<SetStateAction<FillMode>>;
lineStyle: LineStyle;
setLineStyle: Dispatch<SetStateAction<LineStyle>>;
showPoints: boolean;
setShowPoints: Dispatch<SetStateAction<boolean>>;
}
RightContainer.defaultProps = {

View File

@@ -36,7 +36,7 @@ const checkStackSeriesState = (
expect(getByTextUtil(container, 'Stack series')).toBeInTheDocument();
const stackSeriesSection = container.querySelector(
'section > .stack-chart',
'.stack-chart',
) as HTMLElement;
expect(stackSeriesSection).toBeInTheDocument();
@@ -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>
@@ -326,7 +326,7 @@ describe('Stacking bar in new panel', () => {
expect(getByText('Stack series')).toBeInTheDocument();
// Verify section exists
const section = container.querySelector('section > .stack-chart');
const section = container.querySelector('.stack-chart');
expect(section).toBeInTheDocument();
// Verify switch is present and enabled (ant-switch-checked)
@@ -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>,
);

View File

@@ -4,8 +4,13 @@ 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 {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@signozhq/resizable';
import { Button, Flex, Modal, Space, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
@@ -30,10 +35,14 @@ import useUrlQuery from 'hooks/useUrlQuery';
import createQueryParams from 'lib/createQueryParams';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import {
FillMode,
LineInterpolation,
LineStyle,
} from 'lib/uPlotV2/config/types';
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 +91,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 +144,6 @@ function NewWidget({
const query = useUrlQuery();
const { dashboardId } = useParams<DashboardWidgetPageParams>();
const [isNewDashboard, setIsNewDashboard] = useState<boolean>(false);
const logEventCalledRef = useRef(false);
@@ -208,6 +214,18 @@ function NewWidget({
const [legendPosition, setLegendPosition] = useState<LegendPosition>(
selectedWidget?.legendPosition || LegendPosition.BOTTOM,
);
const [lineInterpolation, setLineInterpolation] = useState<LineInterpolation>(
selectedWidget?.lineInterpolation || LineInterpolation.Spline,
);
const [fillMode, setFillMode] = useState<FillMode>(
selectedWidget?.fillMode || FillMode.None,
);
const [lineStyle, setLineStyle] = useState<LineStyle>(
selectedWidget?.lineStyle || LineStyle.Solid,
);
const [showPoints, setShowPoints] = useState<boolean>(
selectedWidget?.showPoints ?? false,
);
const [customLegendColors, setCustomLegendColors] = useState<
Record<string, string>
>(selectedWidget?.customLegendColors || {});
@@ -273,6 +291,10 @@ function NewWidget({
softMin,
softMax,
fillSpans: isFillSpans,
lineInterpolation,
fillMode,
lineStyle,
showPoints,
columnUnits,
bucketCount,
stackedBarChart,
@@ -283,11 +305,10 @@ function NewWidget({
isLogScale,
legendPosition,
customLegendColors,
columnWidths: columnWidths?.[selectedWidget?.id],
columnWidths: selectedWidget.columnWidths,
contextLinks,
};
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
columnUnits,
currentQuery,
@@ -309,9 +330,13 @@ function NewWidget({
stackedBarChart,
isLogScale,
legendPosition,
lineInterpolation,
fillMode,
lineStyle,
showPoints,
customLegendColors,
columnWidths,
contextLinks,
selectedWidget.columnWidths,
]);
const closeModal = (): void => {
@@ -444,6 +469,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 +595,9 @@ function NewWidget({
};
updateDashboardMutation.mutateAsync(dashboard, {
onSuccess: (updatedDashboard) => {
setSelectedDashboard(updatedDashboard.data);
onSuccess: () => {
setToScrollWidgetId(selectedWidget?.id || '');
safeNavigate({
pathname: generatePath(ROUTES.DASHBOARD, { dashboardId }),
});
navigateToDashboardPage();
},
});
}, [
@@ -577,9 +612,8 @@ function NewWidget({
preWidgets,
updateDashboardMutation,
widgets,
setSelectedDashboard,
setToScrollWidgetId,
safeNavigate,
navigateToDashboardPage,
dashboardId,
]);
@@ -588,12 +622,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 +661,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 +769,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);
@@ -748,7 +787,7 @@ function NewWidget({
}, [query, safeNavigate, dashboardId, currentQuery]);
return (
<Container>
<Container className="new-widget-container">
<div className="edit-header">
<div className="left-header">
<X
@@ -802,79 +841,104 @@ function NewWidget({
</div>
<PanelContainer>
<LeftContainerWrapper isDarkMode={useIsDarkMode()}>
<OverlayScrollbar>
{selectedWidget && (
<LeftContainer
selectedGraph={graphType}
selectedLogFields={selectedLogFields}
setSelectedLogFields={setSelectedLogFields}
selectedTracesFields={selectedTracesFields}
setSelectedTracesFields={setSelectedTracesFields}
selectedWidget={selectedWidget}
selectedTime={selectedTime}
requestData={requestData}
setRequestData={setRequestData}
isLoadingPanelData={isLoadingPanelData}
setQueryResponse={setQueryResponse}
enableDrillDown={enableDrillDown}
/>
)}
</OverlayScrollbar>
</LeftContainerWrapper>
<RightContainerWrapper>
<OverlayScrollbar>
<RightContainer
setGraphHandler={setGraphHandler}
title={title}
setTitle={setTitle}
description={description}
setDescription={setDescription}
stackedBarChart={stackedBarChart}
setStackedBarChart={setStackedBarChart}
opacity={opacity}
yAxisUnit={yAxisUnit}
columnUnits={columnUnits}
setColumnUnits={setColumnUnits}
bucketCount={bucketCount}
bucketWidth={bucketWidth}
combineHistogram={combineHistogram}
setCombineHistogram={setCombineHistogram}
setBucketWidth={setBucketWidth}
setBucketCount={setBucketCount}
setOpacity={setOpacity}
selectedNullZeroValue={selectedNullZeroValue}
setSelectedNullZeroValue={setSelectedNullZeroValue}
selectedGraph={graphType}
setSelectedTime={setSelectedTime}
selectedTime={selectedTime}
setYAxisUnit={setYAxisUnit}
decimalPrecision={decimalPrecision}
setDecimalPrecision={setDecimalPrecision}
thresholds={thresholds}
setThresholds={setThresholds}
selectedWidget={selectedWidget}
isFillSpans={isFillSpans}
setIsFillSpans={setIsFillSpans}
isLogScale={isLogScale}
setIsLogScale={setIsLogScale}
legendPosition={legendPosition}
setLegendPosition={setLegendPosition}
customLegendColors={customLegendColors}
setCustomLegendColors={setCustomLegendColors}
queryResponse={queryResponse}
softMin={softMin}
setSoftMin={setSoftMin}
softMax={softMax}
setSoftMax={setSoftMax}
contextLinks={contextLinks}
setContextLinks={setContextLinks}
enableDrillDown={enableDrillDown}
isNewDashboard={isNewDashboard}
/>
</OverlayScrollbar>
</RightContainerWrapper>
<ResizablePanelGroup direction="horizontal" autoSaveId="panel-editor">
<ResizablePanel
minSize={70}
maxSize={80}
defaultSize={80}
className="resizable-panel-left-container"
>
<OverlayScrollbar>
<LeftContainerWrapper isDarkMode={useIsDarkMode()}>
{selectedWidget && (
<LeftContainer
selectedDashboard={selectedDashboard}
selectedGraph={graphType}
selectedLogFields={selectedLogFields}
setSelectedLogFields={setSelectedLogFields}
selectedTracesFields={selectedTracesFields}
setSelectedTracesFields={setSelectedTracesFields}
selectedWidget={selectedWidget}
selectedTime={selectedTime}
requestData={requestData}
setRequestData={setRequestData}
isLoadingPanelData={isLoadingPanelData}
setQueryResponse={setQueryResponse}
enableDrillDown={enableDrillDown}
/>
)}
</LeftContainerWrapper>
</OverlayScrollbar>
</ResizablePanel>
<ResizableHandle withHandle className="widget-resizable-handle" />
<ResizablePanel
minSize={20}
maxSize={30}
defaultSize={20}
className="resizable-panel-right-container"
>
<OverlayScrollbar>
<RightContainerWrapper>
<RightContainer
setGraphHandler={setGraphHandler}
title={title}
setTitle={setTitle}
description={description}
setDescription={setDescription}
stackedBarChart={stackedBarChart}
setStackedBarChart={setStackedBarChart}
lineInterpolation={lineInterpolation}
setLineInterpolation={setLineInterpolation}
fillMode={fillMode}
setFillMode={setFillMode}
lineStyle={lineStyle}
setLineStyle={setLineStyle}
showPoints={showPoints}
setShowPoints={setShowPoints}
opacity={opacity}
yAxisUnit={yAxisUnit}
columnUnits={columnUnits}
setColumnUnits={setColumnUnits}
bucketCount={bucketCount}
bucketWidth={bucketWidth}
combineHistogram={combineHistogram}
setCombineHistogram={setCombineHistogram}
setBucketWidth={setBucketWidth}
setBucketCount={setBucketCount}
setOpacity={setOpacity}
selectedNullZeroValue={selectedNullZeroValue}
setSelectedNullZeroValue={setSelectedNullZeroValue}
selectedGraph={graphType}
setSelectedTime={setSelectedTime}
selectedTime={selectedTime}
setYAxisUnit={setYAxisUnit}
decimalPrecision={decimalPrecision}
setDecimalPrecision={setDecimalPrecision}
thresholds={thresholds}
setThresholds={setThresholds}
selectedWidget={selectedWidget}
isFillSpans={isFillSpans}
setIsFillSpans={setIsFillSpans}
isLogScale={isLogScale}
setIsLogScale={setIsLogScale}
legendPosition={legendPosition}
setLegendPosition={setLegendPosition}
customLegendColors={customLegendColors}
setCustomLegendColors={setCustomLegendColors}
queryResponse={queryResponse}
softMin={softMin}
setSoftMin={setSoftMin}
softMax={softMax}
setSoftMax={setSoftMax}
contextLinks={contextLinks}
setContextLinks={setContextLinks}
enableDrillDown={enableDrillDown}
isNewDashboard={isNewDashboard}
/>
</RightContainerWrapper>
</OverlayScrollbar>
</ResizablePanel>
</ResizablePanelGroup>
</PanelContainer>
<Modal
title={

View File

@@ -10,8 +10,7 @@ export const Container = styled.div`
export const RightContainerWrapper = styled(Col)`
&&& {
max-width: 400px;
width: 30%;
width: 100%;
overflow-y: auto;
}
&::-webkit-scrollbar {

View File

@@ -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 = {

View File

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

View File

@@ -1,4 +0,0 @@
.info-text {
margin-top: 8px;
padding: 8px;
}

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ interface DashboardLocalStorageVariables {
[id: string]: LocalStoreDashboardVariables;
}
interface UseDashboardVariablesFromLocalStorageReturn {
export interface UseDashboardVariablesFromLocalStorageReturn {
currentDashboard: LocalStoreDashboardVariables;
updateLocalStorageDashboardVariables: (
id: string,

View 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,
};
}

View File

@@ -11,7 +11,7 @@ export interface LocalStoreDashboardVariables {
| IDashboardVariable['selectedValue'];
}
interface UseVariablesFromUrlReturn {
export interface UseVariablesFromUrlReturn {
getUrlVariables: () => LocalStoreDashboardVariables;
setUrlVariables: (variables: LocalStoreDashboardVariables) => void;
updateUrlVariable: (

View File

@@ -107,7 +107,6 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
queryRangeMutation,
dashboardVariables,
dashboardDynamicVariables,
selectedDashboard?.data.version,
widget,
]);
};

View File

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

View File

@@ -3,14 +3,15 @@ import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { calculateWidthBasedOnStepInterval } from 'lib/uPlotV2/utils';
import uPlot, { Series } from 'uplot';
import { generateGradientFill } from '../utils/generateGradientFill';
import {
BarAlignment,
ConfigBuilder,
DrawStyle,
FillMode,
LineInterpolation,
LineStyle,
SeriesProps,
VisibilityMode,
} from './types';
/**
@@ -52,7 +53,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
}: {
resolvedLineColor: string;
}): Partial<Series> {
const { lineWidth, lineStyle, lineCap, fillColor } = this.props;
const { lineWidth, lineStyle, lineCap, fillColor, fillMode } = this.props;
const lineConfig: Partial<Series> = {
stroke: resolvedLineColor,
width: lineWidth ?? DEFAULT_LINE_WIDTH,
@@ -66,12 +67,19 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
lineConfig.cap = lineCap;
}
if (fillColor) {
lineConfig.fill = fillColor;
} else if (this.props.drawStyle === DrawStyle.Bar) {
lineConfig.fill = resolvedLineColor;
const finalFillColor = fillColor ?? resolvedLineColor;
if (this.props.drawStyle === DrawStyle.Bar) {
lineConfig.fill = finalFillColor;
} else if (this.props.drawStyle === DrawStyle.Histogram) {
lineConfig.fill = `${resolvedLineColor}40`;
lineConfig.fill = `${finalFillColor}40`;
} else if (fillMode && fillMode !== FillMode.None) {
if (fillMode === FillMode.Solid) {
lineConfig.fill = finalFillColor;
} else if (fillMode === FillMode.Gradient) {
lineConfig.fill = (self: uPlot): CanvasGradient =>
generateGradientFill(self, finalFillColor, 'rgba(0, 0, 0, 0)');
}
}
return lineConfig;
@@ -159,12 +167,8 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
pointsConfig.show = pointsBuilder;
} else if (drawStyle === DrawStyle.Points) {
pointsConfig.show = true;
} else if (showPoints === VisibilityMode.Never) {
pointsConfig.show = false;
} else if (showPoints === VisibilityMode.Always) {
pointsConfig.show = true;
} else {
pointsConfig.show = false; // default to hidden
pointsConfig.show = !!showPoints;
}
return pointsConfig;

View File

@@ -2,12 +2,7 @@ import { themeColors } from 'constants/theme';
import uPlot from 'uplot';
import type { SeriesProps } from '../types';
import {
DrawStyle,
LineInterpolation,
LineStyle,
VisibilityMode,
} from '../types';
import { DrawStyle, LineInterpolation, LineStyle } from '../types';
import { POINT_SIZE_FACTOR, UPlotSeriesBuilder } from '../UPlotSeriesBuilder';
const createBaseProps = (
@@ -168,17 +163,17 @@ describe('UPlotSeriesBuilder', () => {
expect(config.points?.show).toBe(pointsBuilder);
});
it('respects VisibilityMode for point visibility when no custom pointsBuilder is given', () => {
it('respects showPoints for point visibility when no custom pointsBuilder is given', () => {
const neverPointsBuilder = new UPlotSeriesBuilder(
createBaseProps({
drawStyle: DrawStyle.Line,
showPoints: VisibilityMode.Never,
showPoints: false,
}),
);
const alwaysPointsBuilder = new UPlotSeriesBuilder(
createBaseProps({
drawStyle: DrawStyle.Line,
showPoints: VisibilityMode.Always,
showPoints: true,
}),
);

View File

@@ -122,12 +122,6 @@ export enum LineInterpolation {
StepBefore = 'stepBefore',
}
export enum VisibilityMode {
Always = 'always',
Auto = 'auto',
Never = 'never',
}
/**
* Props for configuring lines
*/
@@ -163,7 +157,13 @@ export interface BarConfig {
export interface PointsConfig {
pointColor?: string;
pointSize?: number;
showPoints?: VisibilityMode;
showPoints?: boolean;
}
export enum FillMode {
Solid = 'solid',
Gradient = 'gradient',
None = 'none',
}
export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
@@ -177,6 +177,7 @@ export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
show?: boolean;
spanGaps?: boolean;
fillColor?: string;
fillMode?: FillMode;
isDarkMode?: boolean;
stepInterval?: number;
}

View File

@@ -0,0 +1,18 @@
import uPlot from 'uplot';
export function generateGradientFill(
uPlotInstance: uPlot,
startColor: string,
endColor: string,
): CanvasGradient {
const g = uPlotInstance.ctx.createLinearGradient(
0,
0,
0,
uPlotInstance.bbox.height,
);
g.addColorStop(0, `${startColor}70`);
g.addColorStop(0.6, `${startColor}40`);
g.addColorStop(1, endColor);
return g;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,6 +36,7 @@ export const getRoutes = (
if (isWorkspaceBlocked && isAdmin) {
settings.push(
...organizationSettings(t),
...membersSettings(t),
...mySettings(t),
...billingSettings(t),
...keyboardShortcuts(t),

View File

@@ -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: (

View File

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

View File

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

View File

@@ -1,237 +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 { 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 { FlamegraphCanvasProps, SpanRect } from './types';
import { formatDuration } from './utils';
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 totalHeight = spans.length * 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,
viewStartTs,
viewEndTs,
scrollTop,
rowHeight,
selectedSpanId: firstSpanAtFetchLevel || undefined,
hoveredSpanId: hoveredSpanId ?? '',
isDarkMode,
spanRectsRef,
});
useScrollToSpan({
firstSpanAtFetchLevel,
spans,
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]);
// todo: move to a separate component/utils file
const tooltipElement = tooltipContent
? createPortal(
<div
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)',
color: '#fff',
padding: '8px 12px',
borderRadius: 4,
fontSize: 12,
fontFamily: 'Inter, sans-serif',
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
pointerEvents: 'none',
}}
>
<div
style={{
fontWeight: 600,
marginBottom: 4,
color: tooltipContent.spanColor,
}}
>
{tooltipContent.spanName}
</div>
<div>Status: {tooltipContent.status}</div>
<div>Start: {tooltipContent.startMs.toFixed(2)} ms</div>
<div>Duration: {formatDuration(tooltipContent.durationMs * 1e6)}</div>
</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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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