mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-13 00:33:17 +00:00
Compare commits
18 Commits
feat/trace
...
refactor/r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68e4a2c5de | ||
|
|
4affdeda56 | ||
|
|
99944cc1de | ||
|
|
d1bd36e88a | ||
|
|
d26d4ebd31 | ||
|
|
771e5bd287 | ||
|
|
bd33304912 | ||
|
|
ca1cc0a4ac | ||
|
|
0ed8169bad | ||
|
|
ed553fb02e | ||
|
|
47daba3c17 | ||
|
|
2b3310809a | ||
|
|
542a648cc3 | ||
|
|
61df12d126 | ||
|
|
b846faa1fa | ||
|
|
557451ed81 | ||
|
|
25c513ec2f | ||
|
|
ae71f2608a |
@@ -1,4 +1,22 @@
|
||||
services:
|
||||
init-clickhouse:
|
||||
image: clickhouse/clickhouse-server:25.5.6
|
||||
container_name: init-clickhouse
|
||||
command:
|
||||
- bash
|
||||
- -c
|
||||
- |
|
||||
version="v0.0.1"
|
||||
node_os=$$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
node_arch=$$(uname -m | sed s/aarch64/arm64/ | sed s/x86_64/amd64/)
|
||||
echo "Fetching histogram-binary for $${node_os}/$${node_arch}"
|
||||
cd /tmp
|
||||
wget -O histogram-quantile.tar.gz "https://github.com/SigNoz/signoz/releases/download/histogram-quantile%2F$${version}/histogram-quantile_$${node_os}_$${node_arch}.tar.gz"
|
||||
tar -xvzf histogram-quantile.tar.gz
|
||||
mv histogram-quantile /var/lib/clickhouse/user_scripts/histogramQuantile
|
||||
restart: on-failure
|
||||
volumes:
|
||||
- ${PWD}/fs/tmp/var/lib/clickhouse/user_scripts/:/var/lib/clickhouse/user_scripts/
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server:25.5.6
|
||||
container_name: clickhouse
|
||||
@@ -7,6 +25,7 @@ services:
|
||||
- ${PWD}/fs/etc/clickhouse-server/users.d/users.xml:/etc/clickhouse-server/users.d/users.xml
|
||||
- ${PWD}/fs/tmp/var/lib/clickhouse/:/var/lib/clickhouse/
|
||||
- ${PWD}/fs/tmp/var/lib/clickhouse/user_scripts/:/var/lib/clickhouse/user_scripts/
|
||||
- ${PWD}/../../../deploy/common/clickhouse/custom-function.xml:/etc/clickhouse-server/custom-function.xml
|
||||
ports:
|
||||
- '127.0.0.1:8123:8123'
|
||||
- '127.0.0.1:9000:9000'
|
||||
@@ -22,7 +41,10 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
depends_on:
|
||||
- zookeeper
|
||||
init-clickhouse:
|
||||
condition: service_completed_successfully
|
||||
zookeeper:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- CLICKHOUSE_SKIP_USER_SETUP=1
|
||||
zookeeper:
|
||||
|
||||
@@ -44,4 +44,6 @@
|
||||
<shard>01</shard>
|
||||
<replica>01</replica>
|
||||
</macros>
|
||||
<user_defined_executable_functions_config>*function.xml</user_defined_executable_functions_config>
|
||||
<user_scripts_path>/var/lib/clickhouse/user_scripts/</user_scripts_path>
|
||||
</clickhouse>
|
||||
@@ -190,7 +190,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.114.1
|
||||
image: signoz/signoz:v0.115.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
# - "6060:6060" # pprof port
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.114.1
|
||||
image: signoz/signoz:v0.115.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
volumes:
|
||||
|
||||
@@ -181,7 +181,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.114.1}
|
||||
image: signoz/signoz:${VERSION:-v0.115.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -109,7 +109,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.114.1}
|
||||
image: signoz/signoz:${VERSION:-v0.115.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -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]
|
||||
@@ -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",
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
|
||||
@@ -321,6 +321,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);
|
||||
|
||||
@@ -1,6 +1,31 @@
|
||||
.custom-time-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.zoom-out-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--foreground);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
box-shadow: none;
|
||||
padding: 10px;
|
||||
height: 33px;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--bg-vanilla-100);
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.timeSelection-input {
|
||||
&:hover {
|
||||
|
||||
@@ -16,6 +16,15 @@ jest.mock('react-router-dom', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useDispatch: jest.fn(() => jest.fn()),
|
||||
useSelector: jest.fn(() => ({
|
||||
minTime: 0,
|
||||
maxTime: Date.now(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('providers/Timezone', () => {
|
||||
const actual = jest.requireActual('providers/Timezone');
|
||||
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Input, InputRef, Popover, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal';
|
||||
import {
|
||||
FixedDurationSuggestionOptions,
|
||||
@@ -17,9 +19,11 @@ import {
|
||||
RelativeDurationSuggestionOptions,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import dayjs from 'dayjs';
|
||||
import { useZoomOut } from 'hooks/useZoomOut';
|
||||
import { isValidShortHandDateTimeFormat } from 'lib/getMinMax';
|
||||
import { isZoomOutDisabled } from 'lib/zoomOutUtils';
|
||||
import { defaultTo, isFunction, noop } from 'lodash-es';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp, ZoomOut } from 'lucide-react';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { getTimeDifference, validateEpochRange } from 'utils/epochUtils';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
@@ -66,6 +70,8 @@ interface CustomTimePickerProps {
|
||||
showRecentlyUsed?: boolean;
|
||||
minTime: number;
|
||||
maxTime: number;
|
||||
/** When true, zoom-out button is hidden (e.g. in drawer/modal time selection) */
|
||||
isModalTimeSelection?: boolean;
|
||||
}
|
||||
|
||||
function CustomTimePicker({
|
||||
@@ -88,6 +94,7 @@ function CustomTimePicker({
|
||||
showRecentlyUsed = true,
|
||||
minTime,
|
||||
maxTime,
|
||||
isModalTimeSelection = false,
|
||||
}: CustomTimePickerProps): JSX.Element {
|
||||
const [
|
||||
selectedTimePlaceholderValue,
|
||||
@@ -116,6 +123,14 @@ function CustomTimePicker({
|
||||
|
||||
const [isOpenedFromFooter, setIsOpenedFromFooter] = useState(false);
|
||||
|
||||
const durationMs = (maxTime - minTime) / 1e6;
|
||||
const zoomOutDisabled = showLiveLogs || isZoomOutDisabled(durationMs);
|
||||
|
||||
const handleZoomOut = useZoomOut({
|
||||
isDisabled: zoomOutDisabled,
|
||||
urlParamsToDelete: [QueryParams.activeLogId],
|
||||
});
|
||||
|
||||
// function to get selected time in Last 1m, Last 2h, Last 3d, Last 4w format
|
||||
// 1m, 2h, 3d, 4w -> Last 1 minute, Last 2 hours, Last 3 days, Last 4 weeks
|
||||
const getSelectedTimeRangeLabelInRelativeFormat = (
|
||||
@@ -631,6 +646,23 @@ function CustomTimePicker({
|
||||
/>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
{!showLiveLogs && !isModalTimeSelection && (
|
||||
<Tooltip
|
||||
title={
|
||||
zoomOutDisabled ? 'Zoom out time range is limited to 1 month' : 'Zoom out'
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
className="zoom-out-btn"
|
||||
onClick={handleZoomOut}
|
||||
disabled={zoomOutDisabled}
|
||||
data-testid="zoom-out-btn"
|
||||
prefixIcon={<ZoomOut size={14} />}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import CustomTimePicker from '../CustomTimePicker';
|
||||
|
||||
const MS_PER_MIN = 60 * 1000;
|
||||
const NOW_MS = 1705312800000;
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
const mockSafeNavigate = jest.fn();
|
||||
const mockUrlQueryDelete = jest.fn();
|
||||
const mockUrlQuerySet = jest.fn();
|
||||
|
||||
interface MockAppState {
|
||||
globalTime: Pick<GlobalReducer, 'minTime' | 'maxTime'>;
|
||||
}
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
useDispatch: (): jest.Mock => mockDispatch,
|
||||
useSelector: (selector: (state: MockAppState) => unknown): unknown => {
|
||||
const mockState: MockAppState = {
|
||||
globalTime: {
|
||||
minTime: (NOW_MS - 15 * MS_PER_MIN) * 1e6,
|
||||
maxTime: NOW_MS * 1e6,
|
||||
},
|
||||
};
|
||||
return selector(mockState);
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
interface MockUrlQuery {
|
||||
delete: typeof mockUrlQueryDelete;
|
||||
set: typeof mockUrlQuerySet;
|
||||
get: () => null;
|
||||
toString: () => string;
|
||||
}
|
||||
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: (): MockUrlQuery => ({
|
||||
delete: mockUrlQueryDelete,
|
||||
set: mockUrlQuerySet,
|
||||
get: (): null => null,
|
||||
toString: (): string => 'relativeTime=45m',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('providers/Timezone', () => ({
|
||||
useTimezone: (): { timezone: { value: string; offset: string } } => ({
|
||||
timezone: { value: 'UTC', offset: 'UTC' },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
useLocation: (): { pathname: string } => ({ pathname: '/logs-explorer' }),
|
||||
}));
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
const defaultProps = {
|
||||
onSelect: jest.fn(),
|
||||
onError: jest.fn(),
|
||||
selectedValue: '15m',
|
||||
selectedTime: '15m',
|
||||
onValidCustomDateChange: jest.fn(),
|
||||
open: false,
|
||||
setOpen: jest.fn(),
|
||||
items: [
|
||||
{ value: '15m', label: 'Last 15 minutes' },
|
||||
{ value: '1h', label: 'Last 1 hour' },
|
||||
],
|
||||
minTime: (now - 15 * 60 * 1000) * 1e6,
|
||||
maxTime: now * 1e6,
|
||||
};
|
||||
|
||||
describe('CustomTimePicker - zoom out button', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(Date, 'now').mockReturnValue(NOW_MS);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should render zoom out button when showLiveLogs is false', () => {
|
||||
render(<CustomTimePicker {...defaultProps} showLiveLogs={false} />);
|
||||
|
||||
expect(screen.getByTestId('zoom-out-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render zoom out button when showLiveLogs is true', () => {
|
||||
render(<CustomTimePicker {...defaultProps} showLiveLogs={true} />);
|
||||
|
||||
expect(screen.queryByTestId('zoom-out-btn')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render zoom out button when isModalTimeSelection is true', () => {
|
||||
render(
|
||||
<CustomTimePicker
|
||||
{...defaultProps}
|
||||
showLiveLogs={false}
|
||||
isModalTimeSelection={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('zoom-out-btn')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call handleZoomOut when zoom out button is clicked', async () => {
|
||||
render(<CustomTimePicker {...defaultProps} showLiveLogs={false} />);
|
||||
|
||||
const zoomOutBtn = screen.getByTestId('zoom-out-btn');
|
||||
await userEvent.click(zoomOutBtn);
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalled();
|
||||
expect(mockUrlQuerySet).toHaveBeenCalledWith(QueryParams.relativeTime, '45m');
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/logs-explorer\?relativeTime=45m/),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use real ladder logic: 15m range zooms to 45m preset and updates URL', async () => {
|
||||
render(<CustomTimePicker {...defaultProps} showLiveLogs={false} />);
|
||||
|
||||
const zoomOutBtn = screen.getByTestId('zoom-out-btn');
|
||||
await userEvent.click(zoomOutBtn);
|
||||
|
||||
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.startTime);
|
||||
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.endTime);
|
||||
expect(mockUrlQuerySet).toHaveBeenCalledWith(QueryParams.relativeTime, '45m');
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/logs-explorer\?relativeTime=45m/),
|
||||
);
|
||||
expect(mockDispatch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should delete activeLogId when zoom out is clicked', async () => {
|
||||
render(<CustomTimePicker {...defaultProps} showLiveLogs={false} />);
|
||||
|
||||
const zoomOutBtn = screen.getByTestId('zoom-out-btn');
|
||||
await userEvent.click(zoomOutBtn);
|
||||
|
||||
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.activeLogId);
|
||||
});
|
||||
|
||||
it('should disable zoom button when time range is >= 1 month', () => {
|
||||
const now = Date.now();
|
||||
render(
|
||||
<CustomTimePicker
|
||||
{...defaultProps}
|
||||
minTime={(now - 31 * MS_PER_DAY) * 1e6}
|
||||
maxTime={now * 1e6}
|
||||
showLiveLogs={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const zoomOutBtn = screen.getByTestId('zoom-out-btn');
|
||||
expect(zoomOutBtn).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
.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 24px 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;
|
||||
|
||||
&.open {
|
||||
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);
|
||||
}
|
||||
}
|
||||
51
frontend/src/components/SettingsSection/SettingsSection.tsx
Normal file
51
frontend/src/components/SettingsSection/SettingsSection.tsx
Normal 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;
|
||||
@@ -1,4 +0,0 @@
|
||||
.timeline-v3-container {
|
||||
// flex: 1;
|
||||
overflow: visible;
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMeasure } from 'react-use';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import {
|
||||
getIntervals,
|
||||
getMinimumIntervalsBasedOnWidth,
|
||||
Interval,
|
||||
} from './utils';
|
||||
|
||||
import './TimelineV3.styles.scss';
|
||||
|
||||
interface ITimelineV3Props {
|
||||
startTimestamp: number;
|
||||
endTimestamp: number;
|
||||
timelineHeight: number;
|
||||
offsetTimestamp: number;
|
||||
}
|
||||
|
||||
function TimelineV3(props: ITimelineV3Props): JSX.Element {
|
||||
const {
|
||||
startTimestamp,
|
||||
endTimestamp,
|
||||
timelineHeight,
|
||||
offsetTimestamp,
|
||||
} = props;
|
||||
const [intervals, setIntervals] = useState<Interval[]>([]);
|
||||
const [ref, { width }] = useMeasure<HTMLDivElement>();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
useEffect(() => {
|
||||
const spread = endTimestamp - startTimestamp;
|
||||
if (spread < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const minIntervals = getMinimumIntervalsBasedOnWidth(width);
|
||||
const intervalisedSpread = (spread / minIntervals) * 1.0;
|
||||
const intervals = getIntervals(intervalisedSpread, spread, offsetTimestamp);
|
||||
|
||||
setIntervals(intervals);
|
||||
}, [startTimestamp, endTimestamp, width, offsetTimestamp]);
|
||||
|
||||
if (endTimestamp < startTimestamp) {
|
||||
console.error(
|
||||
'endTimestamp cannot be less than startTimestamp',
|
||||
startTimestamp,
|
||||
endTimestamp,
|
||||
);
|
||||
return <div />;
|
||||
}
|
||||
|
||||
const strokeColor = isDarkMode ? ' rgb(192,193,195,0.8)' : 'black';
|
||||
|
||||
return (
|
||||
<div ref={ref as never} className="timeline-v3-container">
|
||||
<svg
|
||||
width={width}
|
||||
height={timelineHeight * 2.5}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
overflow="visible"
|
||||
>
|
||||
{intervals &&
|
||||
intervals.length > 0 &&
|
||||
intervals.map((interval, index) => (
|
||||
<g
|
||||
transform={`translate(${(interval.percentage * width) / 100},0)`}
|
||||
key={`${interval.percentage + interval.label + index}`}
|
||||
textAnchor="middle"
|
||||
fontSize="0.6rem"
|
||||
>
|
||||
<text
|
||||
x={index === intervals.length - 1 ? -10 : 0}
|
||||
y={timelineHeight * 2}
|
||||
fill={strokeColor}
|
||||
>
|
||||
{interval.label}
|
||||
</text>
|
||||
<line y1={0} y2={timelineHeight} stroke={strokeColor} strokeWidth="1" />
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimelineV3;
|
||||
@@ -1,93 +0,0 @@
|
||||
import {
|
||||
IIntervalUnit,
|
||||
Interval,
|
||||
INTERVAL_UNITS,
|
||||
resolveTimeFromInterval,
|
||||
} from 'components/TimelineV2/utils';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
export type { Interval };
|
||||
|
||||
/** Fewer intervals than TimelineV2 for a cleaner flamegraph ruler. */
|
||||
export function getMinimumIntervalsBasedOnWidth(width: number): number {
|
||||
if (width < 640) {
|
||||
return 3;
|
||||
}
|
||||
if (width < 768) {
|
||||
return 4;
|
||||
}
|
||||
if (width < 1024) {
|
||||
return 5;
|
||||
}
|
||||
return 6;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes timeline intervals with offset-aware labels.
|
||||
* Labels reflect absolute time from trace start (offsetTimestamp + elapsed),
|
||||
* so when zoomed into a window, the first tick shows e.g. "50ms" not "0ms".
|
||||
*/
|
||||
export function getIntervals(
|
||||
intervalSpread: number,
|
||||
baseSpread: number,
|
||||
offsetTimestamp: number,
|
||||
): Interval[] {
|
||||
const integerPartString = intervalSpread.toString().split('.')[0];
|
||||
const integerPartLength = integerPartString.length;
|
||||
|
||||
const intervalSpreadNormalized =
|
||||
intervalSpread < 1.0
|
||||
? intervalSpread
|
||||
: Math.floor(Number(integerPartString) / 10 ** (integerPartLength - 1)) *
|
||||
10 ** (integerPartLength - 1);
|
||||
|
||||
// Unit must suit both: (1) tick granularity (intervalSpread) and (2) label magnitude
|
||||
// (offsetTimestamp). When zoomed deep into a trace, labels show offsetTimestamp + elapsed,
|
||||
// so we must pick a unit where that value is readable (e.g. "500.00s" not "500000.00ms").
|
||||
const valueForUnitSelection = Math.max(offsetTimestamp, intervalSpread);
|
||||
let intervalUnit: IIntervalUnit = INTERVAL_UNITS[0];
|
||||
for (let idx = INTERVAL_UNITS.length - 1; idx >= 0; idx -= 1) {
|
||||
const standardInterval = INTERVAL_UNITS[idx];
|
||||
if (valueForUnitSelection * standardInterval.multiplier >= 1) {
|
||||
intervalUnit = INTERVAL_UNITS[idx];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const intervals: Interval[] = [
|
||||
{
|
||||
label: `${toFixed(
|
||||
resolveTimeFromInterval(offsetTimestamp, intervalUnit),
|
||||
2,
|
||||
)}${intervalUnit.name}`,
|
||||
percentage: 0,
|
||||
},
|
||||
];
|
||||
|
||||
let tempBaseSpread = baseSpread;
|
||||
let elapsedIntervals = 0;
|
||||
|
||||
while (tempBaseSpread && intervals.length < 20) {
|
||||
let intervalTime: number;
|
||||
|
||||
if (tempBaseSpread <= 1.5 * intervalSpreadNormalized) {
|
||||
intervalTime = elapsedIntervals + tempBaseSpread;
|
||||
tempBaseSpread = 0;
|
||||
} else {
|
||||
intervalTime = elapsedIntervals + intervalSpreadNormalized;
|
||||
tempBaseSpread -= intervalSpreadNormalized;
|
||||
}
|
||||
|
||||
elapsedIntervals = intervalTime;
|
||||
const labelTime = offsetTimestamp + intervalTime;
|
||||
|
||||
intervals.push({
|
||||
label: `${toFixed(resolveTimeFromInterval(labelTime, intervalUnit), 2)}${
|
||||
intervalUnit.name
|
||||
}`,
|
||||
percentage: (intervalTime / baseSpread) * 100,
|
||||
});
|
||||
}
|
||||
|
||||
return intervals;
|
||||
}
|
||||
@@ -33,125 +33,6 @@ const themeColors = {
|
||||
purple: '#800080',
|
||||
cyan: '#00FFFF',
|
||||
},
|
||||
traceDetailColorsV3: {
|
||||
// Blues
|
||||
dodgerBlue: '#2F80ED',
|
||||
royalBlue: '#3366E6',
|
||||
steelBlue: '#4682B4',
|
||||
|
||||
// Teals / Cyans
|
||||
turquoise: '#00CEC9',
|
||||
lagoon: '#1ABC9C',
|
||||
cyanBright: '#22A6F2',
|
||||
|
||||
// Greens
|
||||
emeraldGreen: '#27AE60',
|
||||
mediumSeaGreen: '#3CB371',
|
||||
limeGreen: '#A3E635',
|
||||
|
||||
// Yellows / Golds
|
||||
festivalYellow: '#F2C94C',
|
||||
sunflower: '#FFD93D',
|
||||
warmAmber: '#FFCA28',
|
||||
|
||||
// Purples / Violets
|
||||
mediumPurple: '#BB6BD9',
|
||||
royalPurple: '#9B51E0',
|
||||
orchid: '#DA77F2',
|
||||
|
||||
// Accent
|
||||
neonViolet: '#C77DFF',
|
||||
electricPurple: '#6C5CE7',
|
||||
arcticBlue: '#48DBFB',
|
||||
|
||||
// Blues extended
|
||||
blue1: '#1F63E0',
|
||||
blue2: '#3A7AED',
|
||||
blue3: '#5A9DF5',
|
||||
blue4: '#2874A6',
|
||||
blue5: '#2E86C1',
|
||||
blue6: '#3498DB',
|
||||
|
||||
// Cyans
|
||||
cyan1: '#00B0AA',
|
||||
cyan2: '#33D6C2',
|
||||
cyan3: '#66E9DA',
|
||||
|
||||
// Greens extended
|
||||
green1: '#1E8449',
|
||||
green2: '#2ECC71',
|
||||
green3: '#58D68D',
|
||||
green4: '#229954',
|
||||
green5: '#27AE60',
|
||||
green6: '#52BE80',
|
||||
|
||||
// Forest
|
||||
forest1: '#27AE60',
|
||||
forest2: '#2ECC71',
|
||||
forest3: '#58D68D',
|
||||
|
||||
// Lime
|
||||
lime1: '#A3E635',
|
||||
lime2: '#B9F18D',
|
||||
lime3: '#D4FFB0',
|
||||
|
||||
// Teals
|
||||
teal1: '#009688',
|
||||
teal2: '#1ABC9C',
|
||||
teal3: '#48C9B0',
|
||||
teal4: '#1ABC9C',
|
||||
teal5: '#48C9B0',
|
||||
teal6: '#76D7C4',
|
||||
|
||||
// Yellows
|
||||
yellow1: '#F1C40F',
|
||||
yellow2: '#F7DC6F',
|
||||
yellow3: '#F9E79F',
|
||||
|
||||
// Gold
|
||||
gold1: '#F39C12',
|
||||
gold2: '#F1C40F',
|
||||
gold3: '#F7DC6F',
|
||||
gold4: '#B7950B',
|
||||
gold5: '#F1C40F',
|
||||
gold6: '#F4D03F',
|
||||
|
||||
// Mustard
|
||||
mustard1: '#F1C40F',
|
||||
mustard2: '#F7DC6F',
|
||||
mustard3: '#F9E79F',
|
||||
|
||||
// Aqua
|
||||
aqua1: '#00BFFF',
|
||||
aqua2: '#1E90FF',
|
||||
aqua3: '#63B8FF',
|
||||
|
||||
// Purple extended
|
||||
purple1: '#8E44AD',
|
||||
purple2: '#9B59B6',
|
||||
purple3: '#BB8FCE',
|
||||
|
||||
violet1: '#8E44AD',
|
||||
violet2: '#9B59B6',
|
||||
violet3: '#BB8FCE',
|
||||
violet4: '#7D3C98',
|
||||
violet5: '#8E44AD',
|
||||
violet6: '#9B59B6',
|
||||
|
||||
// Lavender
|
||||
lavender1: '#9B59B6',
|
||||
lavender2: '#AF7AC5',
|
||||
lavender3: '#C39BD3',
|
||||
|
||||
// Oranges (safe ones, not red-ish)
|
||||
orange4: '#D35400',
|
||||
orange5: '#E67E22',
|
||||
orange6: '#EB984E',
|
||||
|
||||
coral1: '#E67E22',
|
||||
coral2: '#F39C12',
|
||||
coral3: '#F5B041',
|
||||
},
|
||||
chartcolors: {
|
||||
// Blues (3)
|
||||
dodgerBlue: '#2F80ED',
|
||||
|
||||
@@ -30,14 +30,15 @@ export default function CustomDomainEditModal({
|
||||
onClearError,
|
||||
onSubmit,
|
||||
}: CustomDomainEditModalProps): JSX.Element {
|
||||
const [value, setValue] = useState(customDomainSubdomain ?? '');
|
||||
const initialSubdomain = customDomainSubdomain ?? '';
|
||||
const [value, setValue] = useState(initialSubdomain);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setValue(customDomainSubdomain ?? '');
|
||||
setValue(initialSubdomain);
|
||||
}
|
||||
}, [isOpen, customDomainSubdomain]);
|
||||
}, [isOpen, initialSubdomain]);
|
||||
|
||||
const handleClose = (): void => {
|
||||
setValidationError(null);
|
||||
@@ -58,6 +59,11 @@ export default function CustomDomainEditModal({
|
||||
};
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
if (value === initialSubdomain) {
|
||||
setValidationError('Input is unchanged');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
setValidationError('This field is required');
|
||||
return;
|
||||
@@ -84,7 +90,7 @@ export default function CustomDomainEditModal({
|
||||
|
||||
const hasError = Boolean(errorMessage);
|
||||
|
||||
const statusIcon = ((): JSX.Element => {
|
||||
const statusIcon = ((): JSX.Element | null => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<LoaderCircle size={16} className="animate-spin edit-modal-status-icon" />
|
||||
@@ -95,7 +101,9 @@ export default function CustomDomainEditModal({
|
||||
return <CircleAlert size={16} color={Color.BG_CHERRY_500} />;
|
||||
}
|
||||
|
||||
return <CircleCheck size={16} color={Color.BG_FOREST_500} />;
|
||||
return value && value.length >= 3 ? (
|
||||
<CircleCheck size={16} color={Color.BG_FOREST_500} />
|
||||
) : null;
|
||||
})();
|
||||
|
||||
return (
|
||||
@@ -189,7 +197,7 @@ export default function CustomDomainEditModal({
|
||||
color="primary"
|
||||
className="edit-modal-apply-btn"
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || value === initialSubdomain}
|
||||
loading={isLoading}
|
||||
>
|
||||
Apply Changes
|
||||
|
||||
@@ -81,6 +81,10 @@
|
||||
padding-left: 26px;
|
||||
}
|
||||
|
||||
.custom-domain-card-meta-row.workspace-name-hidden {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.custom-domain-card-meta-timezone {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -117,32 +121,6 @@
|
||||
background: var(--l2-border);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.custom-domain-card-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-5);
|
||||
padding: var(--padding-3);
|
||||
}
|
||||
|
||||
.custom-domain-card-license {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.custom-domain-plan-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-url-trigger {
|
||||
|
||||
@@ -69,8 +69,9 @@ function DomainUpdateToast({
|
||||
}
|
||||
|
||||
export default function CustomDomainSettings(): JSX.Element {
|
||||
const { org, activeLicense } = useAppContext();
|
||||
const { org } = useAppContext();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isPollingEnabled, setIsPollingEnabled] = useState(false);
|
||||
const [hosts, setHosts] = useState<ZeustypesHostDTO[] | null>(null);
|
||||
@@ -175,7 +176,8 @@ export default function CustomDomainSettings(): JSX.Element {
|
||||
[hosts, activeHost],
|
||||
);
|
||||
|
||||
const planName = activeLicense?.plan?.name;
|
||||
const workspaceName =
|
||||
org?.[0]?.displayName || customDomainSubdomain || activeHost?.name;
|
||||
|
||||
if (isLoadingHosts) {
|
||||
return (
|
||||
@@ -191,106 +193,98 @@ export default function CustomDomainSettings(): JSX.Element {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="custom-domain-card">
|
||||
<div className="custom-domain-card-top">
|
||||
<div className="custom-domain-card-info">
|
||||
<div className="custom-domain-card-top">
|
||||
<div className="custom-domain-card-info">
|
||||
{!!workspaceName && (
|
||||
<div className="custom-domain-card-name-row">
|
||||
<span className="beacon" />
|
||||
<span className="custom-domain-card-org-name">
|
||||
{org?.[0]?.displayName ? org?.[0]?.displayName : customDomainSubdomain}
|
||||
</span>
|
||||
<span className="custom-domain-card-org-name">{workspaceName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="custom-domain-card-meta-row">
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
dropdownRender={(): JSX.Element => (
|
||||
<div className="workspace-url-dropdown">
|
||||
<span className="workspace-url-dropdown-header">
|
||||
All Workspace URLs
|
||||
</span>
|
||||
<div className="workspace-url-dropdown-divider" />
|
||||
{sortedHosts.map((host) => {
|
||||
const isActive = host.name === activeHost?.name;
|
||||
return (
|
||||
<a
|
||||
key={host.name}
|
||||
href={host.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`workspace-url-dropdown-item${
|
||||
isActive ? ' workspace-url-dropdown-item--active' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="workspace-url-dropdown-item-label">
|
||||
{stripProtocol(host.url ?? '')}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<Check size={14} className="workspace-url-dropdown-item-check" />
|
||||
) : (
|
||||
<ExternalLink
|
||||
size={12}
|
||||
className="workspace-url-dropdown-item-external"
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
className="workspace-url-trigger"
|
||||
disabled={isFetchingHosts}
|
||||
>
|
||||
<Link2 size={12} />
|
||||
<span>{stripProtocol(activeHost?.url ?? '')}</span>
|
||||
<ChevronDown size={12} />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<span className="custom-domain-card-meta-timezone">
|
||||
<Clock size={11} />
|
||||
{timezone.offset}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
className="custom-domain-edit-button"
|
||||
prefixIcon={<FilePenLine size={12} />}
|
||||
disabled={isFetchingHosts || isPollingEnabled}
|
||||
onClick={(): void => setIsEditModalOpen(true)}
|
||||
<div
|
||||
className={`custom-domain-card-meta-row ${
|
||||
!workspaceName ? 'workspace-name-hidden' : ''
|
||||
}`}
|
||||
>
|
||||
Edit workspace link
|
||||
</Button>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
dropdownRender={(): JSX.Element => (
|
||||
<div className="workspace-url-dropdown">
|
||||
<span className="workspace-url-dropdown-header">
|
||||
All Workspace URLs
|
||||
</span>
|
||||
<div className="workspace-url-dropdown-divider" />
|
||||
{sortedHosts.map((host) => {
|
||||
const isActive = host.name === activeHost?.name;
|
||||
return (
|
||||
<a
|
||||
key={host.name}
|
||||
href={host.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`workspace-url-dropdown-item${
|
||||
isActive ? ' workspace-url-dropdown-item--active' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="workspace-url-dropdown-item-label">
|
||||
{stripProtocol(host.url ?? '')}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<Check size={14} className="workspace-url-dropdown-item-check" />
|
||||
) : (
|
||||
<ExternalLink
|
||||
size={12}
|
||||
className="workspace-url-dropdown-item-external"
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
className="workspace-url-trigger"
|
||||
disabled={isFetchingHosts}
|
||||
>
|
||||
<Link2 size={12} />
|
||||
<span>{stripProtocol(activeHost?.url ?? '')}</span>
|
||||
<ChevronDown size={12} />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<span className="custom-domain-card-meta-timezone">
|
||||
<Clock size={11} />
|
||||
{timezone.offset}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPollingEnabled && (
|
||||
<Callout
|
||||
type="info"
|
||||
showIcon
|
||||
className="custom-domain-callout"
|
||||
size="small"
|
||||
icon={<SolidAlertCircle size={13} color="primary" />}
|
||||
message={`Updating your URL to ⎯ ${customDomainSubdomain}.${dnsSuffix}. This may take a few mins.`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="custom-domain-card-divider" />
|
||||
|
||||
<div className="custom-domain-card-bottom">
|
||||
<span className="beacon" />
|
||||
<span className="custom-domain-card-license">
|
||||
{planName && <code className="custom-domain-plan-badge">{planName}</code>}{' '}
|
||||
license is currently active
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
className="custom-domain-edit-button"
|
||||
prefixIcon={<FilePenLine size={12} />}
|
||||
disabled={isFetchingHosts || isPollingEnabled}
|
||||
onClick={(): void => setIsEditModalOpen(true)}
|
||||
>
|
||||
Edit workspace link
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isPollingEnabled && (
|
||||
<Callout
|
||||
type="info"
|
||||
showIcon
|
||||
className="custom-domain-callout"
|
||||
size="small"
|
||||
icon={<SolidAlertCircle size={13} color="primary" />}
|
||||
message={`Updating your URL to ⎯ ${customDomainSubdomain}.${dnsSuffix}. This may take a few mins.`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CustomDomainEditModal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={(): void => setIsEditModalOpen(false)}
|
||||
|
||||
@@ -239,4 +239,87 @@ describe('CustomDomainSettings', () => {
|
||||
const { container } = render(toastRenderer('test-id'));
|
||||
expect(container).toHaveTextContent(/myteam\.test\.cloud/i);
|
||||
});
|
||||
|
||||
describe('Workspace Name rendering', () => {
|
||||
it('renders org displayName when available from appContext', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<CustomDomainSettings />, undefined, {
|
||||
appContextOverrides: {
|
||||
org: [{ id: 'xyz', displayName: 'My Org Name', createdAt: 0 }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(await screen.findByText('My Org Name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to customDomainSubdomain when org displayName is missing', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<CustomDomainSettings />, undefined, {
|
||||
appContextOverrides: { org: [] },
|
||||
});
|
||||
|
||||
expect(await screen.findByText('custom-host')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to activeHost.name when neither org name nor custom domain exists', async () => {
|
||||
const onlyDefaultHostResponse = {
|
||||
...mockHostsResponse,
|
||||
data: {
|
||||
...mockHostsResponse.data,
|
||||
hosts: mockHostsResponse.data.hosts
|
||||
? [mockHostsResponse.data.hosts[0]]
|
||||
: [],
|
||||
},
|
||||
};
|
||||
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(onlyDefaultHostResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<CustomDomainSettings />, undefined, {
|
||||
appContextOverrides: { org: [] },
|
||||
});
|
||||
|
||||
// 'accepted-starfish' is the default host's name
|
||||
expect(await screen.findByText('accepted-starfish')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the card name row if workspaceName is totally falsy', async () => {
|
||||
const emptyHostsResponse = {
|
||||
...mockHostsResponse,
|
||||
data: {
|
||||
...mockHostsResponse.data,
|
||||
hosts: [],
|
||||
},
|
||||
};
|
||||
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(emptyHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const { container } = render(<CustomDomainSettings />, undefined, {
|
||||
appContextOverrides: { org: [] },
|
||||
});
|
||||
|
||||
await screen.findByRole('button', { name: /edit workspace link/i });
|
||||
|
||||
expect(
|
||||
container.querySelector('.custom-domain-card-name-row'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -199,8 +199,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(),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useScrollToWidgetIdStore } from 'providers/Dashboard/helpers/scrollToWidgetIdHelper';
|
||||
|
||||
import { useScrollWidgetIntoView } from '../useScrollWidgetIntoView';
|
||||
|
||||
jest.mock('providers/Dashboard/Dashboard');
|
||||
jest.mock('providers/Dashboard/helpers/scrollToWidgetIdHelper');
|
||||
|
||||
type MockHTMLElement = {
|
||||
scrollIntoView: jest.Mock;
|
||||
@@ -18,25 +18,35 @@ function createMockElement(): MockHTMLElement {
|
||||
}
|
||||
|
||||
describe('useScrollWidgetIntoView', () => {
|
||||
const mockedUseDashboard = useDashboard as jest.MockedFunction<
|
||||
typeof useDashboard
|
||||
const mockedUseScrollToWidgetIdStore = useScrollToWidgetIdStore as jest.MockedFunction<
|
||||
typeof useScrollToWidgetIdStore
|
||||
>;
|
||||
|
||||
let mockElement: MockHTMLElement;
|
||||
let ref: React.RefObject<HTMLDivElement>;
|
||||
let setToScrollWidgetId: jest.Mock;
|
||||
|
||||
function mockStore(toScrollWidgetId: string): void {
|
||||
const storeState = { toScrollWidgetId, setToScrollWidgetId };
|
||||
mockedUseScrollToWidgetIdStore.mockImplementation(
|
||||
(selector) =>
|
||||
selector(
|
||||
(storeState as unknown) as Parameters<typeof selector>[0],
|
||||
) as ReturnType<typeof useScrollToWidgetIdStore>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockElement = createMockElement();
|
||||
ref = ({
|
||||
current: mockElement,
|
||||
} as unknown) as React.RefObject<HTMLDivElement>;
|
||||
setToScrollWidgetId = jest.fn();
|
||||
});
|
||||
|
||||
it('scrolls into view and focuses when toScrollWidgetId matches widget id', () => {
|
||||
const setToScrollWidgetId = jest.fn();
|
||||
const mockElement = createMockElement();
|
||||
const ref = ({
|
||||
current: mockElement,
|
||||
} as unknown) as React.RefObject<HTMLDivElement>;
|
||||
|
||||
mockedUseDashboard.mockReturnValue(({
|
||||
toScrollWidgetId: 'widget-id',
|
||||
setToScrollWidgetId,
|
||||
} as unknown) as ReturnType<typeof useDashboard>);
|
||||
mockStore('widget-id');
|
||||
|
||||
renderHook(() => useScrollWidgetIntoView('widget-id', ref));
|
||||
|
||||
@@ -49,16 +59,7 @@ describe('useScrollWidgetIntoView', () => {
|
||||
});
|
||||
|
||||
it('does nothing when toScrollWidgetId does not match widget id', () => {
|
||||
const setToScrollWidgetId = jest.fn();
|
||||
const mockElement = createMockElement();
|
||||
const ref = ({
|
||||
current: mockElement,
|
||||
} as unknown) as React.RefObject<HTMLDivElement>;
|
||||
|
||||
mockedUseDashboard.mockReturnValue(({
|
||||
toScrollWidgetId: 'other-widget',
|
||||
setToScrollWidgetId,
|
||||
} as unknown) as ReturnType<typeof useDashboard>);
|
||||
mockStore('other-widget');
|
||||
|
||||
renderHook(() => useScrollWidgetIntoView('widget-id', ref));
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { RefObject, useEffect } from 'react';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useScrollToWidgetIdStore } from 'providers/Dashboard/helpers/scrollToWidgetIdHelper';
|
||||
|
||||
/**
|
||||
* Scrolls the given widget container into view when the dashboard
|
||||
@@ -11,7 +11,10 @@ export function useScrollWidgetIntoView<T extends HTMLElement>(
|
||||
widgetId: string,
|
||||
widgetContainerRef: RefObject<T>,
|
||||
): void {
|
||||
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
|
||||
const toScrollWidgetId = useScrollToWidgetIdStore((s) => s.toScrollWidgetId);
|
||||
const setToScrollWidgetId = useScrollToWidgetIdStore(
|
||||
(s) => s.setToScrollWidgetId,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (toScrollWidgetId === widgetId) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useScrollWidgetIntoView } from 'container/DashboardContainer/visualization/hooks/useScrollWidgetIntoView';
|
||||
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
@@ -34,8 +33,6 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
useScrollWidgetIntoView(widget.id, graphRef);
|
||||
|
||||
useEffect((): void => {
|
||||
const { startTime, endTime } = getTimeRange(queryResponse);
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useScrollWidgetIntoView } from 'container/DashboardContainer/visualization/hooks/useScrollWidgetIntoView';
|
||||
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
@@ -32,8 +31,6 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
useScrollWidgetIntoView(widget.id, graphRef);
|
||||
|
||||
const config = useMemo(() => {
|
||||
return prepareHistogramPanelConfig({
|
||||
widget,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
|
||||
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
|
||||
import { usePanelContextMenu } from 'container/DashboardContainer/visualization/hooks/usePanelContextMenu';
|
||||
import { useScrollWidgetIntoView } from 'container/DashboardContainer/visualization/hooks/useScrollWidgetIntoView';
|
||||
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
@@ -33,8 +32,6 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
useScrollWidgetIntoView(widget.id, graphRef);
|
||||
|
||||
useEffect((): void => {
|
||||
const { startTime, endTime } = getTimeRange(queryResponse);
|
||||
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import setRetentionApi from 'api/settings/setRetention';
|
||||
import setRetentionApiV2 from 'api/settings/setRetentionV2';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import CustomDomainSettings from 'container/CustomDomainSettings';
|
||||
import LicenseKeyRow from 'container/GeneralSettings/LicenseKeyRow/LicenseKeyRow';
|
||||
import GeneralSettingsCloud from 'container/GeneralSettingsCloud';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
@@ -81,7 +82,7 @@ function GeneralSettings({
|
||||
logsTtlValuesPayload,
|
||||
);
|
||||
|
||||
const { user } = useAppContext();
|
||||
const { user, activeLicense } = useAppContext();
|
||||
|
||||
const [setRetentionPermission] = useComponentPermission(
|
||||
['set_retention_period'],
|
||||
@@ -680,7 +681,15 @@ function GeneralSettings({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showCustomDomainSettings && <CustomDomainSettings />}
|
||||
{(showCustomDomainSettings || activeLicense?.key) && (
|
||||
<div className="custom-domain-card">
|
||||
{showCustomDomainSettings && <CustomDomainSettings />}
|
||||
{showCustomDomainSettings && activeLicense?.key && (
|
||||
<div className="custom-domain-card-divider" />
|
||||
)}
|
||||
{activeLicense?.key && <LicenseKeyRow />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="retention-controls-container">
|
||||
<div className="retention-controls-header">
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
.license-key-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--padding-2) var(--padding-3);
|
||||
gap: var(--spacing-5);
|
||||
|
||||
&__left {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--l2-foreground);
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__value {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
&__code {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 2px;
|
||||
border-radius: 2px 0 0 2px;
|
||||
background: var(--l3-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'SF Mono', 'Fira Code', 'Fira Mono', monospace;
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
white-space: nowrap;
|
||||
margin-right: -1px;
|
||||
}
|
||||
|
||||
&__copy-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
padding: 1px 2px;
|
||||
border-radius: 0 2px 2px 0;
|
||||
background: var(--l3-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l2-foreground);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
height: 24px;
|
||||
|
||||
&:hover {
|
||||
background: var(--l3-background-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Copy, KeyRound } from '@signozhq/icons';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { getMaskedKey } from 'utils/maskedKey';
|
||||
|
||||
import './LicenseKeyRow.styles.scss';
|
||||
|
||||
function LicenseKeyRow(): JSX.Element | null {
|
||||
const { activeLicense } = useAppContext();
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
if (!activeLicense?.key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleCopyLicenseKey = (text: string): void => {
|
||||
copyToClipboard(text);
|
||||
toast.success('License key copied to clipboard.', { richColors: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="license-key-row">
|
||||
<span className="license-key-row__left">
|
||||
<KeyRound size={14} />
|
||||
<span className="license-key-row__label">SigNoz License Key</span>
|
||||
</span>
|
||||
<span className="license-key-row__value">
|
||||
<code className="license-key-row__code">
|
||||
{getMaskedKey(activeLicense.key)}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
aria-label="Copy license key"
|
||||
data-testid="license-key-row-copy-btn"
|
||||
className="license-key-row__copy-btn"
|
||||
onClick={(): void => handleCopyLicenseKey(activeLicense.key)}
|
||||
>
|
||||
<Copy size={12} />
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LicenseKeyRow;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import LicenseKeyRow from '../LicenseKeyRow';
|
||||
|
||||
const mockCopyToClipboard = jest.fn();
|
||||
|
||||
jest.mock('react-use', () => ({
|
||||
__esModule: true,
|
||||
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopyToClipboard],
|
||||
}));
|
||||
|
||||
const mockToastSuccess = jest.fn();
|
||||
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: {
|
||||
success: (...args: unknown[]): unknown => mockToastSuccess(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('LicenseKeyRow', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders nothing when activeLicense key is absent', () => {
|
||||
const { container } = render(<LicenseKeyRow />, undefined, {
|
||||
appContextOverrides: { activeLicense: null },
|
||||
});
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders label and masked key when activeLicense key exists', () => {
|
||||
render(<LicenseKeyRow />, undefined, {
|
||||
appContextOverrides: {
|
||||
activeLicense: { key: 'abcdefghij' } as any,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText('SigNoz License Key')).toBeInTheDocument();
|
||||
expect(screen.getByText('ab·······ij')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls copyToClipboard and shows success toast when clipboard is available', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<LicenseKeyRow />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /copy license key/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith('test-key');
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith(
|
||||
'License key copied to clipboard.',
|
||||
{
|
||||
richColors: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import logEvent from 'api/common/logEvent';
|
||||
import { DEFAULT_ENTITY_VERSION, ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useScrollWidgetIntoView } from 'container/DashboardContainer/visualization/hooks/useScrollWidgetIntoView';
|
||||
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
|
||||
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { useIsPanelWaitingOnVariable } from 'hooks/dashboard/useVariableFetchState';
|
||||
@@ -67,11 +68,7 @@ function GridCardGraph({
|
||||
const [isInternalServerError, setIsInternalServerError] = useState<boolean>(
|
||||
false,
|
||||
);
|
||||
const {
|
||||
toScrollWidgetId,
|
||||
setToScrollWidgetId,
|
||||
setDashboardQueryRangeCalled,
|
||||
} = useDashboard();
|
||||
const { setDashboardQueryRangeCalled } = useDashboard();
|
||||
|
||||
const {
|
||||
minTime,
|
||||
@@ -109,20 +106,11 @@ function GridCardGraph({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const widgetContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isVisible = useIntersectionObserver(graphRef, undefined, true);
|
||||
const isVisible = useIntersectionObserver(widgetContainerRef, undefined, true);
|
||||
|
||||
useEffect(() => {
|
||||
if (toScrollWidgetId === widget.id) {
|
||||
graphRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
graphRef.current?.focus();
|
||||
setToScrollWidgetId('');
|
||||
}
|
||||
}, [toScrollWidgetId, setToScrollWidgetId, widget.id]);
|
||||
useScrollWidgetIntoView(widget?.id || '', widgetContainerRef);
|
||||
|
||||
const updatedQuery = widget?.query;
|
||||
|
||||
@@ -306,7 +294,7 @@ function GridCardGraph({
|
||||
: headerMenuList;
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
||||
<div style={{ height: '100%', width: '100%' }} ref={widgetContainerRef}>
|
||||
{isEmptyLayout ? (
|
||||
<EmptyWidget />
|
||||
) : (
|
||||
|
||||
@@ -4,8 +4,8 @@ import { getColorsForSeverityLabels, isRedLike } from '../utils';
|
||||
|
||||
describe('getColorsForSeverityLabels', () => {
|
||||
it('should return slate for blank labels', () => {
|
||||
expect(getColorsForSeverityLabels('', 0)).toBe(Color.BG_SLATE_300);
|
||||
expect(getColorsForSeverityLabels(' ', 0)).toBe(Color.BG_SLATE_300);
|
||||
expect(getColorsForSeverityLabels('', 0)).toBe(Color.BG_VANILLA_400);
|
||||
expect(getColorsForSeverityLabels(' ', 0)).toBe(Color.BG_VANILLA_400);
|
||||
});
|
||||
|
||||
it('should return correct colors for known severity variants', () => {
|
||||
|
||||
@@ -79,7 +79,7 @@ export function getColorsForSeverityLabels(
|
||||
const trimmed = label.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return Color.BG_SLATE_300;
|
||||
return Color.BG_VANILLA_400; // Default color for empty labels
|
||||
}
|
||||
|
||||
const variantColor = SEVERITY_VARIANT_COLORS[trimmed];
|
||||
@@ -119,6 +119,6 @@ export function getColorsForSeverityLabels(
|
||||
|
||||
return (
|
||||
SAFE_FALLBACK_COLORS[index % SAFE_FALLBACK_COLORS.length] ||
|
||||
Color.BG_SLATE_400
|
||||
Color.BG_VANILLA_400
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Typography } from 'antd';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { Copy } from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { getMaskedKey } from 'utils/maskedKey';
|
||||
|
||||
import './LicenseSection.styles.scss';
|
||||
|
||||
@@ -12,15 +13,6 @@ function LicenseSection(): JSX.Element | null {
|
||||
const { notifications } = useNotifications();
|
||||
const [, handleCopyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const getMaskedKey = (key: string): string => {
|
||||
if (!key || key.length < 4) {
|
||||
return key || 'N/A';
|
||||
}
|
||||
return `${key.substring(0, 2)}********${key
|
||||
.substring(key.length - 2)
|
||||
.trim()}`;
|
||||
};
|
||||
|
||||
const handleCopyKey = (text: string): void => {
|
||||
handleCopyToClipboard(text);
|
||||
notifications.success({
|
||||
|
||||
@@ -271,7 +271,7 @@ describe('MySettings Flows', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(within(container).getByText('ab********cd')).toBeInTheDocument();
|
||||
expect(within(container).getByText('ab·······cd')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should not mask license key if it is too short', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.section-heading {
|
||||
font-family: 'Space Mono';
|
||||
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;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
padding: 14px 14px 14px 12px;
|
||||
@@ -24,86 +35,35 @@
|
||||
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-input {
|
||||
display: flex;
|
||||
padding: 6px 6px 6px 8px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1 0 0;
|
||||
align-self: stretch;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
color: var(--bg-vanilla-100);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.description-input {
|
||||
border-style: unset;
|
||||
.ant-input {
|
||||
display: flex;
|
||||
height: 80px;
|
||||
padding: 6px 6px 6px 8px;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
color: var(--bg-vanilla-100);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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 +75,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 +153,6 @@
|
||||
|
||||
.text {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: 'Space Mono';
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
@@ -278,113 +171,8 @@
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.bucket-input {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 6px 6px 6px 8px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
align-self: stretch;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
|
||||
.ant-input {
|
||||
background: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
|
||||
.combine-hist {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
cursor: pointer;
|
||||
|
||||
.left-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.bell-icon {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.alerts-text {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: 0.14px;
|
||||
}
|
||||
}
|
||||
.plus-icon {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -411,42 +199,16 @@
|
||||
.lightMode {
|
||||
.right-container {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
.section-heading {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
.header {
|
||||
.header-text {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.name-description {
|
||||
border-top: 1px solid var(--bg-vanilla-300);
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.typography {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.name-input {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
.description-input {
|
||||
.ant-input {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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,28 +233,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);
|
||||
}
|
||||
}
|
||||
|
||||
.bucket-config {
|
||||
.label {
|
||||
.toggle-card-description {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.bucket-input {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-300);
|
||||
|
||||
.ant-input {
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.panel-time-text {
|
||||
@@ -528,27 +278,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.alerts {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.left-section {
|
||||
.bell-icon {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
.alerts-text {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
.plus-icon {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
|
||||
.context-links {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
|
||||
.select-option {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
.alerts-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 12px 32px 12px;
|
||||
min-height: 44px;
|
||||
border-top: 1px solid var(--bg-slate-500);
|
||||
cursor: pointer;
|
||||
|
||||
.alerts-section__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.alerts-section__bell-icon {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.alerts-section__text {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: 0.14px;
|
||||
}
|
||||
}
|
||||
.alerts-section__plus-icon {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.alerts-section {
|
||||
border-top: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.alerts-section__left {
|
||||
.alerts-section__bell-icon {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
.alerts-section__text {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
.alerts-section__plus-icon {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Typography } from 'antd';
|
||||
import { ConciergeBell, Plus, SquareArrowOutUpRight } from 'lucide-react';
|
||||
|
||||
import './AlertsSection.styles.scss';
|
||||
|
||||
interface AlertsSectionProps {
|
||||
onCreateAlertsHandler: () => void;
|
||||
}
|
||||
|
||||
export function AlertsSection({
|
||||
onCreateAlertsHandler,
|
||||
}: AlertsSectionProps): JSX.Element {
|
||||
return (
|
||||
<section className="alerts-section" onClick={onCreateAlertsHandler}>
|
||||
<div className="alerts-section__left">
|
||||
<ConciergeBell size={14} className="alerts-section__bell-icon" />
|
||||
<Typography.Text className="alerts-section__text">Alerts</Typography.Text>
|
||||
<SquareArrowOutUpRight size={10} className="info-icon" />
|
||||
</div>
|
||||
<Plus size={14} className="alerts-section__plus-icon" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { InputNumber, Select, Typography } from 'antd';
|
||||
import SettingsSection from 'components/SettingsSection/SettingsSection';
|
||||
import { Axis3D, LineChart, Spline } from 'lucide-react';
|
||||
|
||||
enum LogScale {
|
||||
LINEAR = 'linear',
|
||||
LOGARITHMIC = 'logarithmic',
|
||||
}
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
interface AxesSectionProps {
|
||||
allowSoftMinMax: boolean;
|
||||
allowLogScale: boolean;
|
||||
softMin: number | null;
|
||||
softMax: number | null;
|
||||
setSoftMin: Dispatch<SetStateAction<number | null>>;
|
||||
setSoftMax: Dispatch<SetStateAction<number | null>>;
|
||||
isLogScale: boolean;
|
||||
setIsLogScale: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export function AxesSection({
|
||||
allowSoftMinMax,
|
||||
allowLogScale,
|
||||
softMin,
|
||||
softMax,
|
||||
setSoftMin,
|
||||
setSoftMax,
|
||||
isLogScale,
|
||||
setIsLogScale,
|
||||
}: AxesSectionProps): JSX.Element {
|
||||
const softMinHandler = (value: number | null): void => {
|
||||
setSoftMin(value);
|
||||
};
|
||||
|
||||
const softMaxHandler = (value: number | null): void => {
|
||||
setSoftMax(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { Switch, Typography } from 'antd';
|
||||
import SettingsSection from 'components/SettingsSection/SettingsSection';
|
||||
import {
|
||||
FillMode,
|
||||
LineInterpolation,
|
||||
LineStyle,
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
import { Paintbrush } from 'lucide-react';
|
||||
|
||||
import { FillModeSelector } from '../../components/FillModeSelector/FillModeSelector';
|
||||
import { LineInterpolationSelector } from '../../components/LineInterpolationSelector/LineInterpolationSelector';
|
||||
import { LineStyleSelector } from '../../components/LineStyleSelector/LineStyleSelector';
|
||||
|
||||
interface ChartAppearanceSectionProps {
|
||||
fillMode: FillMode;
|
||||
setFillMode: Dispatch<SetStateAction<FillMode>>;
|
||||
lineStyle: LineStyle;
|
||||
setLineStyle: Dispatch<SetStateAction<LineStyle>>;
|
||||
lineInterpolation: LineInterpolation;
|
||||
setLineInterpolation: Dispatch<SetStateAction<LineInterpolation>>;
|
||||
showPoints: boolean;
|
||||
setShowPoints: Dispatch<SetStateAction<boolean>>;
|
||||
allowFillMode: boolean;
|
||||
allowLineStyle: boolean;
|
||||
allowLineInterpolation: boolean;
|
||||
allowShowPoints: boolean;
|
||||
}
|
||||
|
||||
export function ChartAppearanceSection({
|
||||
fillMode,
|
||||
setFillMode,
|
||||
lineStyle,
|
||||
setLineStyle,
|
||||
lineInterpolation,
|
||||
setLineInterpolation,
|
||||
showPoints,
|
||||
setShowPoints,
|
||||
allowFillMode,
|
||||
allowLineStyle,
|
||||
allowLineInterpolation,
|
||||
allowShowPoints,
|
||||
}: ChartAppearanceSectionProps): JSX.Element {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
.context-links-section {
|
||||
padding: 12px 12px 16px 12px;
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.context-links-section {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import SettingsSection from 'components/SettingsSection/SettingsSection';
|
||||
import { Link as LinkIcon } from 'lucide-react';
|
||||
import { ContextLinksData, Widgets } from 'types/api/dashboard/getAll';
|
||||
|
||||
import ContextLinks from '../../ContextLinks';
|
||||
|
||||
import './ContextLinksSection.styles.scss';
|
||||
|
||||
interface ContextLinksSectionProps {
|
||||
contextLinks: ContextLinksData;
|
||||
setContextLinks: Dispatch<SetStateAction<ContextLinksData>>;
|
||||
selectedWidget?: Widgets;
|
||||
}
|
||||
|
||||
export function ContextLinksSection({
|
||||
contextLinks,
|
||||
setContextLinks,
|
||||
selectedWidget,
|
||||
}: ContextLinksSectionProps): JSX.Element {
|
||||
return (
|
||||
<SettingsSection
|
||||
title="Context Links"
|
||||
icon={<LinkIcon size={14} />}
|
||||
defaultOpen={!!contextLinks.linksData.length}
|
||||
>
|
||||
<div className="context-links-section">
|
||||
<ContextLinks
|
||||
contextLinks={contextLinks}
|
||||
setContextLinks={setContextLinks}
|
||||
selectedWidget={selectedWidget}
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Dispatch, SetStateAction, useMemo } from 'react';
|
||||
import { Select, Typography } from 'antd';
|
||||
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
|
||||
import SettingsSection from 'components/SettingsSection/SettingsSection';
|
||||
import { PanelDisplay } from 'constants/queryBuilder';
|
||||
import { SlidersHorizontal } from 'lucide-react';
|
||||
import { ColumnUnit } from 'types/api/dashboard/getAll';
|
||||
|
||||
import { ColumnUnitSelector } from '../../ColumnUnitSelector/ColumnUnitSelector';
|
||||
import DashboardYAxisUnitSelectorWrapper from '../../DashboardYAxisUnitSelectorWrapper';
|
||||
|
||||
interface FormattingUnitsSectionProps {
|
||||
selectedGraphType: PanelDisplay | '';
|
||||
yAxisUnit: string;
|
||||
setYAxisUnit: Dispatch<SetStateAction<string>>;
|
||||
isNewDashboard: boolean;
|
||||
decimalPrecision: PrecisionOption;
|
||||
setDecimalPrecision: Dispatch<SetStateAction<PrecisionOption>>;
|
||||
columnUnits: ColumnUnit;
|
||||
setColumnUnits: Dispatch<SetStateAction<ColumnUnit>>;
|
||||
allowYAxisUnit: boolean;
|
||||
allowDecimalPrecision: boolean;
|
||||
allowPanelColumnPreference: boolean;
|
||||
}
|
||||
|
||||
export function FormattingUnitsSection({
|
||||
selectedGraphType,
|
||||
yAxisUnit,
|
||||
setYAxisUnit,
|
||||
isNewDashboard,
|
||||
decimalPrecision,
|
||||
setDecimalPrecision,
|
||||
columnUnits,
|
||||
setColumnUnits,
|
||||
allowYAxisUnit,
|
||||
allowDecimalPrecision,
|
||||
allowPanelColumnPreference,
|
||||
}: FormattingUnitsSectionProps): JSX.Element {
|
||||
const decimapPrecisionOptions = useMemo(
|
||||
() => [
|
||||
{ label: '0 decimals', value: PrecisionOptionsEnum.ZERO },
|
||||
{ label: '1 decimal', value: PrecisionOptionsEnum.ONE },
|
||||
{ label: '2 decimals', value: PrecisionOptionsEnum.TWO },
|
||||
{ label: '3 decimals', value: PrecisionOptionsEnum.THREE },
|
||||
],
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<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'
|
||||
}
|
||||
shouldUpdateYAxisUnit={isNewDashboard}
|
||||
/>
|
||||
)}
|
||||
|
||||
{allowDecimalPrecision && (
|
||||
<section className="decimal-precision-selector control-container">
|
||||
<Typography.Text className="section-heading">
|
||||
Decimal Precision
|
||||
</Typography.Text>
|
||||
<Select
|
||||
options={decimapPrecisionOptions}
|
||||
value={decimalPrecision}
|
||||
style={{ width: '100%' }}
|
||||
className="panel-type-select"
|
||||
defaultValue={decimapPrecisionOptions[0]?.value}
|
||||
onChange={(val: PrecisionOption): void => setDecimalPrecision(val)}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{allowPanelColumnPreference && (
|
||||
<ColumnUnitSelector
|
||||
columnUnits={columnUnits}
|
||||
setColumnUnits={setColumnUnits}
|
||||
isNewDashboard={isNewDashboard}
|
||||
/>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
.general-settings__name-description {
|
||||
padding: 0 0 4px 0;
|
||||
|
||||
.general-settings__name-input {
|
||||
display: flex;
|
||||
padding: 6px 6px 6px 8px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1 0 0;
|
||||
align-self: stretch;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
color: var(--bg-vanilla-100);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.general-settings__description-input {
|
||||
border-style: unset;
|
||||
.ant-input {
|
||||
display: flex;
|
||||
height: 80px;
|
||||
padding: 6px 6px 6px 8px;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
color: var(--bg-vanilla-100);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.general-settings__name-description {
|
||||
border-top: 1px solid var(--bg-vanilla-300);
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.general-settings__name-input {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
.general-settings__description-input {
|
||||
.ant-input {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { InputRef } from 'antd';
|
||||
import { AutoComplete, Input, Typography } from 'antd';
|
||||
import SettingsSection from 'components/SettingsSection/SettingsSection';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import './GeneralSettingsSection.styles.scss';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface VariableOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface GeneralSettingsSectionProps {
|
||||
title: string;
|
||||
setTitle: Dispatch<SetStateAction<string>>;
|
||||
description: string;
|
||||
setDescription: Dispatch<SetStateAction<string>>;
|
||||
dashboardVariables: Record<string, { name?: string }>;
|
||||
}
|
||||
|
||||
export function GeneralSettingsSection({
|
||||
title,
|
||||
setTitle,
|
||||
description,
|
||||
setDescription,
|
||||
dashboardVariables,
|
||||
}: GeneralSettingsSectionProps): JSX.Element {
|
||||
const [inputValue, setInputValue] = useState(title);
|
||||
const [autoCompleteOpen, setAutoCompleteOpen] = useState(false);
|
||||
const [cursorPos, setCursorPos] = useState(0);
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
|
||||
const onChangeHandler = useCallback(
|
||||
(setFunc: Dispatch<SetStateAction<string>>, value: string) => {
|
||||
setFunc(value);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const dashboardVariableOptions = useMemo<VariableOption[]>(() => {
|
||||
return Object.entries(dashboardVariables).map(([, value]) => ({
|
||||
value: value.name || '',
|
||||
label: value.name || '',
|
||||
}));
|
||||
}, [dashboardVariables]);
|
||||
|
||||
const updateCursorAndDropdown = (value: string, pos: number): void => {
|
||||
setCursorPos(pos);
|
||||
const lastDollar = value.lastIndexOf('$', pos - 1);
|
||||
setAutoCompleteOpen(lastDollar !== -1 && pos >= lastDollar + 1);
|
||||
};
|
||||
|
||||
const onInputChange = (value: string): void => {
|
||||
setInputValue(value);
|
||||
onChangeHandler(setTitle, value);
|
||||
setTimeout(() => {
|
||||
const pos = inputRef.current?.input?.selectionStart ?? 0;
|
||||
updateCursorAndDropdown(value, pos);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const onSelect = (selectedValue: string): void => {
|
||||
const pos = cursorPos;
|
||||
const value = inputValue;
|
||||
const lastDollar = value.lastIndexOf('$', pos - 1);
|
||||
const textBeforeDollar = value.substring(0, lastDollar);
|
||||
const textAfterDollar = value.substring(lastDollar + 1);
|
||||
const match = textAfterDollar.match(/^([a-zA-Z0-9_.]*)/);
|
||||
const rest = textAfterDollar.substring(match ? match[1].length : 0);
|
||||
const newValue = `${textBeforeDollar}$${selectedValue}${rest}`;
|
||||
setInputValue(newValue);
|
||||
onChangeHandler(setTitle, newValue);
|
||||
setAutoCompleteOpen(false);
|
||||
setTimeout(() => {
|
||||
const newCursor = `${textBeforeDollar}$${selectedValue}`.length;
|
||||
inputRef.current?.input?.setSelectionRange(newCursor, newCursor);
|
||||
setCursorPos(newCursor);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const filterOption = (
|
||||
currentInputValue: string,
|
||||
option?: VariableOption,
|
||||
): boolean => {
|
||||
const pos = cursorPos;
|
||||
const value = currentInputValue;
|
||||
const lastDollar = value.lastIndexOf('$', pos - 1);
|
||||
if (lastDollar === -1) {
|
||||
return false;
|
||||
}
|
||||
const afterDollar = value.substring(lastDollar + 1, pos).toLowerCase();
|
||||
return option?.value.toLowerCase().startsWith(afterDollar) || false;
|
||||
};
|
||||
|
||||
const handleInputCursor = (): void => {
|
||||
const pos = inputRef.current?.input?.selectionStart ?? 0;
|
||||
updateCursorAndDropdown(inputValue, pos);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection title="General" defaultOpen icon={null}>
|
||||
<section className="general-settings__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="general-settings__name-input"
|
||||
ref={inputRef}
|
||||
onSelect={handleInputCursor}
|
||||
onClick={handleInputCursor}
|
||||
onBlur={(): void => setAutoCompleteOpen(false)}
|
||||
/>
|
||||
</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)
|
||||
}
|
||||
rootClassName="general-settings__description-input"
|
||||
/>
|
||||
</section>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
.histogram-settings__bucket-config {
|
||||
.histogram-settings__bucket-size-label {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.histogram-settings__bucket-input {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 6px 6px 6px 8px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
align-self: stretch;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
|
||||
.ant-input {
|
||||
background: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
|
||||
.histogram-settings__combine-hist {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
|
||||
.histogram-settings__merge-label {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.histogram-settings__bucket-config {
|
||||
.histogram-settings__merge-label {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.histogram-settings__bucket-input {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-300);
|
||||
|
||||
.ant-input {
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { InputNumber, Switch, Typography } from 'antd';
|
||||
import SettingsSection from 'components/SettingsSection/SettingsSection';
|
||||
|
||||
import './HistogramBucketsSection.styles.scss';
|
||||
|
||||
interface HistogramBucketsSectionProps {
|
||||
bucketCount: number;
|
||||
setBucketCount: Dispatch<SetStateAction<number>>;
|
||||
bucketWidth: number;
|
||||
setBucketWidth: Dispatch<SetStateAction<number>>;
|
||||
combineHistogram: boolean;
|
||||
setCombineHistogram: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export function HistogramBucketsSection({
|
||||
bucketCount,
|
||||
setBucketCount,
|
||||
bucketWidth,
|
||||
setBucketWidth,
|
||||
combineHistogram,
|
||||
setCombineHistogram,
|
||||
}: HistogramBucketsSectionProps): JSX.Element {
|
||||
return (
|
||||
<SettingsSection title="Histogram / Buckets">
|
||||
<section className="histogram-settings__bucket-config control-container">
|
||||
<Typography.Text className="section-heading">
|
||||
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="section-heading histogram-settings__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="histogram-settings__bucket-input"
|
||||
onChange={(val): void => {
|
||||
setBucketWidth(val || 0);
|
||||
}}
|
||||
/>
|
||||
<section className="histogram-settings__combine-hist">
|
||||
<Typography.Text className="section-heading">
|
||||
<span className="histogram-settings__merge-label">
|
||||
Merge all series into one
|
||||
</span>
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
checked={combineHistogram}
|
||||
size="small"
|
||||
onChange={(checked): void => setCombineHistogram(checked)}
|
||||
/>
|
||||
</section>
|
||||
</section>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import type { UseQueryResult } from 'react-query';
|
||||
import { Select, Typography } from 'antd';
|
||||
import SettingsSection from 'components/SettingsSection/SettingsSection';
|
||||
import { Layers } from 'lucide-react';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { LegendPosition } from 'types/api/dashboard/getAll';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
|
||||
import LegendColors from '../../LegendColors/LegendColors';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
interface LegendSectionProps {
|
||||
allowLegendPosition: boolean;
|
||||
allowLegendColors: boolean;
|
||||
legendPosition: LegendPosition;
|
||||
setLegendPosition: Dispatch<SetStateAction<LegendPosition>>;
|
||||
customLegendColors: Record<string, string>;
|
||||
setCustomLegendColors: Dispatch<SetStateAction<Record<string, string>>>;
|
||||
queryResponse?: UseQueryResult<
|
||||
SuccessResponse<MetricRangePayloadProps, unknown>,
|
||||
Error
|
||||
>;
|
||||
}
|
||||
|
||||
export function LegendSection({
|
||||
allowLegendPosition,
|
||||
allowLegendColors,
|
||||
legendPosition,
|
||||
setLegendPosition,
|
||||
customLegendColors,
|
||||
setCustomLegendColors,
|
||||
queryResponse,
|
||||
}: LegendSectionProps): JSX.Element {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
.thresholds-section {
|
||||
padding: 12px 12px 16px 12px;
|
||||
border-top: 1px solid var(--bg-slate-500);
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.thresholds-section {
|
||||
border-top: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import SettingsSection from 'components/SettingsSection/SettingsSection';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { Antenna } from 'lucide-react';
|
||||
import { ColumnUnit } from 'types/api/dashboard/getAll';
|
||||
|
||||
import ThresholdSelector from '../../Threshold/ThresholdSelector';
|
||||
import { ThresholdProps } from '../../Threshold/types';
|
||||
|
||||
import './ThresholdsSection.styles.scss';
|
||||
|
||||
interface ThresholdsSectionProps {
|
||||
thresholds: ThresholdProps[];
|
||||
setThresholds: Dispatch<SetStateAction<ThresholdProps[]>>;
|
||||
yAxisUnit: string;
|
||||
selectedGraph: PANEL_TYPES;
|
||||
columnUnits: ColumnUnit;
|
||||
}
|
||||
|
||||
export function ThresholdsSection({
|
||||
thresholds,
|
||||
setThresholds,
|
||||
yAxisUnit,
|
||||
selectedGraph,
|
||||
columnUnits,
|
||||
}: ThresholdsSectionProps): JSX.Element {
|
||||
return (
|
||||
<SettingsSection
|
||||
title="Thresholds"
|
||||
icon={<Antenna size={14} />}
|
||||
defaultOpen={!!thresholds.length}
|
||||
>
|
||||
<ThresholdSelector
|
||||
thresholds={thresholds}
|
||||
setThresholds={setThresholds}
|
||||
yAxisUnit={yAxisUnit}
|
||||
selectedGraph={selectedGraph}
|
||||
columnUnits={columnUnits}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
|
||||
import { Select, Switch, Typography } from 'antd';
|
||||
import SettingsSection from 'components/SettingsSection/SettingsSection';
|
||||
import TimePreference from 'components/TimePreferenceDropDown';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import GraphTypes, {
|
||||
ItemsProps,
|
||||
} from 'container/DashboardContainer/ComponentsSlider/menuItems';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { LayoutDashboard } from 'lucide-react';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { timePreferance } from '../../timeItems';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
interface VisualizationSettingsSectionProps {
|
||||
selectedGraph: PANEL_TYPES;
|
||||
setGraphHandler: (type: PANEL_TYPES) => void;
|
||||
selectedTime: timePreferance;
|
||||
setSelectedTime: Dispatch<SetStateAction<timePreferance>>;
|
||||
stackedBarChart: boolean;
|
||||
setStackedBarChart: Dispatch<SetStateAction<boolean>>;
|
||||
isFillSpans: boolean;
|
||||
setIsFillSpans: Dispatch<SetStateAction<boolean>>;
|
||||
allowPanelTimePreference: boolean;
|
||||
allowStackingBarChart: boolean;
|
||||
allowFillSpans: boolean;
|
||||
}
|
||||
|
||||
export function VisualizationSettingsSection({
|
||||
selectedGraph,
|
||||
setGraphHandler,
|
||||
selectedTime,
|
||||
setSelectedTime,
|
||||
stackedBarChart,
|
||||
setStackedBarChart,
|
||||
isFillSpans,
|
||||
setIsFillSpans,
|
||||
allowPanelTimePreference,
|
||||
allowStackingBarChart,
|
||||
allowFillSpans,
|
||||
}: VisualizationSettingsSectionProps): JSX.Element {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const [graphTypes, setGraphTypes] = useState<ItemsProps[]>(GraphTypes);
|
||||
|
||||
useEffect(() => {
|
||||
const queryContainsMetricsDataSource = currentQuery.builder.queryData.some(
|
||||
(query) => query.dataSource === DataSource.METRICS,
|
||||
);
|
||||
|
||||
if (queryContainsMetricsDataSource) {
|
||||
setGraphTypes((prev) =>
|
||||
prev.filter((graph) => graph.name !== PANEL_TYPES.LIST),
|
||||
);
|
||||
} else {
|
||||
setGraphTypes(GraphTypes);
|
||||
}
|
||||
}, [currentQuery]);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title="Visualization"
|
||||
defaultOpen
|
||||
icon={<LayoutDashboard size={14} />}
|
||||
>
|
||||
<section className="panel-type control-container">
|
||||
<Typography.Text className="section-heading">Panel Type</Typography.Text>
|
||||
<Select
|
||||
onChange={setGraphHandler}
|
||||
value={selectedGraph}
|
||||
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>
|
||||
</section>
|
||||
|
||||
{allowPanelTimePreference && (
|
||||
<section className="panel-time-preference control-container">
|
||||
<Typography.Text className="section-heading">
|
||||
Panel Time Preference
|
||||
</Typography.Text>
|
||||
<TimePreference
|
||||
{...{
|
||||
selectedTime,
|
||||
setSelectedTime,
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
.threshold-selector-container {
|
||||
padding: 12px;
|
||||
padding-bottom: 80px;
|
||||
|
||||
.threshold-select {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,11 @@ 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';
|
||||
@@ -168,6 +173,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(() => {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
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="fill-mode-label">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>
|
||||
None
|
||||
</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>
|
||||
Solid
|
||||
</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>
|
||||
Gradient
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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="line-interpolation-label">
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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="line-style-label">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>
|
||||
Solid
|
||||
</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>
|
||||
Dashed
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1,39 +1,16 @@
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Dispatch, SetStateAction, useMemo } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import type { InputRef } from 'antd';
|
||||
import {
|
||||
AutoComplete,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
|
||||
import TimePreference from 'components/TimePreferenceDropDown';
|
||||
import { Typography } from 'antd';
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import { PANEL_TYPES, PanelDisplay } from 'constants/queryBuilder';
|
||||
import GraphTypes, {
|
||||
ItemsProps,
|
||||
} from 'container/DashboardContainer/ComponentsSlider/menuItems';
|
||||
import GraphTypes from 'container/DashboardContainer/ComponentsSlider/menuItems';
|
||||
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
|
||||
import useCreateAlerts from 'hooks/queryBuilder/useCreateAlerts';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import {
|
||||
ConciergeBell,
|
||||
LineChart,
|
||||
Plus,
|
||||
Spline,
|
||||
SquareArrowOutUpRight,
|
||||
} from 'lucide-react';
|
||||
FillMode,
|
||||
LineInterpolation,
|
||||
LineStyle,
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import {
|
||||
ColumnUnit,
|
||||
@@ -42,55 +19,55 @@ import {
|
||||
Widgets,
|
||||
} from 'types/api/dashboard/getAll';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { ColumnUnitSelector } from './ColumnUnitSelector/ColumnUnitSelector';
|
||||
import {
|
||||
panelTypeVsBucketConfig,
|
||||
panelTypeVsColumnUnitPreferences,
|
||||
panelTypeVsContextLinks,
|
||||
panelTypeVsCreateAlert,
|
||||
panelTypeVsDecimalPrecision,
|
||||
panelTypeVsFillMode,
|
||||
panelTypeVsFillSpan,
|
||||
panelTypeVsLegendColors,
|
||||
panelTypeVsLegendPosition,
|
||||
panelTypeVsLineInterpolation,
|
||||
panelTypeVsLineStyle,
|
||||
panelTypeVsLogScale,
|
||||
panelTypeVsPanelTimePreferences,
|
||||
panelTypeVsShowPoints,
|
||||
panelTypeVsSoftMinMax,
|
||||
panelTypeVsStackingChartPreferences,
|
||||
panelTypeVsThreshold,
|
||||
panelTypeVsYAxisUnit,
|
||||
} from './constants';
|
||||
import ContextLinks from './ContextLinks';
|
||||
import DashboardYAxisUnitSelectorWrapper from './DashboardYAxisUnitSelectorWrapper';
|
||||
import LegendColors from './LegendColors/LegendColors';
|
||||
import ThresholdSelector from './Threshold/ThresholdSelector';
|
||||
import { AlertsSection } from './SettingSections/AlertsSection/AlertsSection';
|
||||
import { AxesSection } from './SettingSections/AxesSection/AxesSection';
|
||||
import { ChartAppearanceSection } from './SettingSections/ChartAppearanceSection/ChartAppearanceSection';
|
||||
import { ContextLinksSection } from './SettingSections/ContextLinksSection/ContextLinksSection';
|
||||
import { FormattingUnitsSection } from './SettingSections/FormattingUnitsSection/FormattingUnitsSection';
|
||||
import { GeneralSettingsSection } from './SettingSections/GeneralSettingsSection/GeneralSettingsSection';
|
||||
import { HistogramBucketsSection } from './SettingSections/HistogramBucketsSection/HistogramBucketsSection';
|
||||
import { LegendSection } from './SettingSections/LegendSection/LegendSection';
|
||||
import { ThresholdsSection } from './SettingSections/ThresholdsSection/ThresholdsSection';
|
||||
import { VisualizationSettingsSection } from './SettingSections/VisualizationSettingsSection/VisualizationSettingsSection';
|
||||
import { ThresholdProps } from './Threshold/types';
|
||||
import { timePreferance } from './timeItems';
|
||||
|
||||
import './RightContainer.styles.scss';
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Option } = Select;
|
||||
|
||||
enum LogScale {
|
||||
LINEAR = 'linear',
|
||||
LOGARITHMIC = 'logarithmic',
|
||||
}
|
||||
|
||||
interface VariableOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function RightContainer({
|
||||
description,
|
||||
setDescription,
|
||||
setTitle,
|
||||
title,
|
||||
selectedGraph,
|
||||
lineInterpolation,
|
||||
setLineInterpolation,
|
||||
fillMode,
|
||||
setFillMode,
|
||||
lineStyle,
|
||||
setLineStyle,
|
||||
showPoints,
|
||||
setShowPoints,
|
||||
bucketCount,
|
||||
bucketWidth,
|
||||
stackedBarChart,
|
||||
@@ -130,20 +107,9 @@ function RightContainer({
|
||||
isNewDashboard,
|
||||
}: RightContainerProps): JSX.Element {
|
||||
const { dashboardVariables } = useDashboardVariables();
|
||||
const [inputValue, setInputValue] = useState(title);
|
||||
const [autoCompleteOpen, setAutoCompleteOpen] = useState(false);
|
||||
const [cursorPos, setCursorPos] = useState(0);
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
|
||||
const onChangeHandler = useCallback(
|
||||
(setFunc: Dispatch<SetStateAction<string>>, value: string) => {
|
||||
setFunc(value);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const selectedGraphType =
|
||||
GraphTypes.find((e) => e.name === selectedGraph)?.display || '';
|
||||
const selectedGraphType = GraphTypes.find((e) => e.name === selectedGraph)
|
||||
?.display as PanelDisplay;
|
||||
|
||||
const onCreateAlertsHandler = useCreateAlerts(selectedWidget, 'panelView');
|
||||
|
||||
@@ -167,399 +133,145 @@ function RightContainer({
|
||||
panelTypeVsContextLinks[selectedGraph] && enableDrillDown;
|
||||
const allowDecimalPrecision = panelTypeVsDecimalPrecision[selectedGraph];
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const allowLineInterpolation = panelTypeVsLineInterpolation[selectedGraph];
|
||||
const allowLineStyle = panelTypeVsLineStyle[selectedGraph];
|
||||
const allowFillMode = panelTypeVsFillMode[selectedGraph];
|
||||
const allowShowPoints = panelTypeVsShowPoints[selectedGraph];
|
||||
|
||||
const [graphTypes, setGraphTypes] = useState<ItemsProps[]>(GraphTypes);
|
||||
const isAxisSectionVisible = useMemo(() => allowSoftMinMax || allowLogScale, [
|
||||
allowSoftMinMax,
|
||||
allowLogScale,
|
||||
]);
|
||||
|
||||
const dashboardVariableOptions = useMemo<VariableOption[]>(() => {
|
||||
return Object.entries(dashboardVariables).map(([, value]) => ({
|
||||
value: value.name || '',
|
||||
label: value.name || '',
|
||||
}));
|
||||
}, [dashboardVariables]);
|
||||
|
||||
const updateCursorAndDropdown = (value: string, pos: number): void => {
|
||||
setCursorPos(pos);
|
||||
const lastDollar = value.lastIndexOf('$', pos - 1);
|
||||
setAutoCompleteOpen(lastDollar !== -1 && pos >= lastDollar + 1);
|
||||
};
|
||||
|
||||
const onInputChange = (value: string): void => {
|
||||
setInputValue(value);
|
||||
onChangeHandler(setTitle, value);
|
||||
setTimeout(() => {
|
||||
const pos = inputRef.current?.input?.selectionStart ?? 0;
|
||||
updateCursorAndDropdown(value, pos);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleInputCursor = (): void => {
|
||||
const pos = inputRef.current?.input?.selectionStart ?? 0;
|
||||
updateCursorAndDropdown(inputValue, pos);
|
||||
};
|
||||
|
||||
const onSelect = (selectedValue: string): void => {
|
||||
const pos = cursorPos;
|
||||
const value = inputValue;
|
||||
const lastDollar = value.lastIndexOf('$', pos - 1);
|
||||
const textBeforeDollar = value.substring(0, lastDollar);
|
||||
const textAfterDollar = value.substring(lastDollar + 1);
|
||||
const match = textAfterDollar.match(/^([a-zA-Z0-9_.]*)/);
|
||||
const rest = textAfterDollar.substring(match ? match[1].length : 0);
|
||||
const newValue = `${textBeforeDollar}$${selectedValue}${rest}`;
|
||||
setInputValue(newValue);
|
||||
onChangeHandler(setTitle, newValue);
|
||||
setAutoCompleteOpen(false);
|
||||
setTimeout(() => {
|
||||
const newCursor = `${textBeforeDollar}$${selectedValue}`.length;
|
||||
inputRef.current?.input?.setSelectionRange(newCursor, newCursor);
|
||||
setCursorPos(newCursor);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const filterOption = (
|
||||
inputValue: string,
|
||||
option?: VariableOption,
|
||||
): boolean => {
|
||||
const pos = cursorPos;
|
||||
const value = inputValue;
|
||||
const lastDollar = value.lastIndexOf('$', pos - 1);
|
||||
if (lastDollar === -1) {
|
||||
return false;
|
||||
}
|
||||
const afterDollar = value.substring(lastDollar + 1, pos).toLowerCase();
|
||||
return option?.value.toLowerCase().startsWith(afterDollar) || false;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const queryContainsMetricsDataSource = currentQuery.builder.queryData.some(
|
||||
(query) => query.dataSource === DataSource.METRICS,
|
||||
);
|
||||
|
||||
if (queryContainsMetricsDataSource) {
|
||||
setGraphTypes((prev) =>
|
||||
prev.filter((graph) => graph.name !== PANEL_TYPES.LIST),
|
||||
);
|
||||
} else {
|
||||
setGraphTypes(GraphTypes);
|
||||
}
|
||||
}, [currentQuery]);
|
||||
|
||||
const softMinHandler = useCallback(
|
||||
(value: number | null) => {
|
||||
setSoftMin(value);
|
||||
},
|
||||
[setSoftMin],
|
||||
const isLegendSectionVisible = useMemo(
|
||||
() => allowLegendPosition || allowLegendColors,
|
||||
[allowLegendPosition, allowLegendColors],
|
||||
);
|
||||
|
||||
const softMaxHandler = useCallback(
|
||||
(value: number | null) => {
|
||||
setSoftMax(value);
|
||||
},
|
||||
[setSoftMax],
|
||||
const isChartAppearanceSectionVisible = useMemo(
|
||||
() =>
|
||||
allowFillMode || allowLineStyle || allowLineInterpolation || allowShowPoints,
|
||||
[allowFillMode, allowLineStyle, allowLineInterpolation, allowShowPoints],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="right-container">
|
||||
<section className="header">
|
||||
<div className="purple-dot" />
|
||||
<Typography.Text className="header-text">Panel details</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"
|
||||
/>
|
||||
<Typography.Text className="header-text">Panel Settings</Typography.Text>
|
||||
</section>
|
||||
|
||||
<GeneralSettingsSection
|
||||
title={title}
|
||||
setTitle={setTitle}
|
||||
description={description}
|
||||
setDescription={setDescription}
|
||||
dashboardVariables={dashboardVariables}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<VisualizationSettingsSection
|
||||
selectedGraph={selectedGraph}
|
||||
setGraphHandler={setGraphHandler}
|
||||
selectedTime={selectedTime}
|
||||
setSelectedTime={setSelectedTime}
|
||||
stackedBarChart={stackedBarChart}
|
||||
setStackedBarChart={setStackedBarChart}
|
||||
isFillSpans={isFillSpans}
|
||||
setIsFillSpans={setIsFillSpans}
|
||||
allowPanelTimePreference={allowPanelTimePreference}
|
||||
allowStackingBarChart={allowStackingBarChart}
|
||||
allowFillSpans={allowFillSpans}
|
||||
/>
|
||||
|
||||
{allowFillSpans && (
|
||||
<Space className="fill-gaps">
|
||||
<Typography className="fill-gaps-text">Fill gaps</Typography>
|
||||
<Switch
|
||||
checked={isFillSpans}
|
||||
size="small"
|
||||
onChange={(checked): void => setIsFillSpans(checked)}
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
<FormattingUnitsSection
|
||||
selectedGraphType={selectedGraphType}
|
||||
yAxisUnit={yAxisUnit}
|
||||
setYAxisUnit={setYAxisUnit}
|
||||
isNewDashboard={isNewDashboard}
|
||||
decimalPrecision={decimalPrecision}
|
||||
setDecimalPrecision={setDecimalPrecision}
|
||||
columnUnits={columnUnits}
|
||||
setColumnUnits={setColumnUnits}
|
||||
allowYAxisUnit={allowYAxisUnit}
|
||||
allowDecimalPrecision={allowDecimalPrecision}
|
||||
allowPanelColumnPreference={allowPanelColumnPreference}
|
||||
/>
|
||||
|
||||
{allowPanelTimePreference && (
|
||||
<>
|
||||
<Typography.Text className="panel-time-text">
|
||||
Panel Time Preference
|
||||
</Typography.Text>
|
||||
<TimePreference
|
||||
{...{
|
||||
selectedTime,
|
||||
setSelectedTime,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{allowPanelColumnPreference && (
|
||||
<ColumnUnitSelector
|
||||
columnUnits={columnUnits}
|
||||
setColumnUnits={setColumnUnits}
|
||||
isNewDashboard={isNewDashboard}
|
||||
{isChartAppearanceSectionVisible && (
|
||||
<ChartAppearanceSection
|
||||
fillMode={fillMode}
|
||||
setFillMode={setFillMode}
|
||||
lineStyle={lineStyle}
|
||||
setLineStyle={setLineStyle}
|
||||
lineInterpolation={lineInterpolation}
|
||||
setLineInterpolation={setLineInterpolation}
|
||||
showPoints={showPoints}
|
||||
setShowPoints={setShowPoints}
|
||||
allowFillMode={allowFillMode}
|
||||
allowLineStyle={allowLineStyle}
|
||||
allowLineInterpolation={allowLineInterpolation}
|
||||
allowShowPoints={allowShowPoints}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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}
|
||||
{isAxisSectionVisible && (
|
||||
<AxesSection
|
||||
allowSoftMinMax={allowSoftMinMax}
|
||||
allowLogScale={allowLogScale}
|
||||
softMin={softMin}
|
||||
softMax={softMax}
|
||||
setSoftMin={setSoftMin}
|
||||
setSoftMax={setSoftMax}
|
||||
isLogScale={isLogScale}
|
||||
setIsLogScale={setIsLogScale}
|
||||
/>
|
||||
)}
|
||||
|
||||
{allowDecimalPrecision && (
|
||||
<section className="decimal-precision-selector">
|
||||
<Typography.Text className="typography">
|
||||
Decimal Precision
|
||||
</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%' }}
|
||||
className="panel-type-select"
|
||||
defaultValue={PrecisionOptionsEnum.TWO}
|
||||
onChange={(val: PrecisionOption): void => setDecimalPrecision(val)}
|
||||
/>
|
||||
</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"
|
||||
/>
|
||||
</section>
|
||||
<section className="container">
|
||||
<Typography.Text className="text">Soft Max</Typography.Text>
|
||||
<InputNumber
|
||||
value={softMax}
|
||||
type="number"
|
||||
rootClassName="input"
|
||||
onChange={softMaxHandler}
|
||||
/>
|
||||
</section>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{allowStackingBarChart && (
|
||||
<section className="stack-chart">
|
||||
<Typography.Text className="label">Stack series</Typography.Text>
|
||||
<Switch
|
||||
checked={stackedBarChart}
|
||||
size="small"
|
||||
onChange={(checked): void => setStackedBarChart(checked)}
|
||||
/>
|
||||
</section>
|
||||
{isLegendSectionVisible && (
|
||||
<LegendSection
|
||||
allowLegendPosition={allowLegendPosition}
|
||||
allowLegendColors={allowLegendColors}
|
||||
legendPosition={legendPosition}
|
||||
setLegendPosition={setLegendPosition}
|
||||
customLegendColors={customLegendColors}
|
||||
setCustomLegendColors={setCustomLegendColors}
|
||||
queryResponse={queryResponse}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
checked={combineHistogram}
|
||||
size="small"
|
||||
onChange={(checked): void => setCombineHistogram(checked)}
|
||||
/>
|
||||
</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>
|
||||
<HistogramBucketsSection
|
||||
bucketCount={bucketCount}
|
||||
setBucketCount={setBucketCount}
|
||||
bucketWidth={bucketWidth}
|
||||
setBucketWidth={setBucketWidth}
|
||||
combineHistogram={combineHistogram}
|
||||
setCombineHistogram={setCombineHistogram}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{allowCreateAlerts && (
|
||||
<section className="alerts" onClick={onCreateAlertsHandler}>
|
||||
<div className="left-section">
|
||||
<ConciergeBell size={14} className="bell-icon" />
|
||||
<Typography.Text className="alerts-text">Alerts</Typography.Text>
|
||||
<SquareArrowOutUpRight size={10} className="info-icon" />
|
||||
</div>
|
||||
<Plus size={14} className="plus-icon" />
|
||||
</section>
|
||||
<AlertsSection onCreateAlertsHandler={onCreateAlertsHandler} />
|
||||
)}
|
||||
|
||||
{allowContextLinks && (
|
||||
<section className="context-links">
|
||||
<ContextLinks
|
||||
contextLinks={contextLinks}
|
||||
setContextLinks={setContextLinks}
|
||||
selectedWidget={selectedWidget}
|
||||
/>
|
||||
</section>
|
||||
<ContextLinksSection
|
||||
contextLinks={contextLinks}
|
||||
setContextLinks={setContextLinks}
|
||||
selectedWidget={selectedWidget}
|
||||
/>
|
||||
)}
|
||||
|
||||
{allowThreshold && (
|
||||
<section>
|
||||
<ThresholdSelector
|
||||
thresholds={thresholds}
|
||||
setThresholds={setThresholds}
|
||||
yAxisUnit={yAxisUnit}
|
||||
selectedGraph={selectedGraph}
|
||||
columnUnits={columnUnits}
|
||||
/>
|
||||
</section>
|
||||
<ThresholdsSection
|
||||
thresholds={thresholds}
|
||||
setThresholds={setThresholds}
|
||||
yAxisUnit={yAxisUnit}
|
||||
selectedGraph={selectedGraph}
|
||||
columnUnits={columnUnits}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -615,6 +327,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 = {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -30,10 +30,16 @@ 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,
|
||||
@@ -86,10 +92,12 @@ function NewWidget({
|
||||
enableDrillDown = false,
|
||||
}: NewWidgetProps): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const setToScrollWidgetId = useScrollToWidgetIdStore(
|
||||
(s) => s.setToScrollWidgetId,
|
||||
);
|
||||
const {
|
||||
selectedDashboard,
|
||||
setSelectedDashboard,
|
||||
setToScrollWidgetId,
|
||||
columnWidths,
|
||||
} = useDashboard();
|
||||
|
||||
@@ -208,6 +216,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 +293,10 @@ function NewWidget({
|
||||
softMin,
|
||||
softMax,
|
||||
fillSpans: isFillSpans,
|
||||
lineInterpolation,
|
||||
fillMode,
|
||||
lineStyle,
|
||||
showPoints,
|
||||
columnUnits,
|
||||
bucketCount,
|
||||
stackedBarChart,
|
||||
@@ -309,6 +333,10 @@ function NewWidget({
|
||||
stackedBarChart,
|
||||
isLogScale,
|
||||
legendPosition,
|
||||
lineInterpolation,
|
||||
fillMode,
|
||||
lineStyle,
|
||||
showPoints,
|
||||
customLegendColors,
|
||||
columnWidths,
|
||||
contextLinks,
|
||||
@@ -833,6 +861,14 @@ function NewWidget({
|
||||
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}
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { ToggleGraphProps } from 'components/Graph/types';
|
||||
import Uplot from 'components/Uplot';
|
||||
import GraphManager from 'container/GridCardLayout/GridCard/FullView/GraphManager';
|
||||
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
|
||||
import { getUplotClickData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import useGraphContextMenu from 'container/QueryTable/Drilldown/useGraphContextMenu';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { getUplotHistogramChartOptions } from 'lib/uPlotLib/getUplotHistogramChartOptions';
|
||||
import _noop from 'lodash-es/noop';
|
||||
import { ContextMenu, useCoordinates } from 'periscope/components/ContextMenu';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
|
||||
import { buildHistogramData } from './histogram';
|
||||
import { PanelWrapperProps } from './panelWrapper.types';
|
||||
|
||||
function HistogramPanelWrapper({
|
||||
queryResponse,
|
||||
widget,
|
||||
setGraphVisibility,
|
||||
graphVisibility,
|
||||
isFullViewMode,
|
||||
onToggleModelHandler,
|
||||
onClickHandler,
|
||||
enableDrillDown = false,
|
||||
}: PanelWrapperProps): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const legendScrollPositionRef = useRef<number>(0);
|
||||
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
const {
|
||||
coordinates,
|
||||
popoverPosition,
|
||||
clickedData,
|
||||
onClose,
|
||||
onClick,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
} = useCoordinates();
|
||||
const { menuItemsConfig } = useGraphContextMenu({
|
||||
widgetId: widget.id || '',
|
||||
query: widget.query,
|
||||
graphData: clickedData,
|
||||
onClose,
|
||||
coordinates,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
contextLinks: widget.contextLinks,
|
||||
panelType: widget.panelTypes,
|
||||
queryRange: queryResponse,
|
||||
});
|
||||
|
||||
const clickHandlerWithContextMenu = useCallback(
|
||||
(...args: any[]) => {
|
||||
const [
|
||||
,
|
||||
,
|
||||
,
|
||||
,
|
||||
metric,
|
||||
queryData,
|
||||
absoluteMouseX,
|
||||
absoluteMouseY,
|
||||
,
|
||||
focusedSeries,
|
||||
] = args;
|
||||
const data = getUplotClickData({
|
||||
metric,
|
||||
queryData,
|
||||
absoluteMouseX,
|
||||
absoluteMouseY,
|
||||
focusedSeries,
|
||||
});
|
||||
if (data && data?.record?.queryName) {
|
||||
onClick(data.coord, { ...data.record, label: data.label });
|
||||
}
|
||||
},
|
||||
[onClick],
|
||||
);
|
||||
|
||||
const histogramData = buildHistogramData(
|
||||
queryResponse.data?.payload.data.result,
|
||||
widget?.bucketWidth,
|
||||
widget?.bucketCount,
|
||||
widget?.mergeAllActiveQueries,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (toScrollWidgetId === widget.id) {
|
||||
graphRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
graphRef.current?.focus();
|
||||
setToScrollWidgetId('');
|
||||
}
|
||||
}, [toScrollWidgetId, setToScrollWidgetId, widget.id]);
|
||||
const lineChartRef = useRef<ToggleGraphProps>();
|
||||
|
||||
useEffect(() => {
|
||||
const {
|
||||
graphVisibilityStates: localStoredVisibilityState,
|
||||
} = getLocalStorageGraphVisibilityState({
|
||||
apiResponse: queryResponse.data?.payload.data.result || [],
|
||||
name: widget.id,
|
||||
});
|
||||
if (setGraphVisibility) {
|
||||
setGraphVisibility(localStoredVisibilityState);
|
||||
}
|
||||
}, [
|
||||
queryResponse?.data?.payload?.data?.result,
|
||||
setGraphVisibility,
|
||||
widget.id,
|
||||
]);
|
||||
|
||||
const histogramOptions = useMemo(
|
||||
() =>
|
||||
getUplotHistogramChartOptions({
|
||||
id: widget.id,
|
||||
dimensions: containerDimensions,
|
||||
isDarkMode,
|
||||
apiResponse: queryResponse.data?.payload,
|
||||
histogramData,
|
||||
panelType: widget.panelTypes,
|
||||
setGraphsVisibilityStates: setGraphVisibility,
|
||||
graphsVisibilityStates: graphVisibility,
|
||||
mergeAllQueries: widget.mergeAllActiveQueries,
|
||||
onClickHandler: enableDrillDown
|
||||
? clickHandlerWithContextMenu
|
||||
: onClickHandler ?? _noop,
|
||||
legendScrollPosition: legendScrollPositionRef.current,
|
||||
setLegendScrollPosition: (position: number) => {
|
||||
legendScrollPositionRef.current = position;
|
||||
},
|
||||
}),
|
||||
[
|
||||
containerDimensions,
|
||||
graphVisibility,
|
||||
histogramData,
|
||||
isDarkMode,
|
||||
queryResponse.data?.payload,
|
||||
setGraphVisibility,
|
||||
widget.id,
|
||||
widget.mergeAllActiveQueries,
|
||||
widget.panelTypes,
|
||||
clickHandlerWithContextMenu,
|
||||
enableDrillDown,
|
||||
onClickHandler,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
||||
<Uplot options={histogramOptions} data={histogramData} ref={lineChartRef} />
|
||||
<ContextMenu
|
||||
coordinates={coordinates}
|
||||
popoverPosition={popoverPosition}
|
||||
title={menuItemsConfig.header as string}
|
||||
items={menuItemsConfig.items}
|
||||
onClose={onClose}
|
||||
/>
|
||||
{isFullViewMode && setGraphVisibility && !widget.mergeAllActiveQueries && (
|
||||
<GraphManager
|
||||
data={histogramData}
|
||||
name={widget.id}
|
||||
options={histogramOptions}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
onToggleModelHandler={onToggleModelHandler}
|
||||
setGraphsVisibilityStates={setGraphVisibility}
|
||||
graphsVisibilityStates={graphVisibility}
|
||||
lineChartRef={lineChartRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HistogramPanelWrapper;
|
||||
@@ -1,4 +0,0 @@
|
||||
.info-text {
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Alert } from 'antd';
|
||||
import { ToggleGraphProps } from 'components/Graph/types';
|
||||
import Uplot from 'components/Uplot';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import GraphManager from 'container/GridCardLayout/GridCard/FullView/GraphManager';
|
||||
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
|
||||
import { getUplotClickData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import useGraphContextMenu from 'container/QueryTable/Drilldown/useGraphContextMenu';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
|
||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { cloneDeep, isEqual, isUndefined } from 'lodash-es';
|
||||
import _noop from 'lodash-es/noop';
|
||||
import { ContextMenu, useCoordinates } from 'periscope/components/ContextMenu';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import uPlot from 'uplot';
|
||||
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
|
||||
import { getTimeRange } from 'utils/getTimeRange';
|
||||
|
||||
import { PanelWrapperProps } from './panelWrapper.types';
|
||||
import { getTimeRangeFromStepInterval, isApmMetric } from './utils';
|
||||
|
||||
import './UplotPanelWrapper.styles.scss';
|
||||
|
||||
function UplotPanelWrapper({
|
||||
queryResponse,
|
||||
widget,
|
||||
isFullViewMode,
|
||||
setGraphVisibility,
|
||||
graphVisibility,
|
||||
onToggleModelHandler,
|
||||
onClickHandler,
|
||||
onDragSelect,
|
||||
selectedGraph,
|
||||
customTooltipElement,
|
||||
customSeries,
|
||||
enableDrillDown = false,
|
||||
}: PanelWrapperProps): JSX.Element {
|
||||
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const lineChartRef = useRef<ToggleGraphProps>();
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const legendScrollPositionRef = useRef<{
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}>({
|
||||
scrollTop: 0,
|
||||
scrollLeft: 0,
|
||||
});
|
||||
const [minTimeScale, setMinTimeScale] = useState<number>();
|
||||
const [maxTimeScale, setMaxTimeScale] = useState<number>();
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
const [hiddenGraph, setHiddenGraph] = useState<{ [key: string]: boolean }>();
|
||||
|
||||
useEffect(() => {
|
||||
if (toScrollWidgetId === widget.id) {
|
||||
graphRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
graphRef.current?.focus();
|
||||
setToScrollWidgetId('');
|
||||
}
|
||||
}, [toScrollWidgetId, setToScrollWidgetId, widget.id]);
|
||||
|
||||
useEffect((): void => {
|
||||
const { startTime, endTime } = getTimeRange(queryResponse);
|
||||
|
||||
setMinTimeScale(startTime);
|
||||
setMaxTimeScale(endTime);
|
||||
}, [queryResponse]);
|
||||
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
|
||||
const {
|
||||
coordinates,
|
||||
popoverPosition,
|
||||
clickedData,
|
||||
onClose,
|
||||
onClick,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
} = useCoordinates();
|
||||
const { menuItemsConfig } = useGraphContextMenu({
|
||||
widgetId: widget.id || '',
|
||||
query: widget.query,
|
||||
graphData: clickedData,
|
||||
onClose,
|
||||
coordinates,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
contextLinks: widget.contextLinks,
|
||||
panelType: widget.panelTypes,
|
||||
queryRange: queryResponse,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const {
|
||||
graphVisibilityStates: localStoredVisibilityState,
|
||||
} = getLocalStorageGraphVisibilityState({
|
||||
apiResponse: queryResponse.data?.payload.data.result || [],
|
||||
name: widget.id,
|
||||
});
|
||||
if (setGraphVisibility) {
|
||||
setGraphVisibility(localStoredVisibilityState);
|
||||
}
|
||||
}, [
|
||||
queryResponse?.data?.payload?.data?.result,
|
||||
setGraphVisibility,
|
||||
widget.id,
|
||||
]);
|
||||
|
||||
if (queryResponse.data && widget.panelTypes === PANEL_TYPES.BAR) {
|
||||
const sortedSeriesData = getSortedSeriesData(
|
||||
queryResponse.data?.payload.data.result,
|
||||
);
|
||||
queryResponse.data.payload.data.result = sortedSeriesData;
|
||||
}
|
||||
|
||||
const stackedBarChart = useMemo(
|
||||
() =>
|
||||
(selectedGraph
|
||||
? selectedGraph === PANEL_TYPES.BAR
|
||||
: widget?.panelTypes === PANEL_TYPES.BAR) && widget?.stackedBarChart,
|
||||
[selectedGraph, widget?.panelTypes, widget?.stackedBarChart],
|
||||
);
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
getUPlotChartData(
|
||||
queryResponse?.data?.payload,
|
||||
widget.fillSpans,
|
||||
stackedBarChart,
|
||||
hiddenGraph,
|
||||
),
|
||||
[
|
||||
queryResponse?.data?.payload,
|
||||
widget.fillSpans,
|
||||
stackedBarChart,
|
||||
hiddenGraph,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (widget.panelTypes === PANEL_TYPES.BAR && stackedBarChart) {
|
||||
const graphV = cloneDeep(graphVisibility)?.slice(1);
|
||||
const isSomeSelectedLegend = graphV?.some((v) => v === false);
|
||||
if (isSomeSelectedLegend) {
|
||||
const hiddenIndex = graphV?.findIndex((v) => v === true);
|
||||
if (!isUndefined(hiddenIndex) && hiddenIndex !== -1) {
|
||||
const updatedHiddenGraph = { [hiddenIndex]: true };
|
||||
if (!isEqual(hiddenGraph, updatedHiddenGraph)) {
|
||||
setHiddenGraph(updatedHiddenGraph);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [graphVisibility, hiddenGraph, widget.panelTypes, stackedBarChart]);
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const clickHandlerWithContextMenu = useCallback(
|
||||
(...args: any[]) => {
|
||||
const [
|
||||
xValue,
|
||||
,
|
||||
,
|
||||
,
|
||||
metric,
|
||||
queryData,
|
||||
absoluteMouseX,
|
||||
absoluteMouseY,
|
||||
axesData,
|
||||
focusedSeries,
|
||||
] = args;
|
||||
const data = getUplotClickData({
|
||||
metric,
|
||||
queryData,
|
||||
absoluteMouseX,
|
||||
absoluteMouseY,
|
||||
focusedSeries,
|
||||
});
|
||||
// Compute time range if needed and if axes data is available
|
||||
let timeRange;
|
||||
if (axesData && queryData?.queryName) {
|
||||
// Get the compositeQuery from the response params
|
||||
const compositeQuery = (queryResponse?.data?.params as any)?.compositeQuery;
|
||||
|
||||
if (compositeQuery?.queries) {
|
||||
// Find the specific query by name from the queries array
|
||||
const specificQuery = compositeQuery.queries.find(
|
||||
(query: any) => query.spec?.name === queryData.queryName,
|
||||
);
|
||||
|
||||
// Use the stepInterval from the specific query, fallback to default
|
||||
const stepInterval = specificQuery?.spec?.stepInterval || 60;
|
||||
timeRange = getTimeRangeFromStepInterval(
|
||||
stepInterval,
|
||||
metric?.clickedTimestamp || xValue, // Use the clicked timestamp if available, otherwise use the click position timestamp
|
||||
specificQuery?.spec?.signal === DataSource.METRICS &&
|
||||
isApmMetric(specificQuery?.spec?.aggregations[0]?.metricName),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (data && data?.record?.queryName) {
|
||||
onClick(data.coord, { ...data.record, label: data.label, timeRange });
|
||||
}
|
||||
},
|
||||
[onClick, queryResponse],
|
||||
);
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
getUPlotChartOptions({
|
||||
id: widget?.id,
|
||||
apiResponse: queryResponse.data?.payload,
|
||||
dimensions: containerDimensions,
|
||||
isDarkMode,
|
||||
onDragSelect,
|
||||
yAxisUnit: widget?.yAxisUnit,
|
||||
onClickHandler: enableDrillDown
|
||||
? clickHandlerWithContextMenu
|
||||
: onClickHandler ?? _noop,
|
||||
thresholds: widget.thresholds,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
softMax: widget.softMax === undefined ? null : widget.softMax,
|
||||
softMin: widget.softMin === undefined ? null : widget.softMin,
|
||||
graphsVisibilityStates: graphVisibility,
|
||||
setGraphsVisibilityStates: setGraphVisibility,
|
||||
panelType: selectedGraph || widget.panelTypes,
|
||||
currentQuery,
|
||||
stackBarChart: stackedBarChart,
|
||||
hiddenGraph,
|
||||
setHiddenGraph,
|
||||
customTooltipElement,
|
||||
tzDate: (timestamp: number) =>
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value),
|
||||
timezone: timezone.value,
|
||||
customSeries,
|
||||
isLogScale: widget?.isLogScale,
|
||||
colorMapping: widget?.customLegendColors,
|
||||
enhancedLegend: true, // Enable enhanced legend
|
||||
legendPosition: widget?.legendPosition,
|
||||
query: widget?.query || currentQuery,
|
||||
legendScrollPosition: legendScrollPositionRef.current,
|
||||
setLegendScrollPosition: (position: {
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}) => {
|
||||
legendScrollPositionRef.current = position;
|
||||
},
|
||||
decimalPrecision: widget.decimalPrecision,
|
||||
}),
|
||||
[
|
||||
queryResponse.data?.payload,
|
||||
containerDimensions,
|
||||
isDarkMode,
|
||||
onDragSelect,
|
||||
clickHandlerWithContextMenu,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
graphVisibility,
|
||||
setGraphVisibility,
|
||||
selectedGraph,
|
||||
currentQuery,
|
||||
hiddenGraph,
|
||||
customTooltipElement,
|
||||
timezone.value,
|
||||
customSeries,
|
||||
enableDrillDown,
|
||||
onClickHandler,
|
||||
widget,
|
||||
stackedBarChart,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
||||
<Uplot options={options} data={chartData} ref={lineChartRef} />
|
||||
<ContextMenu
|
||||
coordinates={coordinates}
|
||||
popoverPosition={popoverPosition}
|
||||
title={menuItemsConfig.header as string}
|
||||
items={menuItemsConfig.items}
|
||||
onClose={onClose}
|
||||
/>
|
||||
{stackedBarChart && isFullViewMode && (
|
||||
<Alert
|
||||
message="Selecting multiple legends is currently not supported in case of stacked bar charts"
|
||||
type="info"
|
||||
className="info-text"
|
||||
/>
|
||||
)}
|
||||
{isFullViewMode && setGraphVisibility && !stackedBarChart && (
|
||||
<GraphManager
|
||||
data={chartData}
|
||||
name={widget.id}
|
||||
options={options}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
onToggleModelHandler={onToggleModelHandler}
|
||||
setGraphsVisibilityStates={setGraphVisibility}
|
||||
graphsVisibilityStates={graphVisibility}
|
||||
lineChartRef={lineChartRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UplotPanelWrapper;
|
||||
@@ -38,6 +38,7 @@ export const routeConfig: Record<string, QueryParams[]> = {
|
||||
[ROUTES.MY_SETTINGS]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.NOT_FOUND]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.ORG_SETTINGS]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.MEMBERS_SETTINGS]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.PASSWORD_RESET]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.SETTINGS]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.SIGN_UP]: [QueryParams.resourceAttributes],
|
||||
|
||||
@@ -30,6 +30,7 @@ import { AppState } from 'store/reducers';
|
||||
import AppActions from 'types/actions';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { addCustomTimeRange } from 'utils/customTimeRangeUtils';
|
||||
import { persistTimeDurationForRoute } from 'utils/metricsTimeStorageUtils';
|
||||
import { normalizeTimeToMs } from 'utils/timeUtils';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
@@ -234,20 +235,7 @@ function DateTimeSelection({
|
||||
|
||||
const updateLocalStorageForRoutes = useCallback(
|
||||
(value: Time | string): void => {
|
||||
const preRoutes = getLocalStorageKey(LOCALSTORAGE.METRICS_TIME_IN_DURATION);
|
||||
if (preRoutes !== null) {
|
||||
const preRoutesObject = JSON.parse(preRoutes);
|
||||
|
||||
const preRoute = {
|
||||
...preRoutesObject,
|
||||
};
|
||||
preRoute[location.pathname] = value;
|
||||
|
||||
setLocalStorageKey(
|
||||
LOCALSTORAGE.METRICS_TIME_IN_DURATION,
|
||||
JSON.stringify(preRoute),
|
||||
);
|
||||
}
|
||||
persistTimeDurationForRoute(location.pathname, String(value));
|
||||
},
|
||||
[location.pathname],
|
||||
);
|
||||
@@ -738,6 +726,7 @@ function DateTimeSelection({
|
||||
showRecentlyUsed={showRecentlyUsed}
|
||||
minTime={minTimeForDateTimePicker}
|
||||
maxTime={maxTimeForDateTimePicker}
|
||||
isModalTimeSelection={isModalTimeSelection}
|
||||
/>
|
||||
|
||||
{showAutoRefresh && selectedTime !== 'custom' && (
|
||||
|
||||
160
frontend/src/hooks/__tests__/useZoomOut.test.ts
Normal file
160
frontend/src/hooks/__tests__/useZoomOut.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { useZoomOut } from '../useZoomOut';
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
const mockSafeNavigate = jest.fn();
|
||||
const mockUrlQueryDelete = jest.fn();
|
||||
const mockUrlQuerySet = jest.fn();
|
||||
const mockUrlQueryToString = jest.fn(() => '');
|
||||
|
||||
interface MockAppState {
|
||||
globalTime: Pick<GlobalReducer, 'minTime' | 'maxTime'>;
|
||||
}
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
useDispatch: (): jest.Mock => mockDispatch,
|
||||
useSelector: <T>(selector: (state: MockAppState) => T): T => {
|
||||
const mockState: MockAppState = {
|
||||
globalTime: {
|
||||
minTime: 15 * 60 * 1000 * 1e6, // 15 min in nanoseconds
|
||||
maxTime: 30 * 60 * 1000 * 1e6, // 30 min in nanoseconds (mock for getNextZoomOutRange)
|
||||
},
|
||||
};
|
||||
return selector(mockState);
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
useLocation: (): { pathname: string } => ({ pathname: '/logs-explorer' }),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: typeof mockSafeNavigate } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
interface MockUrlQuery {
|
||||
delete: typeof mockUrlQueryDelete;
|
||||
set: typeof mockUrlQuerySet;
|
||||
get: () => null;
|
||||
toString: typeof mockUrlQueryToString;
|
||||
}
|
||||
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: (): MockUrlQuery => ({
|
||||
delete: mockUrlQueryDelete,
|
||||
set: mockUrlQuerySet,
|
||||
get: (): null => null,
|
||||
toString: mockUrlQueryToString,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockGetNextZoomOutRange = jest.fn();
|
||||
jest.mock('lib/zoomOutUtils', () => ({
|
||||
getNextZoomOutRange: (
|
||||
...args: unknown[]
|
||||
): ReturnType<typeof mockGetNextZoomOutRange> =>
|
||||
mockGetNextZoomOutRange(...args),
|
||||
}));
|
||||
|
||||
describe('useZoomOut', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUrlQueryToString.mockReturnValue('relativeTime=45m');
|
||||
});
|
||||
|
||||
it('should do nothing when isDisabled is true', () => {
|
||||
const { result } = renderHook(() => useZoomOut({ isDisabled: true }));
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
|
||||
expect(mockGetNextZoomOutRange).not.toHaveBeenCalled();
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should do nothing when getNextZoomOutRange returns null', () => {
|
||||
mockGetNextZoomOutRange.mockReturnValue(null);
|
||||
|
||||
const { result } = renderHook(() => useZoomOut());
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
|
||||
expect(mockGetNextZoomOutRange).toHaveBeenCalled();
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should dispatch preset and update URL when result has preset', () => {
|
||||
mockGetNextZoomOutRange.mockReturnValue({
|
||||
range: [1000, 2000],
|
||||
preset: '45m',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useZoomOut());
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.startTime);
|
||||
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.endTime);
|
||||
expect(mockUrlQuerySet).toHaveBeenCalledWith(QueryParams.relativeTime, '45m');
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/logs-explorer'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should dispatch custom range and update URL when result has no preset', () => {
|
||||
mockGetNextZoomOutRange.mockReturnValue({
|
||||
range: [1000000, 2000000],
|
||||
preset: null,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useZoomOut());
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(mockUrlQuerySet).toHaveBeenCalledWith(
|
||||
QueryParams.startTime,
|
||||
'1000000',
|
||||
);
|
||||
expect(mockUrlQuerySet).toHaveBeenCalledWith(QueryParams.endTime, '2000000');
|
||||
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.relativeTime);
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/logs-explorer'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should delete urlParamsToDelete when provided', () => {
|
||||
mockGetNextZoomOutRange.mockReturnValue({
|
||||
range: [1000, 2000],
|
||||
preset: '45m',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useZoomOut({
|
||||
urlParamsToDelete: [QueryParams.activeLogId],
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
|
||||
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.activeLogId);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
79
frontend/src/hooks/useZoomOut.ts
Normal file
79
frontend/src/hooks/useZoomOut.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { getNextZoomOutRange } from 'lib/zoomOutUtils';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { persistTimeDurationForRoute } from 'utils/metricsTimeStorageUtils';
|
||||
|
||||
export interface UseZoomOutOptions {
|
||||
/** When true, the zoom out handler does nothing (e.g. when live logs are enabled) */
|
||||
isDisabled?: boolean;
|
||||
/** URL params to delete when zooming out (e.g. [QueryParams.activeLogId] for logs) */
|
||||
urlParamsToDelete?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable hook for zoom-out functionality in explorers (logs, traces, etc.).
|
||||
* Computes the next time range using the zoom-out ladder, updates Redux global time,
|
||||
* and navigates with the new URL params.
|
||||
*/
|
||||
const EMPTY_PARAMS: string[] = [];
|
||||
|
||||
export function useZoomOut(options: UseZoomOutOptions = {}): () => void {
|
||||
const { isDisabled = false, urlParamsToDelete = EMPTY_PARAMS } = options;
|
||||
const urlParamsToDeleteRef = useRef(urlParamsToDelete);
|
||||
urlParamsToDeleteRef.current = urlParamsToDelete;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const urlQuery = useUrlQuery();
|
||||
const location = useLocation();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
return useCallback((): void => {
|
||||
if (isDisabled) {
|
||||
return;
|
||||
}
|
||||
const minMs = Math.floor((minTime ?? 0) / 1e6);
|
||||
const maxMs = Math.floor((maxTime ?? 0) / 1e6);
|
||||
const result = getNextZoomOutRange(minMs, maxMs);
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
const [newStartMs, newEndMs] = result.range;
|
||||
const { preset } = result;
|
||||
|
||||
if (preset) {
|
||||
dispatch(UpdateTimeInterval(preset));
|
||||
urlQuery.delete(QueryParams.startTime);
|
||||
urlQuery.delete(QueryParams.endTime);
|
||||
urlQuery.set(QueryParams.relativeTime, preset);
|
||||
persistTimeDurationForRoute(location.pathname, preset);
|
||||
} else {
|
||||
dispatch(UpdateTimeInterval('custom', [newStartMs, newEndMs]));
|
||||
urlQuery.set(QueryParams.startTime, String(newStartMs));
|
||||
urlQuery.set(QueryParams.endTime, String(newEndMs));
|
||||
urlQuery.delete(QueryParams.relativeTime);
|
||||
}
|
||||
for (const param of urlParamsToDeleteRef.current) {
|
||||
urlQuery.delete(param);
|
||||
}
|
||||
safeNavigate(`${location.pathname}?${urlQuery.toString()}`);
|
||||
}, [
|
||||
dispatch,
|
||||
isDisabled,
|
||||
location.pathname,
|
||||
maxTime,
|
||||
minTime,
|
||||
safeNavigate,
|
||||
urlQuery,
|
||||
]);
|
||||
}
|
||||
147
frontend/src/lib/__tests__/zoomOutUtils.test.ts
Normal file
147
frontend/src/lib/__tests__/zoomOutUtils.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
getNextDurationInLadder,
|
||||
getNextZoomOutRange,
|
||||
isZoomOutDisabled,
|
||||
ZoomOutResult,
|
||||
} from '../zoomOutUtils';
|
||||
|
||||
const MS_PER_MIN = 60 * 1000;
|
||||
const MS_PER_HOUR = 60 * MS_PER_MIN;
|
||||
const MS_PER_DAY = 24 * MS_PER_HOUR;
|
||||
const MS_PER_WEEK = 7 * MS_PER_DAY;
|
||||
|
||||
// Fixed "now" for deterministic tests: 2024-01-15 12:00:00 UTC
|
||||
const NOW_MS = 1705312800000;
|
||||
|
||||
describe('zoomOutUtils', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(Date, 'now').mockReturnValue(NOW_MS);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('getNextDurationInLadder', () => {
|
||||
it('should use 3x zoom out below 15m until reaching 15m', () => {
|
||||
expect(getNextDurationInLadder(1 * MS_PER_MIN)).toBe(3 * MS_PER_MIN);
|
||||
expect(getNextDurationInLadder(2 * MS_PER_MIN)).toBe(6 * MS_PER_MIN);
|
||||
expect(getNextDurationInLadder(3 * MS_PER_MIN)).toBe(9 * MS_PER_MIN);
|
||||
expect(getNextDurationInLadder(4 * MS_PER_MIN)).toBe(12 * MS_PER_MIN);
|
||||
expect(getNextDurationInLadder(5 * MS_PER_MIN)).toBe(15 * MS_PER_MIN); // cap at 15m
|
||||
expect(getNextDurationInLadder(6 * MS_PER_MIN)).toBe(15 * MS_PER_MIN); // 18m capped
|
||||
});
|
||||
|
||||
it('should return next step for each ladder rung from 15m onward', () => {
|
||||
expect(getNextDurationInLadder(10 * MS_PER_MIN)).toBe(15 * MS_PER_MIN);
|
||||
expect(getNextDurationInLadder(15 * MS_PER_MIN)).toBe(45 * MS_PER_MIN);
|
||||
expect(getNextDurationInLadder(45 * MS_PER_MIN)).toBe(2 * MS_PER_HOUR);
|
||||
expect(getNextDurationInLadder(2 * MS_PER_HOUR)).toBe(7 * MS_PER_HOUR);
|
||||
expect(getNextDurationInLadder(7 * MS_PER_HOUR)).toBe(21 * MS_PER_HOUR);
|
||||
expect(getNextDurationInLadder(21 * MS_PER_HOUR)).toBe(1 * MS_PER_DAY);
|
||||
expect(getNextDurationInLadder(1 * MS_PER_DAY)).toBe(2 * MS_PER_DAY);
|
||||
expect(getNextDurationInLadder(2 * MS_PER_DAY)).toBe(3 * MS_PER_DAY);
|
||||
expect(getNextDurationInLadder(3 * MS_PER_DAY)).toBe(1 * MS_PER_WEEK);
|
||||
expect(getNextDurationInLadder(1 * MS_PER_WEEK)).toBe(2 * MS_PER_WEEK);
|
||||
expect(getNextDurationInLadder(2 * MS_PER_WEEK)).toBe(30 * MS_PER_DAY);
|
||||
});
|
||||
|
||||
it('should return MAX when at or past 1 month (no wrap)', () => {
|
||||
expect(getNextDurationInLadder(30 * MS_PER_DAY)).toBe(30 * MS_PER_DAY);
|
||||
expect(getNextDurationInLadder(31 * MS_PER_DAY)).toBe(30 * MS_PER_DAY);
|
||||
});
|
||||
|
||||
it('should return next step for duration between ladder rungs', () => {
|
||||
expect(getNextDurationInLadder(1 * MS_PER_HOUR)).toBe(2 * MS_PER_HOUR);
|
||||
expect(getNextDurationInLadder(5 * MS_PER_HOUR)).toBe(7 * MS_PER_HOUR);
|
||||
expect(getNextDurationInLadder(12 * MS_PER_HOUR)).toBe(21 * MS_PER_HOUR);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextZoomOutRange', () => {
|
||||
it('should return null when duration is zero or negative', () => {
|
||||
expect(getNextZoomOutRange(NOW_MS, NOW_MS)).toBeNull();
|
||||
expect(getNextZoomOutRange(NOW_MS, NOW_MS - 1000)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return center-anchored range and preset=null when new end does not exceed now (Phase 1)', () => {
|
||||
// 15m range centered well before now so zoom to 45m keeps end <= now
|
||||
// Center at now-30m: end = center + 22.5m = now - 7.5m <= now
|
||||
const centerMs = NOW_MS - 30 * MS_PER_MIN;
|
||||
const start15m = centerMs - 7.5 * MS_PER_MIN;
|
||||
const end15m = centerMs + 7.5 * MS_PER_MIN;
|
||||
const result = getNextZoomOutRange(start15m, end15m) as ZoomOutResult;
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.preset).toBeNull(); // Phase 1: preserve center-anchored range, avoid GetMinMax "last X from now"
|
||||
const [newStart, newEnd] = result.range;
|
||||
expect(newEnd - newStart).toBe(45 * MS_PER_MIN);
|
||||
const newCenter = (newStart + newEnd) / 2;
|
||||
expect(Math.abs(newCenter - centerMs)).toBeLessThan(2000);
|
||||
expect(newEnd).toBeLessThanOrEqual(NOW_MS + 1000);
|
||||
});
|
||||
|
||||
it('should return end-anchored range when new end would exceed now (Phase 2)', () => {
|
||||
// 22hr range ending at now - zoom to 1d (24hr) would push end past now
|
||||
// Next ladder step from 22hr is 1d
|
||||
const start22h = NOW_MS - 22 * MS_PER_HOUR;
|
||||
const end22h = NOW_MS;
|
||||
const result = getNextZoomOutRange(start22h, end22h) as ZoomOutResult;
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.preset).toBe('1d');
|
||||
const [newStart, newEnd] = result.range;
|
||||
expect(newEnd).toBe(NOW_MS); // End anchored at now
|
||||
expect(newStart).toBe(NOW_MS - 1 * MS_PER_DAY);
|
||||
});
|
||||
|
||||
it('should return correct preset for each ladder step', () => {
|
||||
const presets: [number, number, string][] = [
|
||||
[15 * MS_PER_MIN, 0, '45m'],
|
||||
[45 * MS_PER_MIN, 0, '2h'],
|
||||
[2 * MS_PER_HOUR, 0, '7h'],
|
||||
[7 * MS_PER_HOUR, 0, '21h'],
|
||||
[21 * MS_PER_HOUR, 0, '1d'],
|
||||
[1 * MS_PER_DAY, 0, '2d'],
|
||||
[2 * MS_PER_DAY, 0, '3d'],
|
||||
[3 * MS_PER_DAY, 0, '1w'],
|
||||
[1 * MS_PER_WEEK, 0, '2w'],
|
||||
[2 * MS_PER_WEEK, 0, '1month'],
|
||||
];
|
||||
|
||||
presets.forEach(([durationMs, offset, expectedPreset]) => {
|
||||
const end = NOW_MS - offset;
|
||||
const start = end - durationMs;
|
||||
const result = getNextZoomOutRange(start, end);
|
||||
expect(result?.preset).toBe(expectedPreset);
|
||||
});
|
||||
});
|
||||
|
||||
it('isZoomOutDisabled returns true when duration >= 1 month', () => {
|
||||
expect(isZoomOutDisabled(30 * MS_PER_DAY)).toBe(true);
|
||||
expect(isZoomOutDisabled(31 * MS_PER_DAY)).toBe(true);
|
||||
expect(isZoomOutDisabled(29 * MS_PER_DAY)).toBe(false);
|
||||
expect(isZoomOutDisabled(15 * MS_PER_MIN)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return null when at 1 month (no zoom out beyond max)', () => {
|
||||
const start1m = NOW_MS - 30 * MS_PER_DAY;
|
||||
const end1m = NOW_MS;
|
||||
const result = getNextZoomOutRange(start1m, end1m);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should zoom out 3x from 5m range to 15m then continue with ladder', () => {
|
||||
// 5m range ending at now → 3x = 15m
|
||||
const start5m = NOW_MS - 5 * MS_PER_MIN;
|
||||
const end5m = NOW_MS;
|
||||
const result = getNextZoomOutRange(start5m, end5m) as ZoomOutResult;
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.preset).toBe('15m');
|
||||
const [newStart, newEnd] = result.range;
|
||||
expect(newEnd - newStart).toBe(15 * MS_PER_MIN);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
18
frontend/src/lib/uPlotV2/utils/generateGradientFill.ts
Normal file
18
frontend/src/lib/uPlotV2/utils/generateGradientFill.ts
Normal 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);
|
||||
g.addColorStop(0.4, startColor);
|
||||
g.addColorStop(1, endColor);
|
||||
return g;
|
||||
}
|
||||
139
frontend/src/lib/zoomOutUtils.ts
Normal file
139
frontend/src/lib/zoomOutUtils.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Custom Time Picker zoom-out ladder:
|
||||
* - Until 1 day: 15m → 45m → 2hr → 7hr → 21hr
|
||||
* - Then fixed: 1d → 2d → 3d → 1w → 2w → 1m
|
||||
* - At 1 month: zoom out is disabled (max range)
|
||||
*/
|
||||
|
||||
import type {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
|
||||
const MS_PER_MIN = 60 * 1000;
|
||||
const MS_PER_HOUR = 60 * MS_PER_MIN;
|
||||
const MS_PER_DAY = 24 * MS_PER_HOUR;
|
||||
const MS_PER_WEEK = 7 * MS_PER_DAY;
|
||||
|
||||
const ZOOM_OUT_LADDER_MS: number[] = [
|
||||
15 * MS_PER_MIN, // 15m
|
||||
45 * MS_PER_MIN, // 45m
|
||||
2 * MS_PER_HOUR, // 2hr
|
||||
7 * MS_PER_HOUR, // 7hr
|
||||
21 * MS_PER_HOUR, // 21hr
|
||||
1 * MS_PER_DAY, // 1d
|
||||
2 * MS_PER_DAY, // 2d
|
||||
3 * MS_PER_DAY, // 3d
|
||||
1 * MS_PER_WEEK, // 1w
|
||||
2 * MS_PER_WEEK, // 2w
|
||||
30 * MS_PER_DAY, // 1m
|
||||
];
|
||||
|
||||
const LADDER_LAST_INDEX = ZOOM_OUT_LADDER_MS.length - 1;
|
||||
const MAX_DURATION = ZOOM_OUT_LADDER_MS[LADDER_LAST_INDEX];
|
||||
const MIN_LADDER_DURATION_MS = ZOOM_OUT_LADDER_MS[0]; // 15m - below this we use 3x
|
||||
|
||||
export const MAX_ZOOM_OUT_DURATION_MS = MAX_DURATION;
|
||||
|
||||
/** Returns true when zoom out should be disabled (range at or beyond 1 month) */
|
||||
export function isZoomOutDisabled(durationMs: number): boolean {
|
||||
return durationMs >= MAX_ZOOM_OUT_DURATION_MS;
|
||||
}
|
||||
|
||||
/** Preset labels for ladder steps supported by GetMinMax (shows "Last 15 minutes" etc. instead of "Custom") */
|
||||
const PRESET_FOR_DURATION_MS: Record<number, Time | CustomTimeType> = {
|
||||
[15 * MS_PER_MIN]: '15m',
|
||||
[45 * MS_PER_MIN]: '45m',
|
||||
[2 * MS_PER_HOUR]: '2h',
|
||||
[7 * MS_PER_HOUR]: '7h',
|
||||
[21 * MS_PER_HOUR]: '21h',
|
||||
[1 * MS_PER_DAY]: '1d',
|
||||
[2 * MS_PER_DAY]: '2d',
|
||||
[3 * MS_PER_DAY]: '3d',
|
||||
[1 * MS_PER_WEEK]: '1w',
|
||||
[2 * MS_PER_WEEK]: '2w',
|
||||
[30 * MS_PER_DAY]: '1month',
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the next duration in the zoom-out ladder for the given current duration.
|
||||
* Below 15m: zoom out 3x until we reach 15m, then continue with the ladder.
|
||||
* If at or past 1 month, returns MAX_DURATION (no zoom out - button is disabled).
|
||||
*/
|
||||
export function getNextDurationInLadder(durationMs: number): number {
|
||||
if (durationMs >= MAX_DURATION) {
|
||||
return MAX_DURATION; // No zoom out beyond 1 month
|
||||
}
|
||||
|
||||
// Below 15m: zoom out 3x until we reach 15m
|
||||
if (durationMs < MIN_LADDER_DURATION_MS) {
|
||||
const next = durationMs * 3;
|
||||
return Math.min(next, MIN_LADDER_DURATION_MS);
|
||||
}
|
||||
|
||||
// At or above 15m: use the fixed ladder
|
||||
for (let i = 0; i < ZOOM_OUT_LADDER_MS.length; i++) {
|
||||
if (ZOOM_OUT_LADDER_MS[i] > durationMs) {
|
||||
return ZOOM_OUT_LADDER_MS[i];
|
||||
}
|
||||
}
|
||||
|
||||
return MAX_DURATION;
|
||||
}
|
||||
|
||||
export interface ZoomOutResult {
|
||||
range: [number, number];
|
||||
/** Preset key (e.g. '15m') when range matches a preset - use for display instead of "Custom Date Range" */
|
||||
preset: Time | CustomTimeType | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the next zoomed-out time range.
|
||||
* Phase 1 (center-anchored): While new end <= now, expand from center.
|
||||
* Phase 2 (end-anchored at now): When new end would exceed now, anchor end at now and move start backward.
|
||||
*
|
||||
* @returns ZoomOutResult with range and preset (or null if no change)
|
||||
*/
|
||||
export function getNextZoomOutRange(
|
||||
startMs: number,
|
||||
endMs: number,
|
||||
): ZoomOutResult | null {
|
||||
const nowMs = Date.now();
|
||||
const durationMs = endMs - startMs;
|
||||
|
||||
if (durationMs <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const newDurationMs = getNextDurationInLadder(durationMs);
|
||||
|
||||
// No zoom out when already at max (1 month)
|
||||
if (newDurationMs <= durationMs) {
|
||||
return null;
|
||||
}
|
||||
const centerMs = startMs + durationMs / 2;
|
||||
const computedEndMs = centerMs + newDurationMs / 2;
|
||||
|
||||
let newStartMs: number;
|
||||
let newEndMs: number;
|
||||
|
||||
const isPhase1 = computedEndMs <= nowMs;
|
||||
if (isPhase1) {
|
||||
// Phase 1: center-anchored (historical range not ending at now)
|
||||
newStartMs = centerMs - newDurationMs / 2;
|
||||
newEndMs = computedEndMs;
|
||||
} else {
|
||||
// Phase 2: end-anchored at now
|
||||
newStartMs = nowMs - newDurationMs;
|
||||
newEndMs = nowMs;
|
||||
}
|
||||
|
||||
// Phase 2 only: use preset so GetMinMax produces "last X from now".
|
||||
// Phase 1: preset=null so the center-anchored range is preserved (GetMinMax would discard it).
|
||||
const preset = isPhase1 ? null : PRESET_FOR_DURATION_MS[newDurationMs] ?? null;
|
||||
|
||||
return {
|
||||
range: [Math.round(newStartMs), Math.round(newEndMs)],
|
||||
preset,
|
||||
};
|
||||
}
|
||||
@@ -63,6 +63,7 @@ function SettingsPage(): JSX.Element {
|
||||
isAdmin &&
|
||||
(item.key === ROUTES.BILLING ||
|
||||
item.key === ROUTES.ORG_SETTINGS ||
|
||||
item.key === ROUTES.MEMBERS_SETTINGS ||
|
||||
item.key === ROUTES.MY_SETTINGS ||
|
||||
item.key === ROUTES.SHORTCUTS)
|
||||
),
|
||||
|
||||
@@ -36,6 +36,7 @@ export const getRoutes = (
|
||||
if (isWorkspaceBlocked && isAdmin) {
|
||||
settings.push(
|
||||
...organizationSettings(t),
|
||||
...membersSettings(t),
|
||||
...mySettings(t),
|
||||
...billingSettings(t),
|
||||
...keyboardShortcuts(t),
|
||||
|
||||
@@ -4,11 +4,19 @@ import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { Compass, Cone, TowerControl } from 'lucide-react';
|
||||
|
||||
import TraceDetailsV3 from '../TraceDetailsV3';
|
||||
import TraceDetailsV2 from './TraceDetailV2';
|
||||
|
||||
import './TraceDetailV2.styles.scss';
|
||||
|
||||
function NewTraceDetail(props: any): JSX.Element {
|
||||
interface INewTraceDetailProps {
|
||||
items: {
|
||||
label: JSX.Element;
|
||||
key: string;
|
||||
children: JSX.Element;
|
||||
}[];
|
||||
}
|
||||
|
||||
function NewTraceDetail(props: INewTraceDetailProps): JSX.Element {
|
||||
const { items } = props;
|
||||
return (
|
||||
<div className="traces-module-container">
|
||||
@@ -42,7 +50,7 @@ export default function TraceDetailsPage(): JSX.Element {
|
||||
</div>
|
||||
),
|
||||
key: 'trace-details',
|
||||
children: <TraceDetailsV3 />,
|
||||
children: <TraceDetailsV2 />,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
.trace-details-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0px 16px;
|
||||
|
||||
.previous-btn {
|
||||
display: flex;
|
||||
height: 30px;
|
||||
padding: 6px 8px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
background: var(--bg-slate-500);
|
||||
border-radius: 4px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.trace-name {
|
||||
display: flex;
|
||||
padding: 6px 8px;
|
||||
margin-left: 6px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
border-radius: 4px 0px 0px 4px;
|
||||
background: var(--bg-slate-500);
|
||||
|
||||
.drafting {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.trace-id {
|
||||
color: #fff;
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.trace-id-value {
|
||||
display: flex;
|
||||
padding: 6px 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--bg-slate-400);
|
||||
color: var(--Vanilla-400, #c0c1c3);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
border-left: unset;
|
||||
border-radius: 0px 4px 4px 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.trace-details-header {
|
||||
.previous-btn {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-200);
|
||||
}
|
||||
|
||||
.trace-name {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-200);
|
||||
border-right: none;
|
||||
|
||||
.drafting {
|
||||
color: var(--bg-ink-100);
|
||||
}
|
||||
|
||||
.trace-id {
|
||||
color: var(--bg-ink-100);
|
||||
}
|
||||
}
|
||||
|
||||
.trace-id-value {
|
||||
background: var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: move to new css module name system
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button, Typography } from 'antd';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import './TraceDetailsHeader.styles.scss';
|
||||
|
||||
function TraceDetailsHeader(): JSX.Element {
|
||||
const { id: traceID } = useParams<TraceDetailV2URLProps>();
|
||||
|
||||
const handlePreviousBtnClick = useCallback((): void => {
|
||||
const isSpaNavigate =
|
||||
document.referrer &&
|
||||
new URL(document.referrer).origin === window.location.origin;
|
||||
if (isSpaNavigate) {
|
||||
history.goBack();
|
||||
} else {
|
||||
history.push(ROUTES.TRACES_EXPLORER);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="trace-details-header">
|
||||
<Button className="previous-btn" onClick={handlePreviousBtnClick}>
|
||||
<ArrowLeft size={14} />
|
||||
</Button>
|
||||
<div className="trace-name">
|
||||
<Typography.Text className="trace-id">Trace ID</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text className="trace-id-value">{traceID}</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDetailsHeader;
|
||||
@@ -1,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;
|
||||
@@ -1,107 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useHistory, useLocation, useParams } from 'react-router-dom';
|
||||
import useGetTraceFlamegraph from 'hooks/trace/useGetTraceFlamegraph';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { TraceDetailFlamegraphURLProps } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
import FlamegraphCanvas from './FlamegraphCanvas';
|
||||
|
||||
//TODO: analyse if this is needed or not and move to separate file if needed else delete this enum.
|
||||
enum TraceFlamegraphState {
|
||||
LOADING = 'LOADING',
|
||||
SUCCESS = 'SUCCESS',
|
||||
NO_DATA = 'NO_DATA',
|
||||
ERROR = 'ERROR',
|
||||
FETCHING_WITH_OLD_DATA = 'FETCHING_WITH_OLD_DATA',
|
||||
}
|
||||
|
||||
function TraceFlamegraph(): JSX.Element {
|
||||
const { id: traceId } = useParams<TraceDetailFlamegraphURLProps>();
|
||||
const urlQuery = useUrlQuery();
|
||||
const history = useHistory();
|
||||
const { search } = useLocation();
|
||||
const [firstSpanAtFetchLevel, setFirstSpanAtFetchLevel] = useState<string>(
|
||||
urlQuery.get('spanId') || '',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFirstSpanAtFetchLevel(urlQuery.get('spanId') || '');
|
||||
}, [urlQuery]);
|
||||
|
||||
const handleSpanClick = useCallback(
|
||||
(spanId: string): void => {
|
||||
setFirstSpanAtFetchLevel(spanId);
|
||||
const searchParams = new URLSearchParams(search);
|
||||
//tood: use from query params constants
|
||||
if (searchParams.get('spanId') !== spanId) {
|
||||
searchParams.set('spanId', spanId);
|
||||
history.replace({ search: searchParams.toString() });
|
||||
}
|
||||
},
|
||||
[history, search],
|
||||
);
|
||||
|
||||
const { data, isFetching, error } = useGetTraceFlamegraph({
|
||||
traceId,
|
||||
selectedSpanId: firstSpanAtFetchLevel,
|
||||
});
|
||||
|
||||
const flamegraphState = useMemo(() => {
|
||||
if (isFetching) {
|
||||
if (data?.payload?.spans && data.payload.spans.length > 0) {
|
||||
return TraceFlamegraphState.FETCHING_WITH_OLD_DATA;
|
||||
}
|
||||
return TraceFlamegraphState.LOADING;
|
||||
}
|
||||
if (error) {
|
||||
return TraceFlamegraphState.ERROR;
|
||||
}
|
||||
if (data?.payload?.spans && data.payload.spans.length === 0) {
|
||||
return TraceFlamegraphState.NO_DATA;
|
||||
}
|
||||
return TraceFlamegraphState.SUCCESS;
|
||||
}, [error, isFetching, data]);
|
||||
|
||||
const spans = useMemo(() => data?.payload?.spans || [], [
|
||||
data?.payload?.spans,
|
||||
]);
|
||||
|
||||
const content = useMemo(() => {
|
||||
switch (flamegraphState) {
|
||||
case TraceFlamegraphState.LOADING:
|
||||
return <div>Loading...</div>;
|
||||
case TraceFlamegraphState.ERROR:
|
||||
return <div>Error loading flamegraph</div>;
|
||||
case TraceFlamegraphState.NO_DATA:
|
||||
return <div>No data found for trace {traceId}</div>;
|
||||
case TraceFlamegraphState.SUCCESS:
|
||||
case TraceFlamegraphState.FETCHING_WITH_OLD_DATA:
|
||||
return (
|
||||
<FlamegraphCanvas
|
||||
spans={spans}
|
||||
firstSpanAtFetchLevel={firstSpanAtFetchLevel}
|
||||
setFirstSpanAtFetchLevel={setFirstSpanAtFetchLevel}
|
||||
onSpanClick={handleSpanClick}
|
||||
traceMetadata={{
|
||||
startTime: data?.payload?.startTimestampMillis || 0,
|
||||
endTime: data?.payload?.endTimestampMillis || 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <div>Fetching the trace...</div>;
|
||||
}
|
||||
}, [
|
||||
data?.payload?.endTimestampMillis,
|
||||
data?.payload?.startTimestampMillis,
|
||||
firstSpanAtFetchLevel,
|
||||
flamegraphState,
|
||||
spans,
|
||||
traceId,
|
||||
handleSpanClick,
|
||||
]);
|
||||
|
||||
return <>{content}</>;
|
||||
}
|
||||
|
||||
export default TraceFlamegraph;
|
||||
@@ -1,525 +0,0 @@
|
||||
import { DASHED_BORDER_LINE_DASH, MIN_WIDTH_FOR_NAME } from '../constants';
|
||||
import type { FlamegraphRowMetrics } from '../utils';
|
||||
import { getFlamegraphRowMetrics } from '../utils';
|
||||
import { drawEventDot, drawSpanBar } from '../utils';
|
||||
import { MOCK_SPAN } from './testUtils';
|
||||
|
||||
jest.mock('container/TraceDetail/utils', () => ({
|
||||
convertTimeToRelevantUnit: (): { time: number; timeUnitName: string } => ({
|
||||
time: 50,
|
||||
timeUnitName: 'ms',
|
||||
}),
|
||||
}));
|
||||
|
||||
/** Minimal 2D context for createStripePattern's internal canvas (jsdom getContext often returns null) */
|
||||
const mockPatternCanvasCtx = {
|
||||
beginPath: jest.fn(),
|
||||
moveTo: jest.fn(),
|
||||
lineTo: jest.fn(),
|
||||
stroke: jest.fn(),
|
||||
globalAlpha: 1,
|
||||
};
|
||||
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
document.createElement = function (
|
||||
tagName: string,
|
||||
): ReturnType<typeof originalCreateElement> {
|
||||
const el = originalCreateElement(tagName);
|
||||
if (tagName.toLowerCase() === 'canvas') {
|
||||
(el as HTMLCanvasElement).getContext = (() =>
|
||||
mockPatternCanvasCtx as unknown) as HTMLCanvasElement['getContext'];
|
||||
}
|
||||
return el;
|
||||
};
|
||||
|
||||
function createMockCtx(): jest.Mocked<CanvasRenderingContext2D> {
|
||||
return ({
|
||||
beginPath: jest.fn(),
|
||||
roundRect: jest.fn(),
|
||||
fill: jest.fn(),
|
||||
stroke: jest.fn(),
|
||||
save: jest.fn(),
|
||||
restore: jest.fn(),
|
||||
translate: jest.fn(),
|
||||
rotate: jest.fn(),
|
||||
fillRect: jest.fn(),
|
||||
strokeRect: jest.fn(),
|
||||
setLineDash: jest.fn(),
|
||||
measureText: jest.fn(
|
||||
(text: string) => ({ width: text.length * 6 } as TextMetrics),
|
||||
),
|
||||
createPattern: jest.fn(() => ({} as CanvasPattern)),
|
||||
clip: jest.fn(),
|
||||
rect: jest.fn(),
|
||||
fillText: jest.fn(),
|
||||
font: '',
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
textAlign: '',
|
||||
textBaseline: '',
|
||||
lineWidth: 0,
|
||||
globalAlpha: 1,
|
||||
} as unknown) as jest.Mocked<CanvasRenderingContext2D>;
|
||||
}
|
||||
|
||||
const METRICS: FlamegraphRowMetrics = getFlamegraphRowMetrics(24);
|
||||
|
||||
describe('Canvas Draw Utils', () => {
|
||||
describe('drawSpanBar', () => {
|
||||
it('draws rect + fill for normal span (no selected/hovered)', () => {
|
||||
const ctx = createMockCtx();
|
||||
const spanRectsArray: {
|
||||
span: typeof MOCK_SPAN;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
}[] = [];
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, event: [] },
|
||||
x: 10,
|
||||
y: 0,
|
||||
width: 100,
|
||||
levelIndex: 0,
|
||||
spanRectsArray,
|
||||
color: '#1890ff',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(ctx.beginPath).toHaveBeenCalled();
|
||||
expect(ctx.roundRect).toHaveBeenCalledWith(10, 1, 100, 22, 2);
|
||||
expect(ctx.fill).toHaveBeenCalled();
|
||||
expect(ctx.stroke).not.toHaveBeenCalled();
|
||||
expect(spanRectsArray).toHaveLength(1);
|
||||
expect(spanRectsArray[0]).toMatchObject({
|
||||
x: 10,
|
||||
y: 1,
|
||||
width: 100,
|
||||
height: 22,
|
||||
level: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses stripe pattern + dashed stroke + 2px when selected', () => {
|
||||
const ctx = createMockCtx();
|
||||
const spanRectsArray: {
|
||||
span: typeof MOCK_SPAN;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
}[] = [];
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, spanId: 'sel', event: [] },
|
||||
x: 20,
|
||||
y: 0,
|
||||
width: 80,
|
||||
levelIndex: 1,
|
||||
spanRectsArray,
|
||||
color: '#2F80ED',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
selectedSpanId: 'sel',
|
||||
});
|
||||
|
||||
expect(ctx.createPattern).toHaveBeenCalled();
|
||||
expect(ctx.setLineDash).toHaveBeenCalledWith(DASHED_BORDER_LINE_DASH);
|
||||
expect(ctx.strokeStyle).toBe('#2F80ED');
|
||||
expect(ctx.lineWidth).toBe(2);
|
||||
expect(ctx.stroke).toHaveBeenCalled();
|
||||
expect(ctx.setLineDash).toHaveBeenLastCalledWith([]);
|
||||
});
|
||||
|
||||
it('uses stripe pattern + solid stroke + 1px when hovered (not selected)', () => {
|
||||
const ctx = createMockCtx();
|
||||
const spanRectsArray: {
|
||||
span: typeof MOCK_SPAN;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
}[] = [];
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, spanId: 'hov', event: [] },
|
||||
x: 30,
|
||||
y: 0,
|
||||
width: 60,
|
||||
levelIndex: 0,
|
||||
spanRectsArray,
|
||||
color: '#2F80ED',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
hoveredSpanId: 'hov',
|
||||
});
|
||||
|
||||
expect(ctx.createPattern).toHaveBeenCalled();
|
||||
expect(ctx.setLineDash).not.toHaveBeenCalled();
|
||||
expect(ctx.lineWidth).toBe(1);
|
||||
expect(ctx.stroke).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pushes spanRectsArray with correct dimensions', () => {
|
||||
const ctx = createMockCtx();
|
||||
const spanRectsArray: {
|
||||
span: typeof MOCK_SPAN;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
}[] = [];
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, spanId: 'rect-test', event: [] },
|
||||
x: 5,
|
||||
y: 24,
|
||||
width: 200,
|
||||
levelIndex: 2,
|
||||
spanRectsArray,
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(spanRectsArray[0]).toMatchObject({
|
||||
x: 5,
|
||||
y: 25,
|
||||
width: 200,
|
||||
height: 22,
|
||||
level: 2,
|
||||
});
|
||||
expect(spanRectsArray[0].span.spanId).toBe('rect-test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('drawSpanLabel (via drawSpanBar)', () => {
|
||||
it('skips label when width < MIN_WIDTH_FOR_NAME', () => {
|
||||
const ctx = createMockCtx();
|
||||
const spanRectsArray: {
|
||||
span: typeof MOCK_SPAN;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
}[] = [];
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, name: 'long-span-name', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: MIN_WIDTH_FOR_NAME - 1,
|
||||
levelIndex: 0,
|
||||
spanRectsArray,
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(ctx.clip).not.toHaveBeenCalled();
|
||||
expect(ctx.fillText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('draws name only when width >= MIN_WIDTH_FOR_NAME but < MIN_WIDTH_FOR_NAME_AND_DURATION', () => {
|
||||
const ctx = createMockCtx();
|
||||
ctx.measureText = jest.fn(
|
||||
(t: string) => ({ width: t.length * 6 } as TextMetrics),
|
||||
);
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, name: 'foo', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 50,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(ctx.clip).toHaveBeenCalled();
|
||||
expect(ctx.fillText).toHaveBeenCalled();
|
||||
expect(ctx.textAlign).toBe('left');
|
||||
});
|
||||
|
||||
it('draws name + duration when width >= MIN_WIDTH_FOR_NAME_AND_DURATION', () => {
|
||||
const ctx = createMockCtx();
|
||||
ctx.measureText = jest.fn(
|
||||
(t: string) => ({ width: t.length * 6 } as TextMetrics),
|
||||
);
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, name: 'my-span', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(ctx.fillText).toHaveBeenCalledTimes(2);
|
||||
expect(ctx.fillText).toHaveBeenCalledWith(
|
||||
'50ms',
|
||||
expect.any(Number),
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(ctx.fillText).toHaveBeenCalledWith(
|
||||
'my-span',
|
||||
expect.any(Number),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncateText (via drawSpanBar)', () => {
|
||||
it('uses full text when it fits', () => {
|
||||
const ctx = createMockCtx();
|
||||
ctx.measureText = jest.fn(
|
||||
(t: string) => ({ width: t.length * 4 } as TextMetrics),
|
||||
);
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, name: 'short', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(ctx.fillText).toHaveBeenCalledWith(
|
||||
'short',
|
||||
expect.any(Number),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('truncates text when it exceeds available width', () => {
|
||||
const ctx = createMockCtx();
|
||||
ctx.measureText = jest.fn(
|
||||
(t: string) =>
|
||||
({
|
||||
width: t.includes('...') ? 24 : t.length * 10,
|
||||
} as TextMetrics),
|
||||
);
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, name: 'very-long-span-name', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 50,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
const fillTextCalls = (ctx.fillText as jest.Mock).mock.calls;
|
||||
const nameArg = fillTextCalls.find((c) => c[0] !== '50ms')?.[0];
|
||||
expect(nameArg).toBeDefined();
|
||||
expect(nameArg).toMatch(/\.\.\.$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('drawEventDot', () => {
|
||||
it('uses error styling when isError is true', () => {
|
||||
const ctx = createMockCtx();
|
||||
|
||||
drawEventDot({
|
||||
ctx,
|
||||
x: 50,
|
||||
y: 11,
|
||||
isError: true,
|
||||
isDarkMode: false,
|
||||
eventDotSize: 6,
|
||||
});
|
||||
|
||||
expect(ctx.save).toHaveBeenCalled();
|
||||
expect(ctx.translate).toHaveBeenCalledWith(50, 11);
|
||||
expect(ctx.rotate).toHaveBeenCalledWith(Math.PI / 4);
|
||||
expect(ctx.fillStyle).toBe('rgb(220, 38, 38)');
|
||||
expect(ctx.strokeStyle).toBe('rgb(153, 27, 27)');
|
||||
expect(ctx.fillRect).toHaveBeenCalledWith(-3, -3, 6, 6);
|
||||
expect(ctx.strokeRect).toHaveBeenCalledWith(-3, -3, 6, 6);
|
||||
expect(ctx.restore).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses normal styling when isError is false', () => {
|
||||
const ctx = createMockCtx();
|
||||
|
||||
drawEventDot({
|
||||
ctx,
|
||||
x: 0,
|
||||
y: 0,
|
||||
isError: false,
|
||||
isDarkMode: false,
|
||||
eventDotSize: 6,
|
||||
});
|
||||
|
||||
expect(ctx.fillStyle).toBe('rgb(6, 182, 212)');
|
||||
expect(ctx.strokeStyle).toBe('rgb(8, 145, 178)');
|
||||
});
|
||||
|
||||
it('uses dark mode colors for error', () => {
|
||||
const ctx = createMockCtx();
|
||||
|
||||
drawEventDot({
|
||||
ctx,
|
||||
x: 0,
|
||||
y: 0,
|
||||
isError: true,
|
||||
isDarkMode: true,
|
||||
eventDotSize: 6,
|
||||
});
|
||||
|
||||
expect(ctx.fillStyle).toBe('rgb(239, 68, 68)');
|
||||
expect(ctx.strokeStyle).toBe('rgb(185, 28, 28)');
|
||||
});
|
||||
|
||||
it('uses dark mode colors for non-error', () => {
|
||||
const ctx = createMockCtx();
|
||||
|
||||
drawEventDot({
|
||||
ctx,
|
||||
x: 0,
|
||||
y: 0,
|
||||
isError: false,
|
||||
isDarkMode: true,
|
||||
eventDotSize: 6,
|
||||
});
|
||||
|
||||
expect(ctx.fillStyle).toBe('rgb(14, 165, 233)');
|
||||
expect(ctx.strokeStyle).toBe('rgb(2, 132, 199)');
|
||||
});
|
||||
|
||||
it('calls save, translate, rotate, restore', () => {
|
||||
const ctx = createMockCtx();
|
||||
|
||||
drawEventDot({
|
||||
ctx,
|
||||
x: 10,
|
||||
y: 20,
|
||||
isError: false,
|
||||
isDarkMode: false,
|
||||
eventDotSize: 4,
|
||||
});
|
||||
|
||||
expect(ctx.save).toHaveBeenCalled();
|
||||
expect(ctx.translate).toHaveBeenCalledWith(10, 20);
|
||||
expect(ctx.rotate).toHaveBeenCalledWith(Math.PI / 4);
|
||||
expect(ctx.restore).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createStripePattern (via drawSpanBar)', () => {
|
||||
it('uses pattern when createPattern returns non-null', () => {
|
||||
const ctx = createMockCtx();
|
||||
const mockPattern = {} as CanvasPattern;
|
||||
(ctx.createPattern as jest.Mock).mockReturnValue(mockPattern);
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, spanId: 'p', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: MIN_WIDTH_FOR_NAME - 1,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
hoveredSpanId: 'p',
|
||||
});
|
||||
|
||||
expect(ctx.createPattern).toHaveBeenCalled();
|
||||
expect(ctx.fillStyle).toBe(mockPattern);
|
||||
expect(ctx.fill).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips fill when createPattern returns null', () => {
|
||||
const ctx = createMockCtx();
|
||||
(ctx.createPattern as jest.Mock).mockReturnValue(null);
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, spanId: 'p', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: MIN_WIDTH_FOR_NAME - 1,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
selectedSpanId: 'p',
|
||||
});
|
||||
|
||||
expect(ctx.fill).not.toHaveBeenCalled();
|
||||
expect(ctx.stroke).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('drawSpanBar with events', () => {
|
||||
it('draws event dots for each span event', () => {
|
||||
const ctx = createMockCtx();
|
||||
const spanWithEvents = {
|
||||
...MOCK_SPAN,
|
||||
event: [
|
||||
{
|
||||
name: 'e1',
|
||||
timeUnixNano: 1_010_000_000,
|
||||
attributeMap: {},
|
||||
isError: false,
|
||||
},
|
||||
{
|
||||
name: 'e2',
|
||||
timeUnixNano: 1_025_000_000,
|
||||
attributeMap: {},
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: spanWithEvents,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(ctx.save).toHaveBeenCalledTimes(3);
|
||||
expect(ctx.translate).toHaveBeenCalledTimes(2);
|
||||
expect(ctx.fillRect).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
/** Minimal FlamegraphSpan for unit tests */
|
||||
export const MOCK_SPAN: FlamegraphSpan = {
|
||||
timestamp: 1000,
|
||||
durationNano: 50_000_000, // 50ms
|
||||
spanId: 'span-1',
|
||||
parentSpanId: '',
|
||||
traceId: 'trace-1',
|
||||
hasError: false,
|
||||
serviceName: 'test-service',
|
||||
name: 'test-span',
|
||||
level: 0,
|
||||
event: [],
|
||||
};
|
||||
|
||||
/** Nested spans structure for findSpanById tests */
|
||||
export const MOCK_SPANS: FlamegraphSpan[][] = [
|
||||
[
|
||||
{
|
||||
...MOCK_SPAN,
|
||||
spanId: 'root',
|
||||
parentSpanId: '',
|
||||
level: 0,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
...MOCK_SPAN,
|
||||
spanId: 'child-a',
|
||||
parentSpanId: 'root',
|
||||
level: 1,
|
||||
},
|
||||
{
|
||||
...MOCK_SPAN,
|
||||
spanId: 'child-b',
|
||||
parentSpanId: 'root',
|
||||
level: 1,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
...MOCK_SPAN,
|
||||
spanId: 'grandchild',
|
||||
parentSpanId: 'child-a',
|
||||
level: 2,
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
export const MOCK_TRACE_METADATA = {
|
||||
startTime: 0,
|
||||
endTime: 1000,
|
||||
};
|
||||
@@ -1,196 +0,0 @@
|
||||
import React from 'react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { useFlamegraphDrag } from '../hooks/useFlamegraphDrag';
|
||||
import { MOCK_TRACE_METADATA } from './testUtils';
|
||||
|
||||
function createMockCanvas(): HTMLCanvasElement {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.getBoundingClientRect = jest.fn(
|
||||
(): DOMRect =>
|
||||
({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 400,
|
||||
x: 0,
|
||||
y: 0,
|
||||
bottom: 400,
|
||||
right: 800,
|
||||
toJSON: (): Record<string, unknown> => ({}),
|
||||
} as DOMRect),
|
||||
);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function createMockContainer(): HTMLDivElement {
|
||||
const div = document.createElement('div');
|
||||
Object.defineProperty(div, 'clientHeight', { value: 400 });
|
||||
return div;
|
||||
}
|
||||
|
||||
const defaultArgs = {
|
||||
canvasRef: { current: createMockCanvas() },
|
||||
containerRef: { current: createMockContainer() },
|
||||
traceMetadata: MOCK_TRACE_METADATA,
|
||||
viewStartRef: { current: 0 },
|
||||
viewEndRef: { current: 1000 },
|
||||
setViewStartTs: jest.fn(),
|
||||
setViewEndTs: jest.fn(),
|
||||
scrollTopRef: { current: 0 },
|
||||
setScrollTop: jest.fn(),
|
||||
totalHeight: 1000,
|
||||
};
|
||||
|
||||
describe('useFlamegraphDrag', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
defaultArgs.viewStartRef.current = 0;
|
||||
defaultArgs.viewEndRef.current = 1000;
|
||||
defaultArgs.scrollTopRef.current = 0;
|
||||
});
|
||||
|
||||
it('starts drag state on mousedown', () => {
|
||||
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseDown(({
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 50,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown) as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(result.current.isDraggingRef.current).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores non-left button mousedown', () => {
|
||||
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseDown(({
|
||||
button: 1,
|
||||
clientX: 100,
|
||||
clientY: 50,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown) as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(result.current.isDraggingRef.current).toBe(false);
|
||||
});
|
||||
|
||||
it('updates pan/scroll on mousemove', () => {
|
||||
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseDown(({
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 50,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown) as React.MouseEvent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseMove(({
|
||||
clientX: 150,
|
||||
clientY: 100,
|
||||
} as unknown) as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(defaultArgs.setViewStartTs).toHaveBeenCalled();
|
||||
expect(defaultArgs.setViewEndTs).toHaveBeenCalled();
|
||||
expect(defaultArgs.setScrollTop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not set suppressClickRef when movement is below threshold', () => {
|
||||
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseDown(({
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 50,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown) as React.MouseEvent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseMove(({
|
||||
clientX: 102,
|
||||
clientY: 51,
|
||||
} as unknown) as React.MouseEvent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseUp();
|
||||
});
|
||||
|
||||
expect(result.current.suppressClickRef.current).toBe(false);
|
||||
});
|
||||
|
||||
it('sets suppressClickRef when drag exceeds threshold', () => {
|
||||
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseDown(({
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 50,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown) as React.MouseEvent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseMove(({
|
||||
clientX: 150,
|
||||
clientY: 100,
|
||||
} as unknown) as React.MouseEvent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseUp();
|
||||
});
|
||||
|
||||
expect(result.current.suppressClickRef.current).toBe(true);
|
||||
});
|
||||
|
||||
it('resets drag state on mouseup', () => {
|
||||
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseDown(({
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 50,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown) as React.MouseEvent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseUp();
|
||||
});
|
||||
|
||||
expect(result.current.isDraggingRef.current).toBe(false);
|
||||
});
|
||||
|
||||
it('cancels drag on mouseleave', () => {
|
||||
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseDown(({
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 50,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown) as React.MouseEvent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragMouseLeave();
|
||||
});
|
||||
|
||||
expect(result.current.isDraggingRef.current).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,174 +0,0 @@
|
||||
import type React from 'react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { useFlamegraphHover } from '../hooks/useFlamegraphHover';
|
||||
import type { SpanRect } from '../types';
|
||||
import { MOCK_SPAN, MOCK_TRACE_METADATA } from './testUtils';
|
||||
|
||||
function createMockCanvas(): HTMLCanvasElement {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 800;
|
||||
canvas.height = 400;
|
||||
canvas.getBoundingClientRect = jest.fn(
|
||||
(): DOMRect =>
|
||||
({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 400,
|
||||
x: 0,
|
||||
y: 0,
|
||||
bottom: 400,
|
||||
right: 800,
|
||||
toJSON: (): Record<string, unknown> => ({}),
|
||||
} as DOMRect),
|
||||
);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
const spanRect: SpanRect = {
|
||||
span: { ...MOCK_SPAN, spanId: 'hover-span', name: 'test-span' },
|
||||
x: 100,
|
||||
y: 50,
|
||||
width: 200,
|
||||
height: 22,
|
||||
level: 0,
|
||||
};
|
||||
|
||||
const defaultArgs = {
|
||||
canvasRef: { current: createMockCanvas() },
|
||||
spanRectsRef: { current: [spanRect] },
|
||||
traceMetadata: MOCK_TRACE_METADATA,
|
||||
viewStartTs: MOCK_TRACE_METADATA.startTime,
|
||||
viewEndTs: MOCK_TRACE_METADATA.endTime,
|
||||
isDraggingRef: { current: false },
|
||||
suppressClickRef: { current: false },
|
||||
onSpanClick: jest.fn(),
|
||||
isDarkMode: false,
|
||||
};
|
||||
|
||||
describe('useFlamegraphHover', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'devicePixelRatio', {
|
||||
configurable: true,
|
||||
value: 1,
|
||||
});
|
||||
jest.clearAllMocks();
|
||||
defaultArgs.spanRectsRef.current = [spanRect];
|
||||
defaultArgs.isDraggingRef.current = false;
|
||||
defaultArgs.suppressClickRef.current = false;
|
||||
});
|
||||
|
||||
it('sets hoveredSpanId and tooltipContent when hovering on span', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseMove({
|
||||
clientX: 150,
|
||||
clientY: 61,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(result.current.hoveredSpanId).toBe('hover-span');
|
||||
expect(result.current.tooltipContent).not.toBeNull();
|
||||
expect(result.current.tooltipContent?.spanName).toBe('test-span');
|
||||
expect(result.current.tooltipContent?.clientX).toBe(150);
|
||||
expect(result.current.tooltipContent?.clientY).toBe(61);
|
||||
});
|
||||
|
||||
it('clears hover when moving to empty area', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseMove({
|
||||
clientX: 150,
|
||||
clientY: 61,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(result.current.hoveredSpanId).toBe('hover-span');
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseMove({
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(result.current.hoveredSpanId).toBeNull();
|
||||
expect(result.current.tooltipContent).toBeNull();
|
||||
});
|
||||
|
||||
it('clears hover on mouse leave', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseMove({
|
||||
clientX: 150,
|
||||
clientY: 61,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseLeave();
|
||||
});
|
||||
|
||||
expect(result.current.hoveredSpanId).toBeNull();
|
||||
expect(result.current.tooltipContent).toBeNull();
|
||||
});
|
||||
|
||||
it('suppresses click when suppressClickRef is set', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
defaultArgs.suppressClickRef.current = true;
|
||||
|
||||
act(() => {
|
||||
result.current.handleClick({
|
||||
clientX: 150,
|
||||
clientY: 61,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(defaultArgs.onSpanClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onSpanClick when clicking on span', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleClick({
|
||||
clientX: 150,
|
||||
clientY: 61,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(defaultArgs.onSpanClick).toHaveBeenCalledWith('hover-span');
|
||||
});
|
||||
|
||||
it('uses clientX/clientY for tooltip positioning', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseMove({
|
||||
clientX: 200,
|
||||
clientY: 60,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(result.current.tooltipContent?.clientX).toBe(200);
|
||||
expect(result.current.tooltipContent?.clientY).toBe(60);
|
||||
});
|
||||
|
||||
it('does not update hover during drag', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
defaultArgs.isDraggingRef.current = true;
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseMove({
|
||||
clientX: 150,
|
||||
clientY: 61,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(result.current.hoveredSpanId).toBeNull();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user