mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-14 01:02:06 +00:00
Compare commits
21 Commits
debug_time
...
feat/chart
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52eedce12f | ||
|
|
b2c0291c11 | ||
|
|
83bf21fb6f | ||
|
|
3443b25791 | ||
|
|
6ac88d2a17 | ||
|
|
0c1078c494 | ||
|
|
937ebc1582 | ||
|
|
d0ab05a84d | ||
|
|
d7b681eaf8 | ||
|
|
fa1620f4da | ||
|
|
69a3d214fb | ||
|
|
dcc8173c79 | ||
|
|
4b4ef5ce58 | ||
|
|
5b8d5fbfd3 | ||
|
|
4affdeda56 | ||
|
|
99944cc1de | ||
|
|
d1bd36e88a | ||
|
|
d26d4ebd31 | ||
|
|
771e5bd287 | ||
|
|
bd33304912 | ||
|
|
ca1cc0a4ac |
5
.github/CODEOWNERS
vendored
5
.github/CODEOWNERS
vendored
@@ -127,12 +127,15 @@
|
|||||||
/frontend/src/pages/DashboardsListPage/ @SigNoz/pulse-frontend
|
/frontend/src/pages/DashboardsListPage/ @SigNoz/pulse-frontend
|
||||||
/frontend/src/container/ListOfDashboard/ @SigNoz/pulse-frontend
|
/frontend/src/container/ListOfDashboard/ @SigNoz/pulse-frontend
|
||||||
|
|
||||||
|
# Dashboard Widget Page
|
||||||
|
/frontend/src/pages/DashboardWidget/ @SigNoz/pulse-frontend
|
||||||
|
/frontend/src/container/NewWidget/ @SigNoz/pulse-frontend
|
||||||
|
|
||||||
## Dashboard Page
|
## Dashboard Page
|
||||||
|
|
||||||
/frontend/src/pages/DashboardPage/ @SigNoz/pulse-frontend
|
/frontend/src/pages/DashboardPage/ @SigNoz/pulse-frontend
|
||||||
/frontend/src/container/DashboardContainer/ @SigNoz/pulse-frontend
|
/frontend/src/container/DashboardContainer/ @SigNoz/pulse-frontend
|
||||||
/frontend/src/container/GridCardLayout/ @SigNoz/pulse-frontend
|
/frontend/src/container/GridCardLayout/ @SigNoz/pulse-frontend
|
||||||
/frontend/src/container/NewWidget/ @SigNoz/pulse-frontend
|
|
||||||
|
|
||||||
## Public Dashboard Page
|
## Public Dashboard Page
|
||||||
|
|
||||||
|
|||||||
29
frontend/__mocks__/resizableMock.tsx
Normal file
29
frontend/__mocks__/resizableMock.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
type CommonProps = PropsWithChildren<{
|
||||||
|
className?: string;
|
||||||
|
minSize?: number;
|
||||||
|
maxSize?: number;
|
||||||
|
defaultSize?: number;
|
||||||
|
direction?: 'horizontal' | 'vertical';
|
||||||
|
autoSaveId?: string;
|
||||||
|
withHandle?: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export function ResizablePanelGroup({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: CommonProps): JSX.Element {
|
||||||
|
return <div className={className}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResizablePanel({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: CommonProps): JSX.Element {
|
||||||
|
return <div className={className}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResizableHandle({ className }: CommonProps): JSX.Element {
|
||||||
|
return <div className={className} />;
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ const config: Config.InitialOptions = {
|
|||||||
'\\.(css|less|scss)$': '<rootDir>/__mocks__/cssMock.ts',
|
'\\.(css|less|scss)$': '<rootDir>/__mocks__/cssMock.ts',
|
||||||
'\\.md$': '<rootDir>/__mocks__/cssMock.ts',
|
'\\.md$': '<rootDir>/__mocks__/cssMock.ts',
|
||||||
'^uplot$': '<rootDir>/__mocks__/uplotMock.ts',
|
'^uplot$': '<rootDir>/__mocks__/uplotMock.ts',
|
||||||
|
'^@signozhq/resizable$': '<rootDir>/__mocks__/resizableMock.tsx',
|
||||||
'^hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
|
'^hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
|
||||||
'^src/hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
|
'^src/hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
|
||||||
'^.*/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
|
'^.*/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
"@signozhq/sonner": "0.1.0",
|
"@signozhq/sonner": "0.1.0",
|
||||||
"@signozhq/switch": "0.0.2",
|
"@signozhq/switch": "0.0.2",
|
||||||
"@signozhq/table": "0.3.7",
|
"@signozhq/table": "0.3.7",
|
||||||
"@signozhq/toggle-group": "^0.0.1",
|
"@signozhq/toggle-group": "0.0.1",
|
||||||
"@signozhq/tooltip": "0.0.2",
|
"@signozhq/tooltip": "0.0.2",
|
||||||
"@tanstack/react-table": "8.20.6",
|
"@tanstack/react-table": "8.20.6",
|
||||||
"@tanstack/react-virtual": "3.11.2",
|
"@tanstack/react-virtual": "3.11.2",
|
||||||
|
|||||||
@@ -1,31 +1,6 @@
|
|||||||
.custom-time-picker {
|
.custom-time-picker {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: column;
|
||||||
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 {
|
.timeSelection-input {
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|||||||
@@ -16,15 +16,6 @@ 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', () => {
|
jest.mock('providers/Timezone', () => {
|
||||||
const actual = jest.requireActual('providers/Timezone');
|
const actual = jest.requireActual('providers/Timezone');
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,9 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { Button } from '@signozhq/button';
|
|
||||||
import { Input, InputRef, Popover, Tooltip } from 'antd';
|
import { Input, InputRef, Popover, Tooltip } from 'antd';
|
||||||
import cx from 'classnames';
|
import cx from 'classnames';
|
||||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||||
import { QueryParams } from 'constants/query';
|
|
||||||
import { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal';
|
import { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal';
|
||||||
import {
|
import {
|
||||||
FixedDurationSuggestionOptions,
|
FixedDurationSuggestionOptions,
|
||||||
@@ -19,11 +17,9 @@ import {
|
|||||||
RelativeDurationSuggestionOptions,
|
RelativeDurationSuggestionOptions,
|
||||||
} from 'container/TopNav/DateTimeSelectionV2/constants';
|
} from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { useZoomOut } from 'hooks/useZoomOut';
|
|
||||||
import { isValidShortHandDateTimeFormat } from 'lib/getMinMax';
|
import { isValidShortHandDateTimeFormat } from 'lib/getMinMax';
|
||||||
import { isZoomOutDisabled } from 'lib/zoomOutUtils';
|
|
||||||
import { defaultTo, isFunction, noop } from 'lodash-es';
|
import { defaultTo, isFunction, noop } from 'lodash-es';
|
||||||
import { ChevronDown, ChevronUp, ZoomOut } from 'lucide-react';
|
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
import { useTimezone } from 'providers/Timezone';
|
import { useTimezone } from 'providers/Timezone';
|
||||||
import { getTimeDifference, validateEpochRange } from 'utils/epochUtils';
|
import { getTimeDifference, validateEpochRange } from 'utils/epochUtils';
|
||||||
import { popupContainer } from 'utils/selectPopupContainer';
|
import { popupContainer } from 'utils/selectPopupContainer';
|
||||||
@@ -70,8 +66,6 @@ interface CustomTimePickerProps {
|
|||||||
showRecentlyUsed?: boolean;
|
showRecentlyUsed?: boolean;
|
||||||
minTime: number;
|
minTime: number;
|
||||||
maxTime: number;
|
maxTime: number;
|
||||||
/** When true, zoom-out button is hidden (e.g. in drawer/modal time selection) */
|
|
||||||
isModalTimeSelection?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CustomTimePicker({
|
function CustomTimePicker({
|
||||||
@@ -94,7 +88,6 @@ function CustomTimePicker({
|
|||||||
showRecentlyUsed = true,
|
showRecentlyUsed = true,
|
||||||
minTime,
|
minTime,
|
||||||
maxTime,
|
maxTime,
|
||||||
isModalTimeSelection = false,
|
|
||||||
}: CustomTimePickerProps): JSX.Element {
|
}: CustomTimePickerProps): JSX.Element {
|
||||||
const [
|
const [
|
||||||
selectedTimePlaceholderValue,
|
selectedTimePlaceholderValue,
|
||||||
@@ -123,14 +116,6 @@ function CustomTimePicker({
|
|||||||
|
|
||||||
const [isOpenedFromFooter, setIsOpenedFromFooter] = useState(false);
|
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
|
// 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
|
// 1m, 2h, 3d, 4w -> Last 1 minute, Last 2 hours, Last 3 days, Last 4 weeks
|
||||||
const getSelectedTimeRangeLabelInRelativeFormat = (
|
const getSelectedTimeRangeLabelInRelativeFormat = (
|
||||||
@@ -646,23 +631,6 @@ function CustomTimePicker({
|
|||||||
/>
|
/>
|
||||||
</Popover>
|
</Popover>
|
||||||
</Tooltip>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
} from 'hooks/dashboard/useDashboardVariables';
|
} from 'hooks/dashboard/useDashboardVariables';
|
||||||
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
|
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
|
||||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||||
import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables';
|
|
||||||
import { updateDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
|
import { updateDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
|
||||||
import {
|
import {
|
||||||
enqueueDescendantsOfVariable,
|
enqueueDescendantsOfVariable,
|
||||||
@@ -30,7 +29,7 @@ function DashboardVariableSelection(): JSX.Element | null {
|
|||||||
updateLocalStorageDashboardVariables,
|
updateLocalStorageDashboardVariables,
|
||||||
} = useDashboard();
|
} = useDashboard();
|
||||||
|
|
||||||
const { updateUrlVariable, getUrlVariables } = useVariablesFromUrl();
|
const { updateUrlVariable } = useVariablesFromUrl();
|
||||||
|
|
||||||
const { dashboardVariables } = useDashboardVariables();
|
const { dashboardVariables } = useDashboardVariables();
|
||||||
const dashboardId = useDashboardVariablesSelector(
|
const dashboardId = useDashboardVariablesSelector(
|
||||||
@@ -50,15 +49,6 @@ function DashboardVariableSelection(): JSX.Element | null {
|
|||||||
(state) => state.globalTime,
|
(state) => state.globalTime,
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Initialize variables with default values if not in URL
|
|
||||||
initializeDefaultVariables(
|
|
||||||
dashboardVariables,
|
|
||||||
getUrlVariables,
|
|
||||||
updateUrlVariable,
|
|
||||||
);
|
|
||||||
}, [getUrlVariables, updateUrlVariable, dashboardVariables]);
|
|
||||||
|
|
||||||
// Memoize the order key to avoid unnecessary triggers
|
// Memoize the order key to avoid unnecessary triggers
|
||||||
const variableOrderKey = useMemo(() => {
|
const variableOrderKey = useMemo(() => {
|
||||||
const queryVariableOrderKey = dependencyData?.order?.join(',') ?? '';
|
const queryVariableOrderKey = dependencyData?.order?.join(',') ?? '';
|
||||||
|
|||||||
@@ -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([
|
const apiResponse = createApiResponse([
|
||||||
{
|
{
|
||||||
metric: {},
|
metric: {},
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import getLabelName from 'lib/getLabelName';
|
|||||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||||
import {
|
import {
|
||||||
DrawStyle,
|
DrawStyle,
|
||||||
|
FillMode,
|
||||||
LineInterpolation,
|
LineInterpolation,
|
||||||
LineStyle,
|
LineStyle,
|
||||||
VisibilityMode,
|
|
||||||
} from 'lib/uPlotV2/config/types';
|
} from 'lib/uPlotV2/config/types';
|
||||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||||
import { isInvalidPlotValue } from 'lib/uPlotV2/utils/dataUtils';
|
import { isInvalidPlotValue } from 'lib/uPlotV2/utils/dataUtils';
|
||||||
@@ -124,12 +124,12 @@ export const prepareUPlotConfig = ({
|
|||||||
label: label,
|
label: label,
|
||||||
colorMapping: widget.customLegendColors ?? {},
|
colorMapping: widget.customLegendColors ?? {},
|
||||||
spanGaps: true,
|
spanGaps: true,
|
||||||
lineStyle: LineStyle.Solid,
|
lineStyle: widget.lineStyle || LineStyle.Solid,
|
||||||
lineInterpolation: LineInterpolation.Spline,
|
lineInterpolation: widget.lineInterpolation || LineInterpolation.Spline,
|
||||||
showPoints: hasSingleValidPoint
|
showPoints:
|
||||||
? VisibilityMode.Always
|
widget.showPoints || hasSingleValidPoint ? true : !!widget.showPoints,
|
||||||
: VisibilityMode.Never,
|
|
||||||
pointSize: 5,
|
pointSize: 5,
|
||||||
|
fillMode: widget.fillMode || FillMode.None,
|
||||||
isDarkMode,
|
isDarkMode,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -65,6 +65,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.new-widget-container {
|
||||||
|
.resizable-panel-left-container {
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizable-panel-right-container {
|
||||||
|
overflow-y: auto;
|
||||||
|
min-width: 350px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-resizable-handle {
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.lightMode {
|
.lightMode {
|
||||||
.edit-header {
|
.edit-header {
|
||||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
.column-unit-selector {
|
.column-unit-selector {
|
||||||
margin-top: 16px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
.heading {
|
.heading {
|
||||||
color: var(--bg-vanilla-400);
|
color: var(--bg-vanilla-400);
|
||||||
@@ -30,6 +32,11 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
&-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.lightMode {
|
.lightMode {
|
||||||
|
|||||||
@@ -72,22 +72,24 @@ export function ColumnUnitSelector(
|
|||||||
return (
|
return (
|
||||||
<section className="column-unit-selector">
|
<section className="column-unit-selector">
|
||||||
<Typography.Text className="heading">Column Units</Typography.Text>
|
<Typography.Text className="heading">Column Units</Typography.Text>
|
||||||
{aggregationQueries.map(({ value, label }) => {
|
<div className="column-unit-selector-content">
|
||||||
const baseQueryName = value.split('.')[0];
|
{aggregationQueries.map(({ value, label }) => {
|
||||||
return (
|
const baseQueryName = value.split('.')[0];
|
||||||
<YAxisUnitSelectorV2
|
return (
|
||||||
value={columnUnits[value] || ''}
|
<YAxisUnitSelectorV2
|
||||||
onSelect={(unitValue: string): void =>
|
value={columnUnits[value] || ''}
|
||||||
handleColumnUnitSelect(value, unitValue)
|
onSelect={(unitValue: string): void =>
|
||||||
}
|
handleColumnUnitSelect(value, unitValue)
|
||||||
fieldLabel={label}
|
}
|
||||||
key={value}
|
fieldLabel={label}
|
||||||
selectedQueryName={baseQueryName}
|
key={value}
|
||||||
// Update the column unit value automatically only in create mode
|
selectedQueryName={baseQueryName}
|
||||||
shouldUpdateYAxisUnit={isNewDashboard}
|
// Update the column unit value automatically only in create mode
|
||||||
/>
|
shouldUpdateYAxisUnit={isNewDashboard}
|
||||||
);
|
/>
|
||||||
})}
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</section>
|
</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
|
// Check that the add button is present
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('button', { name: /context link/i }),
|
screen.getByRole('button', { name: /context link/i }),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { Button, Modal, Typography } from 'antd';
|
import { Button, Modal } from 'antd';
|
||||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||||
import { GripVertical, Pencil, Plus, Trash2 } from 'lucide-react';
|
import { GripVertical, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
@@ -134,11 +134,16 @@ function ContextLinks({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="context-links-container">
|
<div className="context-links-container">
|
||||||
<Typography.Text className="context-links-text">
|
|
||||||
Context Links
|
|
||||||
</Typography.Text>
|
|
||||||
|
|
||||||
<div className="context-links-list">
|
<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>
|
<OverlayScrollbar>
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
@@ -160,16 +165,6 @@ function ContextLinks({
|
|||||||
</SortableContext>
|
</SortableContext>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
</OverlayScrollbar>
|
</OverlayScrollbar>
|
||||||
|
|
||||||
{/* button to add context link */}
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
className="add-context-link-button"
|
|
||||||
icon={<Plus size={12} />}
|
|
||||||
onClick={handleAddContextLink}
|
|
||||||
>
|
|
||||||
Context Link
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-links-text {
|
.context-links-text {
|
||||||
@@ -110,10 +109,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.add-context-link-button {
|
.add-context-link-button {
|
||||||
display: flex;
|
width: 100%;
|
||||||
align-items: center;
|
|
||||||
margin: auto;
|
|
||||||
width: fit-content;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.lightMode {
|
.lightMode {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
.fill-mode-selector {
|
||||||
|
.fill-mode-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill-mode-label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightMode {
|
||||||
|
.fill-mode-selector {
|
||||||
|
.fill-mode-label {
|
||||||
|
color: var(--bg-ink-400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
|
||||||
|
import { Typography } from 'antd';
|
||||||
|
import { FillMode } from 'lib/uPlotV2/config/types';
|
||||||
|
|
||||||
|
import './FillModeSelector.styles.scss';
|
||||||
|
|
||||||
|
interface FillModeSelectorProps {
|
||||||
|
value: FillMode;
|
||||||
|
onChange: (value: FillMode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FillModeSelector({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: FillModeSelectorProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<section className="fill-mode-selector control-container">
|
||||||
|
<Typography.Text className="section-heading">Fill mode</Typography.Text>
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
value={value}
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
onValueChange={(newValue): void => {
|
||||||
|
if (newValue) {
|
||||||
|
onChange(newValue as FillMode);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ToggleGroupItem value={FillMode.None} aria-label="None" title="None">
|
||||||
|
<svg
|
||||||
|
className="fill-mode-icon"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
fill="none"
|
||||||
|
stroke="#888"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="8" y="16" width="32" height="16" stroke="#888" fill="none" />
|
||||||
|
</svg>
|
||||||
|
<Typography.Text className="section-heading-small">None</Typography.Text>
|
||||||
|
</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value={FillMode.Solid} aria-label="Solid" title="Solid">
|
||||||
|
<svg
|
||||||
|
className="fill-mode-icon"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
fill="none"
|
||||||
|
stroke="#888"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="8" y="16" width="32" height="16" fill="#888" />
|
||||||
|
</svg>
|
||||||
|
<Typography.Text className="section-heading-small">Solid</Typography.Text>
|
||||||
|
</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem
|
||||||
|
value={FillMode.Gradient}
|
||||||
|
aria-label="Gradient"
|
||||||
|
title="Gradient"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="fill-mode-icon"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
fill="none"
|
||||||
|
stroke="#888"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="fill-gradient" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stopColor="#888" stopOpacity="0.2" />
|
||||||
|
<stop offset="100%" stopColor="#888" stopOpacity="0.8" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect
|
||||||
|
x="8"
|
||||||
|
y="16"
|
||||||
|
width="32"
|
||||||
|
height="16"
|
||||||
|
fill="url(#fill-gradient)"
|
||||||
|
stroke="#888"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<Typography.Text className="section-heading-small">
|
||||||
|
Gradient
|
||||||
|
</Typography.Text>
|
||||||
|
</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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="section-heading">
|
||||||
|
Line interpolation
|
||||||
|
</Typography.Text>
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
value={value}
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
onValueChange={(newValue): void => {
|
||||||
|
if (newValue) {
|
||||||
|
onChange(newValue as LineInterpolation);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ToggleGroupItem
|
||||||
|
value={LineInterpolation.Linear}
|
||||||
|
aria-label="Linear"
|
||||||
|
title="Linear"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="line-interpolation-icon"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
fill="none"
|
||||||
|
stroke="#888"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="8" cy="32" r="3" fill="#888" />
|
||||||
|
<circle cx="24" cy="16" r="3" fill="#888" />
|
||||||
|
<circle cx="40" cy="32" r="3" fill="#888" />
|
||||||
|
<path d="M8 32 L24 16 L40 32" stroke="#888" />
|
||||||
|
</svg>
|
||||||
|
</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value={LineInterpolation.Spline} aria-label="Spline">
|
||||||
|
<svg
|
||||||
|
className="line-interpolation-icon"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
fill="none"
|
||||||
|
stroke="#888"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="8" cy="32" r="3" fill="#888" />
|
||||||
|
<circle cx="24" cy="16" r="3" fill="#888" />
|
||||||
|
<circle cx="40" cy="32" r="3" fill="#888" />
|
||||||
|
<path d="M8 32 C16 8, 32 8, 40 32" />
|
||||||
|
</svg>
|
||||||
|
</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem
|
||||||
|
value={LineInterpolation.StepAfter}
|
||||||
|
aria-label="Step After"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="line-interpolation-icon"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
fill="none"
|
||||||
|
stroke="#888"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="8" cy="32" r="3" fill="#888" />
|
||||||
|
<circle cx="24" cy="16" r="3" fill="#888" />
|
||||||
|
<circle cx="40" cy="32" r="3" fill="#888" />
|
||||||
|
<path d="M8 32 V16 H24 V32 H40" />
|
||||||
|
</svg>
|
||||||
|
</ToggleGroupItem>
|
||||||
|
|
||||||
|
<ToggleGroupItem
|
||||||
|
value={LineInterpolation.StepBefore}
|
||||||
|
aria-label="Step Before"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="line-interpolation-icon"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
fill="none"
|
||||||
|
stroke="#888"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="8" cy="32" r="3" fill="#888" />
|
||||||
|
<circle cx="24" cy="16" r="3" fill="#888" />
|
||||||
|
<circle cx="40" cy="32" r="3" fill="#888" />
|
||||||
|
<path d="M8 32 H24 V16 H40 V32" />
|
||||||
|
</svg>
|
||||||
|
</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
.line-style-selector {
|
||||||
|
.line-style-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-style-label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightMode {
|
||||||
|
.line-style-selector {
|
||||||
|
.line-style-label {
|
||||||
|
color: var(--bg-ink-400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
|
||||||
|
import { Typography } from 'antd';
|
||||||
|
import { LineStyle } from 'lib/uPlotV2/config/types';
|
||||||
|
|
||||||
|
import './LineStyleSelector.styles.scss';
|
||||||
|
|
||||||
|
interface LineStyleSelectorProps {
|
||||||
|
value: LineStyle;
|
||||||
|
onChange: (value: LineStyle) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LineStyleSelector({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: LineStyleSelectorProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<section className="line-style-selector control-container">
|
||||||
|
<Typography.Text className="section-heading">Line style</Typography.Text>
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
value={value}
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
onValueChange={(newValue): void => {
|
||||||
|
if (newValue) {
|
||||||
|
onChange(newValue as LineStyle);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ToggleGroupItem value={LineStyle.Solid} aria-label="Solid" title="Solid">
|
||||||
|
<svg
|
||||||
|
className="line-style-icon"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
fill="none"
|
||||||
|
stroke="#888"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M8 24 L40 24" />
|
||||||
|
</svg>
|
||||||
|
<Typography.Text className="section-heading-small">Solid</Typography.Text>
|
||||||
|
</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem
|
||||||
|
value={LineStyle.Dashed}
|
||||||
|
aria-label="Dashed"
|
||||||
|
title="Dashed"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="line-style-icon"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
fill="none"
|
||||||
|
stroke="#888"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeDasharray="6 4"
|
||||||
|
>
|
||||||
|
<path d="M8 24 L40 24" />
|
||||||
|
</svg>
|
||||||
|
<Typography.Text className="section-heading-small">Dashed</Typography.Text>
|
||||||
|
</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,28 @@
|
|||||||
.right-container {
|
.right-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
font-family: 'Space Mono';
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
font-size: 13px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 18px; /* 138.462% */
|
||||||
|
letter-spacing: 0.52px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading-small {
|
||||||
|
font-family: 'Space Mono';
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
word-break: initial;
|
||||||
|
line-height: 16px; /* 133.333% */
|
||||||
|
letter-spacing: 0.48px;
|
||||||
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -24,25 +46,14 @@
|
|||||||
letter-spacing: -0.07px;
|
letter-spacing: -0.07px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.control-container {
|
||||||
.name-description {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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;
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.typography {
|
.name-description {
|
||||||
color: var(--bg-vanilla-400);
|
padding: 0 0 4px 0;
|
||||||
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 {
|
.name-input {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -88,22 +99,26 @@
|
|||||||
.panel-config {
|
.panel-config {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 12px 12px 16px 12px;
|
|
||||||
gap: 8px;
|
|
||||||
border-bottom: 1px solid var(--bg-slate-500);
|
|
||||||
|
|
||||||
.typography {
|
.toggle-card {
|
||||||
color: var(--bg-vanilla-400);
|
display: flex;
|
||||||
font-family: 'Space Mono';
|
flex-direction: row;
|
||||||
font-size: 13px;
|
justify-content: space-between;
|
||||||
font-style: normal;
|
align-items: center;
|
||||||
font-weight: 400;
|
padding: 12px;
|
||||||
line-height: 18px; /* 138.462% */
|
border-radius: 2px;
|
||||||
letter-spacing: 0.52px;
|
border: 1px solid var(--bg-slate-400);
|
||||||
text-transform: uppercase;
|
background: var(--bg-ink-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-card-text-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-type-select {
|
.panel-type-select {
|
||||||
|
width: 100%;
|
||||||
.ant-select-selector {
|
.ant-select-selector {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
@@ -115,98 +130,32 @@
|
|||||||
border: 1px solid var(--bg-slate-400);
|
border: 1px solid var(--bg-slate-400);
|
||||||
background: var(--bg-ink-300);
|
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 {
|
.toggle-card-description {
|
||||||
margin-top: 16px;
|
color: var(--bg-vanilla-400);
|
||||||
display: flex;
|
font-family: Inter;
|
||||||
padding: 12px;
|
font-size: 12px;
|
||||||
justify-content: space-between;
|
font-style: normal;
|
||||||
align-items: center;
|
font-weight: 400;
|
||||||
border-radius: 2px;
|
opacity: 0.6;
|
||||||
border: 1px solid var(--bg-slate-400);
|
line-height: 16px; /* 133.333% */
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-scale,
|
.log-scale,
|
||||||
.decimal-precision-selector {
|
.decimal-precision-selector,
|
||||||
margin-top: 16px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend-position {
|
.legend-position {
|
||||||
margin-top: 16px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
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,
|
||||||
.y-axis-unit-selector-v2 {
|
.y-axis-unit-selector-v2 {
|
||||||
margin-top: 16px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
||||||
.heading {
|
.heading {
|
||||||
color: var(--bg-vanilla-400);
|
@extend .section-heading;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
@@ -259,7 +208,6 @@
|
|||||||
|
|
||||||
.text {
|
.text {
|
||||||
color: var(--bg-vanilla-400);
|
color: var(--bg-vanilla-400);
|
||||||
font-family: 'Space Mono';
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
@@ -278,40 +226,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stack-chart {
|
.stack-chart {
|
||||||
margin-top: 16px;
|
flex-direction: row;
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
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 {
|
.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 {
|
.bucket-size-label {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
@@ -340,7 +259,6 @@
|
|||||||
|
|
||||||
.label {
|
.label {
|
||||||
color: var(--bg-vanilla-400);
|
color: var(--bg-vanilla-400);
|
||||||
font-family: 'Space Mono';
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
@@ -352,16 +270,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-links {
|
|
||||||
border-bottom: 1px solid var(--bg-slate-500);
|
|
||||||
}
|
|
||||||
|
|
||||||
.alerts {
|
.alerts {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 12px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
border-bottom: 1px solid var(--bg-slate-500);
|
padding: 12px;
|
||||||
|
min-height: 44px;
|
||||||
|
border-top: 1px solid var(--bg-slate-500);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
.left-section {
|
.left-section {
|
||||||
@@ -375,7 +290,6 @@
|
|||||||
|
|
||||||
.alerts-text {
|
.alerts-text {
|
||||||
color: var(--bg-vanilla-400);
|
color: var(--bg-vanilla-400);
|
||||||
font-family: Inter;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
@@ -387,6 +301,16 @@
|
|||||||
color: var(--bg-vanilla-400);
|
color: var(--bg-vanilla-400);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.context-links {
|
||||||
|
padding: 12px 12px 16px 12px;
|
||||||
|
border-bottom: 1px solid var(--bg-slate-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thresholds-section {
|
||||||
|
padding: 12px 12px 16px 12px;
|
||||||
|
border-top: 1px solid var(--bg-slate-500);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-option {
|
.select-option {
|
||||||
@@ -411,6 +335,9 @@
|
|||||||
.lightMode {
|
.lightMode {
|
||||||
.right-container {
|
.right-container {
|
||||||
background-color: var(--bg-vanilla-100);
|
background-color: var(--bg-vanilla-100);
|
||||||
|
.section-heading {
|
||||||
|
color: var(--bg-ink-400);
|
||||||
|
}
|
||||||
.header {
|
.header {
|
||||||
.header-text {
|
.header-text {
|
||||||
color: var(--bg-ink-400);
|
color: var(--bg-ink-400);
|
||||||
@@ -418,9 +345,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.name-description {
|
.name-description {
|
||||||
border-top: 1px solid var(--bg-vanilla-300);
|
|
||||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
|
||||||
|
|
||||||
.typography {
|
.typography {
|
||||||
color: var(--bg-ink-400);
|
color: var(--bg-ink-400);
|
||||||
}
|
}
|
||||||
@@ -441,12 +365,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel-config {
|
.panel-config {
|
||||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
|
||||||
|
|
||||||
.typography {
|
|
||||||
color: var(--bg-ink-400);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-type-select {
|
.panel-type-select {
|
||||||
.ant-select-selector {
|
.ant-select-selector {
|
||||||
border: 1px solid var(--bg-vanilla-300);
|
border: 1px solid var(--bg-vanilla-300);
|
||||||
@@ -471,13 +389,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.fill-gaps {
|
.toggle-card {
|
||||||
border: 1px solid var(--bg-vanilla-300);
|
border: 1px solid var(--bg-vanilla-300);
|
||||||
background: var(--bg-vanilla-300);
|
background: var(--bg-vanilla-300);
|
||||||
|
|
||||||
.fill-gaps-text {
|
.fill-gaps-text {
|
||||||
color: var(--bg-ink-400);
|
color: var(--bg-ink-400);
|
||||||
}
|
}
|
||||||
|
.toggle-card-description {
|
||||||
|
color: var(--bg-ink-400);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.bucket-config {
|
.bucket-config {
|
||||||
@@ -530,7 +451,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.alerts {
|
.alerts {
|
||||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
border-top: 1px solid var(--bg-vanilla-300);
|
||||||
|
|
||||||
.left-section {
|
.left-section {
|
||||||
.bell-icon {
|
.bell-icon {
|
||||||
@@ -549,6 +470,10 @@
|
|||||||
.context-links {
|
.context-links {
|
||||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.thresholds-section {
|
||||||
|
border-top: 1px solid var(--bg-vanilla-300);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-option {
|
.select-option {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
.threshold-selector-container {
|
.threshold-selector-container {
|
||||||
padding: 12px;
|
|
||||||
padding-bottom: 80px;
|
padding-bottom: 80px;
|
||||||
|
|
||||||
.threshold-select {
|
.threshold-select {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { DndProvider } from 'react-dnd';
|
import { DndProvider } from 'react-dnd';
|
||||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||||
import { Typography } from 'antd';
|
import { Button } from 'antd';
|
||||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
import { useGetQueryLabels } from 'hooks/useGetQueryLabels';
|
import { useGetQueryLabels } from 'hooks/useGetQueryLabels';
|
||||||
import { Antenna, Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
import Threshold from './Threshold';
|
import Threshold from './Threshold';
|
||||||
@@ -68,11 +68,14 @@ function ThresholdSelector({
|
|||||||
<DndProvider backend={HTML5Backend}>
|
<DndProvider backend={HTML5Backend}>
|
||||||
<div className="threshold-selector-container">
|
<div className="threshold-selector-container">
|
||||||
<div className="threshold-select" onClick={addThresholdHandler}>
|
<div className="threshold-select" onClick={addThresholdHandler}>
|
||||||
<div className="left-section">
|
<Button
|
||||||
<Antenna size={14} className="icon" />
|
type="default"
|
||||||
<Typography.Text className="text">Thresholds</Typography.Text>
|
icon={<Plus size={14} />}
|
||||||
</div>
|
style={{ width: '100%' }}
|
||||||
<Plus size={14} onClick={addThresholdHandler} className="icon" />
|
onClick={addThresholdHandler}
|
||||||
|
>
|
||||||
|
Add Threshold
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{thresholds.map((threshold, idx) => (
|
{thresholds.map((threshold, idx) => (
|
||||||
<Threshold
|
<Threshold
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import { MemoryRouter } from 'react-router-dom';
|
|||||||
import { render as rtlRender, screen } from '@testing-library/react';
|
import { render as rtlRender, screen } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
|
import {
|
||||||
|
FillMode,
|
||||||
|
LineInterpolation,
|
||||||
|
LineStyle,
|
||||||
|
} from 'lib/uPlotV2/config/types';
|
||||||
import { AppContext } from 'providers/App/App';
|
import { AppContext } from 'providers/App/App';
|
||||||
import { IAppContext } from 'providers/App/types';
|
import { IAppContext } from 'providers/App/types';
|
||||||
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
|
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
|
||||||
@@ -165,6 +170,14 @@ describe('RightContainer - Alerts Section', () => {
|
|||||||
setContextLinks: jest.fn(),
|
setContextLinks: jest.fn(),
|
||||||
enableDrillDown: false,
|
enableDrillDown: false,
|
||||||
isNewDashboard: 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(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
.settings-section {
|
||||||
|
border-top: 1px solid var(--bg-slate-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 12px;
|
||||||
|
min-height: 44px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.settings-section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
font-family: 'Space Mono';
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron-icon {
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
transition: transform 0.2s ease-in-out;
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section-content {
|
||||||
|
padding: 0 12px 0 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transition: max-height 0.25s ease, opacity 0.25s ease, padding 0.25s ease;
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
padding-bottom: 24px;
|
||||||
|
max-height: 1000px;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightMode {
|
||||||
|
.settings-section-header {
|
||||||
|
.chevron-icon {
|
||||||
|
color: var(--bg-ink-400);
|
||||||
|
}
|
||||||
|
.settings-section-title {
|
||||||
|
color: var(--bg-ink-400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
border-top: 1px solid var(--bg-vanilla-300);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -206,3 +206,59 @@ export const panelTypeVsDecimalPrecision: {
|
|||||||
[PANEL_TYPES.TRACE]: false,
|
[PANEL_TYPES.TRACE]: false,
|
||||||
[PANEL_TYPES.EMPTY_WIDGET]: false,
|
[PANEL_TYPES.EMPTY_WIDGET]: false,
|
||||||
} as const;
|
} 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;
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
|
||||||
Switch,
|
Switch,
|
||||||
Typography,
|
Typography,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
@@ -28,9 +27,22 @@ import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
|
|||||||
import useCreateAlerts from 'hooks/queryBuilder/useCreateAlerts';
|
import useCreateAlerts from 'hooks/queryBuilder/useCreateAlerts';
|
||||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
import {
|
import {
|
||||||
|
FillMode,
|
||||||
|
LineInterpolation,
|
||||||
|
LineStyle,
|
||||||
|
} from 'lib/uPlotV2/config/types';
|
||||||
|
import {
|
||||||
|
Antenna,
|
||||||
|
Axis3D,
|
||||||
ConciergeBell,
|
ConciergeBell,
|
||||||
|
Layers,
|
||||||
|
LayoutDashboard,
|
||||||
LineChart,
|
LineChart,
|
||||||
|
Link,
|
||||||
|
Paintbrush,
|
||||||
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
|
SlidersHorizontal,
|
||||||
Spline,
|
Spline,
|
||||||
SquareArrowOutUpRight,
|
SquareArrowOutUpRight,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -46,17 +58,22 @@ import { DataSource } from 'types/common/queryBuilder';
|
|||||||
import { popupContainer } from 'utils/selectPopupContainer';
|
import { popupContainer } from 'utils/selectPopupContainer';
|
||||||
|
|
||||||
import { ColumnUnitSelector } from './ColumnUnitSelector/ColumnUnitSelector';
|
import { ColumnUnitSelector } from './ColumnUnitSelector/ColumnUnitSelector';
|
||||||
|
import SettingsSection from './components/SettingsSection/SettingsSection';
|
||||||
import {
|
import {
|
||||||
panelTypeVsBucketConfig,
|
panelTypeVsBucketConfig,
|
||||||
panelTypeVsColumnUnitPreferences,
|
panelTypeVsColumnUnitPreferences,
|
||||||
panelTypeVsContextLinks,
|
panelTypeVsContextLinks,
|
||||||
panelTypeVsCreateAlert,
|
panelTypeVsCreateAlert,
|
||||||
panelTypeVsDecimalPrecision,
|
panelTypeVsDecimalPrecision,
|
||||||
|
panelTypeVsFillMode,
|
||||||
panelTypeVsFillSpan,
|
panelTypeVsFillSpan,
|
||||||
panelTypeVsLegendColors,
|
panelTypeVsLegendColors,
|
||||||
panelTypeVsLegendPosition,
|
panelTypeVsLegendPosition,
|
||||||
|
panelTypeVsLineInterpolation,
|
||||||
|
panelTypeVsLineStyle,
|
||||||
panelTypeVsLogScale,
|
panelTypeVsLogScale,
|
||||||
panelTypeVsPanelTimePreferences,
|
panelTypeVsPanelTimePreferences,
|
||||||
|
panelTypeVsShowPoints,
|
||||||
panelTypeVsSoftMinMax,
|
panelTypeVsSoftMinMax,
|
||||||
panelTypeVsStackingChartPreferences,
|
panelTypeVsStackingChartPreferences,
|
||||||
panelTypeVsThreshold,
|
panelTypeVsThreshold,
|
||||||
@@ -64,7 +81,10 @@ import {
|
|||||||
} from './constants';
|
} from './constants';
|
||||||
import ContextLinks from './ContextLinks';
|
import ContextLinks from './ContextLinks';
|
||||||
import DashboardYAxisUnitSelectorWrapper from './DashboardYAxisUnitSelectorWrapper';
|
import DashboardYAxisUnitSelectorWrapper from './DashboardYAxisUnitSelectorWrapper';
|
||||||
|
import { FillModeSelector } from './FillModeSelector';
|
||||||
import LegendColors from './LegendColors/LegendColors';
|
import LegendColors from './LegendColors/LegendColors';
|
||||||
|
import { LineInterpolationSelector } from './LineInterpolationSelector';
|
||||||
|
import { LineStyleSelector } from './LineStyleSelector';
|
||||||
import ThresholdSelector from './Threshold/ThresholdSelector';
|
import ThresholdSelector from './Threshold/ThresholdSelector';
|
||||||
import { ThresholdProps } from './Threshold/types';
|
import { ThresholdProps } from './Threshold/types';
|
||||||
import { timePreferance } from './timeItems';
|
import { timePreferance } from './timeItems';
|
||||||
@@ -91,6 +111,14 @@ function RightContainer({
|
|||||||
setTitle,
|
setTitle,
|
||||||
title,
|
title,
|
||||||
selectedGraph,
|
selectedGraph,
|
||||||
|
lineInterpolation,
|
||||||
|
setLineInterpolation,
|
||||||
|
fillMode,
|
||||||
|
setFillMode,
|
||||||
|
lineStyle,
|
||||||
|
setLineStyle,
|
||||||
|
showPoints,
|
||||||
|
setShowPoints,
|
||||||
bucketCount,
|
bucketCount,
|
||||||
bucketWidth,
|
bucketWidth,
|
||||||
stackedBarChart,
|
stackedBarChart,
|
||||||
@@ -167,6 +195,11 @@ function RightContainer({
|
|||||||
panelTypeVsContextLinks[selectedGraph] && enableDrillDown;
|
panelTypeVsContextLinks[selectedGraph] && enableDrillDown;
|
||||||
const allowDecimalPrecision = panelTypeVsDecimalPrecision[selectedGraph];
|
const allowDecimalPrecision = panelTypeVsDecimalPrecision[selectedGraph];
|
||||||
|
|
||||||
|
const allowLineInterpolation = panelTypeVsLineInterpolation[selectedGraph];
|
||||||
|
const allowLineStyle = panelTypeVsLineStyle[selectedGraph];
|
||||||
|
const allowFillMode = panelTypeVsFillMode[selectedGraph];
|
||||||
|
const allowShowPoints = panelTypeVsShowPoints[selectedGraph];
|
||||||
|
|
||||||
const { currentQuery } = useQueryBuilder();
|
const { currentQuery } = useQueryBuilder();
|
||||||
|
|
||||||
const [graphTypes, setGraphTypes] = useState<ItemsProps[]>(GraphTypes);
|
const [graphTypes, setGraphTypes] = useState<ItemsProps[]>(GraphTypes);
|
||||||
@@ -178,6 +211,37 @@ function RightContainer({
|
|||||||
}));
|
}));
|
||||||
}, [dashboardVariables]);
|
}, [dashboardVariables]);
|
||||||
|
|
||||||
|
const isAxisSectionVisible = useMemo(() => allowSoftMinMax || allowLogScale, [
|
||||||
|
allowSoftMinMax,
|
||||||
|
allowLogScale,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isFormattingSectionVisible = useMemo(
|
||||||
|
() => allowYAxisUnit || allowDecimalPrecision || allowPanelColumnPreference,
|
||||||
|
[allowYAxisUnit, allowDecimalPrecision, allowPanelColumnPreference],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLegendSectionVisible = useMemo(
|
||||||
|
() => allowLegendPosition || allowLegendColors,
|
||||||
|
[allowLegendPosition, allowLegendColors],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isChartAppearanceSectionVisible = useMemo(
|
||||||
|
() =>
|
||||||
|
/**
|
||||||
|
* Disabled for now as we are not done with other settings in chart appearance section
|
||||||
|
* TODO: @ahrefabhi Enable this after we are done other settings in chart appearance section
|
||||||
|
*/
|
||||||
|
|
||||||
|
// eslint-disable-next-line sonarjs/no-redundant-boolean
|
||||||
|
false &&
|
||||||
|
(allowFillMode ||
|
||||||
|
allowLineStyle ||
|
||||||
|
allowLineInterpolation ||
|
||||||
|
allowShowPoints),
|
||||||
|
[allowFillMode, allowLineStyle, allowLineInterpolation, allowShowPoints],
|
||||||
|
);
|
||||||
|
|
||||||
const updateCursorAndDropdown = (value: string, pos: number): void => {
|
const updateCursorAndDropdown = (value: string, pos: number): void => {
|
||||||
setCursorPos(pos);
|
setCursorPos(pos);
|
||||||
const lastDollar = value.lastIndexOf('$', pos - 1);
|
const lastDollar = value.lastIndexOf('$', pos - 1);
|
||||||
@@ -193,6 +257,15 @@ function RightContainer({
|
|||||||
}, 0);
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const decimapPrecisionOptions = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{ label: '0 decimals', value: PrecisionOptionsEnum.ZERO },
|
||||||
|
{ label: '1 decimal', value: PrecisionOptionsEnum.ONE },
|
||||||
|
{ label: '2 decimals', value: PrecisionOptionsEnum.TWO },
|
||||||
|
{ label: '3 decimals', value: PrecisionOptionsEnum.THREE },
|
||||||
|
];
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleInputCursor = (): void => {
|
const handleInputCursor = (): void => {
|
||||||
const pos = inputRef.current?.input?.selectionStart ?? 0;
|
const pos = inputRef.current?.input?.selectionStart ?? 0;
|
||||||
updateCursorAndDropdown(inputValue, pos);
|
updateCursorAndDropdown(inputValue, pos);
|
||||||
@@ -263,269 +336,333 @@ function RightContainer({
|
|||||||
<div className="right-container">
|
<div className="right-container">
|
||||||
<section className="header">
|
<section className="header">
|
||||||
<div className="purple-dot" />
|
<div className="purple-dot" />
|
||||||
<Typography.Text className="header-text">Panel details</Typography.Text>
|
<Typography.Text className="header-text">Panel Settings</Typography.Text>
|
||||||
</section>
|
</section>
|
||||||
<section className="name-description">
|
|
||||||
<Typography.Text className="typography">Name</Typography.Text>
|
|
||||||
<AutoComplete
|
|
||||||
options={dashboardVariableOptions}
|
|
||||||
value={inputValue}
|
|
||||||
onChange={onInputChange}
|
|
||||||
onSelect={onSelect}
|
|
||||||
filterOption={filterOption}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
getPopupContainer={popupContainer}
|
|
||||||
placeholder="Enter the panel name here..."
|
|
||||||
open={autoCompleteOpen}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
rootClassName="name-input"
|
|
||||||
ref={inputRef}
|
|
||||||
onSelect={handleInputCursor}
|
|
||||||
onClick={handleInputCursor}
|
|
||||||
onBlur={(): void => setAutoCompleteOpen(false)}
|
|
||||||
/>
|
|
||||||
</AutoComplete>
|
|
||||||
<Typography.Text className="typography">Description</Typography.Text>
|
|
||||||
<TextArea
|
|
||||||
placeholder="Enter the panel description here..."
|
|
||||||
bordered
|
|
||||||
allowClear
|
|
||||||
value={description}
|
|
||||||
onChange={(event): void =>
|
|
||||||
onChangeHandler(setDescription, event.target.value)
|
|
||||||
}
|
|
||||||
rootClassName="description-input"
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
<section className="panel-config">
|
|
||||||
<Typography.Text className="typography">Panel Type</Typography.Text>
|
|
||||||
<Select
|
|
||||||
onChange={setGraphHandler}
|
|
||||||
value={selectedGraph}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
className="panel-type-select"
|
|
||||||
data-testid="panel-change-select"
|
|
||||||
data-stacking-state={stackedBarChart ? 'true' : 'false'}
|
|
||||||
>
|
|
||||||
{graphTypes.map((item) => (
|
|
||||||
<Option key={item.name} value={item.name}>
|
|
||||||
<div className="select-option">
|
|
||||||
<div className="icon">{item.icon}</div>
|
|
||||||
<Typography.Text className="display">{item.display}</Typography.Text>
|
|
||||||
</div>
|
|
||||||
</Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{allowFillSpans && (
|
<SettingsSection title="General" defaultOpen icon={<Pencil size={14} />}>
|
||||||
<Space className="fill-gaps">
|
<section className="name-description control-container">
|
||||||
<Typography className="fill-gaps-text">Fill gaps</Typography>
|
<Typography.Text className="section-heading">Name</Typography.Text>
|
||||||
<Switch
|
<AutoComplete
|
||||||
checked={isFillSpans}
|
options={dashboardVariableOptions}
|
||||||
size="small"
|
value={inputValue}
|
||||||
onChange={(checked): void => setIsFillSpans(checked)}
|
onChange={onInputChange}
|
||||||
|
onSelect={onSelect}
|
||||||
|
filterOption={filterOption}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
getPopupContainer={popupContainer}
|
||||||
|
placeholder="Enter the panel name here..."
|
||||||
|
open={autoCompleteOpen}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
rootClassName="name-input"
|
||||||
|
ref={inputRef}
|
||||||
|
onSelect={handleInputCursor}
|
||||||
|
onClick={handleInputCursor}
|
||||||
|
onBlur={(): void => setAutoCompleteOpen(false)}
|
||||||
/>
|
/>
|
||||||
</Space>
|
</AutoComplete>
|
||||||
)}
|
<Typography.Text className="section-heading">Description</Typography.Text>
|
||||||
|
<TextArea
|
||||||
{allowPanelTimePreference && (
|
placeholder="Enter the panel description here..."
|
||||||
<>
|
bordered
|
||||||
<Typography.Text className="panel-time-text">
|
allowClear
|
||||||
Panel Time Preference
|
value={description}
|
||||||
</Typography.Text>
|
onChange={(event): void =>
|
||||||
<TimePreference
|
onChangeHandler(setDescription, event.target.value)
|
||||||
{...{
|
|
||||||
selectedTime,
|
|
||||||
setSelectedTime,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{allowPanelColumnPreference && (
|
|
||||||
<ColumnUnitSelector
|
|
||||||
columnUnits={columnUnits}
|
|
||||||
setColumnUnits={setColumnUnits}
|
|
||||||
isNewDashboard={isNewDashboard}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{allowYAxisUnit && (
|
|
||||||
<DashboardYAxisUnitSelectorWrapper
|
|
||||||
onSelect={setYAxisUnit}
|
|
||||||
value={yAxisUnit || ''}
|
|
||||||
fieldLabel={
|
|
||||||
selectedGraphType === PanelDisplay.VALUE ||
|
|
||||||
selectedGraphType === PanelDisplay.PIE
|
|
||||||
? 'Unit'
|
|
||||||
: 'Y Axis Unit'
|
|
||||||
}
|
}
|
||||||
// Only update the y-axis unit value automatically in create mode
|
rootClassName="description-input"
|
||||||
shouldUpdateYAxisUnit={isNewDashboard}
|
|
||||||
/>
|
/>
|
||||||
)}
|
</section>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
{allowDecimalPrecision && (
|
<section className="panel-config">
|
||||||
<section className="decimal-precision-selector">
|
<SettingsSection
|
||||||
<Typography.Text className="typography">
|
title="Visualization"
|
||||||
Decimal Precision
|
defaultOpen
|
||||||
</Typography.Text>
|
icon={<LayoutDashboard size={14} />}
|
||||||
|
>
|
||||||
|
<section className="panel-type control-container">
|
||||||
|
<Typography.Text className="section-heading">Panel Type</Typography.Text>
|
||||||
<Select
|
<Select
|
||||||
options={[
|
onChange={setGraphHandler}
|
||||||
{ label: '0 decimals', value: PrecisionOptionsEnum.ZERO },
|
value={selectedGraph}
|
||||||
{ 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"
|
className="panel-type-select"
|
||||||
defaultValue={PrecisionOptionsEnum.TWO}
|
data-testid="panel-change-select"
|
||||||
onChange={(val: PrecisionOption): void => setDecimalPrecision(val)}
|
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>
|
</section>
|
||||||
)}
|
|
||||||
|
|
||||||
{allowSoftMinMax && (
|
{allowPanelTimePreference && (
|
||||||
<section className="soft-min-max">
|
<section className="panel-time-preference control-container">
|
||||||
<section className="container">
|
<Typography.Text className="section-heading">
|
||||||
<Typography.Text className="text">Soft Min</Typography.Text>
|
Panel Time Preference
|
||||||
<InputNumber
|
</Typography.Text>
|
||||||
type="number"
|
<TimePreference
|
||||||
value={softMin}
|
{...{
|
||||||
onChange={softMinHandler}
|
selectedTime,
|
||||||
rootClassName="input"
|
setSelectedTime,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
<section className="container">
|
)}
|
||||||
<Typography.Text className="text">Soft Max</Typography.Text>
|
|
||||||
<InputNumber
|
{allowStackingBarChart && (
|
||||||
value={softMax}
|
<section className="stack-chart control-container">
|
||||||
type="number"
|
<Typography.Text className="section-heading">
|
||||||
rootClassName="input"
|
Stack series
|
||||||
onChange={softMaxHandler}
|
</Typography.Text>
|
||||||
|
<Switch
|
||||||
|
checked={stackedBarChart}
|
||||||
|
size="small"
|
||||||
|
onChange={(checked): void => setStackedBarChart(checked)}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
)}
|
||||||
|
|
||||||
|
{allowFillSpans && (
|
||||||
|
<section className="fill-gaps toggle-card">
|
||||||
|
<div className="toggle-card-text-container">
|
||||||
|
<Typography className="section-heading">Fill gaps</Typography>
|
||||||
|
<Typography.Text className="toggle-card-description">
|
||||||
|
Fill gaps in data with 0 for continuity
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={isFillSpans}
|
||||||
|
size="small"
|
||||||
|
onChange={(checked): void => setIsFillSpans(checked)}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{isFormattingSectionVisible && (
|
||||||
|
<SettingsSection
|
||||||
|
title="Formatting & Units"
|
||||||
|
icon={<SlidersHorizontal size={14} />}
|
||||||
|
>
|
||||||
|
{allowYAxisUnit && (
|
||||||
|
<DashboardYAxisUnitSelectorWrapper
|
||||||
|
onSelect={setYAxisUnit}
|
||||||
|
value={yAxisUnit || ''}
|
||||||
|
fieldLabel={
|
||||||
|
selectedGraphType === PanelDisplay.VALUE ||
|
||||||
|
selectedGraphType === PanelDisplay.PIE
|
||||||
|
? 'Unit'
|
||||||
|
: 'Y Axis Unit'
|
||||||
|
}
|
||||||
|
// Only update the y-axis unit value automatically in create mode
|
||||||
|
shouldUpdateYAxisUnit={isNewDashboard}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{allowDecimalPrecision && (
|
||||||
|
<section className="decimal-precision-selector control-container">
|
||||||
|
<Typography.Text className="typography">
|
||||||
|
Decimal Precision
|
||||||
|
</Typography.Text>
|
||||||
|
<Select
|
||||||
|
options={decimapPrecisionOptions}
|
||||||
|
value={decimalPrecision}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
className="panel-type-select"
|
||||||
|
defaultValue={PrecisionOptionsEnum.TWO}
|
||||||
|
onChange={(val: PrecisionOption): void => setDecimalPrecision(val)}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{allowPanelColumnPreference && (
|
||||||
|
<ColumnUnitSelector
|
||||||
|
columnUnits={columnUnits}
|
||||||
|
setColumnUnits={setColumnUnits}
|
||||||
|
isNewDashboard={isNewDashboard}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SettingsSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{allowStackingBarChart && (
|
{isChartAppearanceSectionVisible && (
|
||||||
<section className="stack-chart">
|
<SettingsSection title="Chart Appearance" icon={<Paintbrush size={14} />}>
|
||||||
<Typography.Text className="label">Stack series</Typography.Text>
|
{allowFillMode && (
|
||||||
<Switch
|
<FillModeSelector value={fillMode} onChange={setFillMode} />
|
||||||
checked={stackedBarChart}
|
)}
|
||||||
size="small"
|
{allowLineStyle && (
|
||||||
onChange={(checked): void => setStackedBarChart(checked)}
|
<LineStyleSelector value={lineStyle} onChange={setLineStyle} />
|
||||||
/>
|
)}
|
||||||
</section>
|
{allowLineInterpolation && (
|
||||||
|
<LineInterpolationSelector
|
||||||
|
value={lineInterpolation}
|
||||||
|
onChange={setLineInterpolation}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{allowShowPoints && (
|
||||||
|
<section className="show-points toggle-card">
|
||||||
|
<div className="toggle-card-text-container">
|
||||||
|
<Typography.Text className="section-heading">
|
||||||
|
Show points
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text className="toggle-card-description">
|
||||||
|
Display individual data points on the chart
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Switch size="small" checked={showPoints} onChange={setShowPoints} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</SettingsSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAxisSectionVisible && (
|
||||||
|
<SettingsSection title="Axes" icon={<Axis3D size={14} />}>
|
||||||
|
{allowSoftMinMax && (
|
||||||
|
<section className="soft-min-max">
|
||||||
|
<section className="container">
|
||||||
|
<Typography.Text className="text">Soft Min</Typography.Text>
|
||||||
|
<InputNumber
|
||||||
|
type="number"
|
||||||
|
value={softMin}
|
||||||
|
onChange={softMinHandler}
|
||||||
|
rootClassName="input"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<section className="container">
|
||||||
|
<Typography.Text className="text">Soft Max</Typography.Text>
|
||||||
|
<InputNumber
|
||||||
|
value={softMax}
|
||||||
|
type="number"
|
||||||
|
rootClassName="input"
|
||||||
|
onChange={softMaxHandler}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{allowLogScale && (
|
||||||
|
<section className="log-scale control-container">
|
||||||
|
<Typography.Text className="section-heading">
|
||||||
|
Y Axis Scale
|
||||||
|
</Typography.Text>
|
||||||
|
<Select
|
||||||
|
onChange={(value): void =>
|
||||||
|
setIsLogScale(value === LogScale.LOGARITHMIC)
|
||||||
|
}
|
||||||
|
value={isLogScale ? LogScale.LOGARITHMIC : LogScale.LINEAR}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
className="panel-type-select"
|
||||||
|
defaultValue={LogScale.LINEAR}
|
||||||
|
>
|
||||||
|
<Option value={LogScale.LINEAR}>
|
||||||
|
<div className="select-option">
|
||||||
|
<div className="icon">
|
||||||
|
<LineChart size={16} />
|
||||||
|
</div>
|
||||||
|
<Typography.Text className="display">Linear</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Option>
|
||||||
|
<Option value={LogScale.LOGARITHMIC}>
|
||||||
|
<div className="select-option">
|
||||||
|
<div className="icon">
|
||||||
|
<Spline size={16} />
|
||||||
|
</div>
|
||||||
|
<Typography.Text className="display">Logarithmic</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Option>
|
||||||
|
</Select>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</SettingsSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLegendSectionVisible && (
|
||||||
|
<SettingsSection title="Legend" icon={<Layers size={14} />}>
|
||||||
|
{allowLegendPosition && (
|
||||||
|
<section className="legend-position control-container">
|
||||||
|
<Typography.Text className="section-heading">Position</Typography.Text>
|
||||||
|
<Select
|
||||||
|
onChange={(value: LegendPosition): void => setLegendPosition(value)}
|
||||||
|
value={legendPosition}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
className="panel-type-select"
|
||||||
|
defaultValue={LegendPosition.BOTTOM}
|
||||||
|
>
|
||||||
|
<Option value={LegendPosition.BOTTOM}>
|
||||||
|
<div className="select-option">
|
||||||
|
<Typography.Text className="display">Bottom</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Option>
|
||||||
|
<Option value={LegendPosition.RIGHT}>
|
||||||
|
<div className="select-option">
|
||||||
|
<Typography.Text className="display">Right</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Option>
|
||||||
|
</Select>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{allowLegendColors && (
|
||||||
|
<section className="legend-colors">
|
||||||
|
<LegendColors
|
||||||
|
customLegendColors={customLegendColors}
|
||||||
|
setCustomLegendColors={setCustomLegendColors}
|
||||||
|
queryResponse={queryResponse}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</SettingsSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{allowBucketConfig && (
|
{allowBucketConfig && (
|
||||||
<section className="bucket-config">
|
<SettingsSection title="Histogram / Buckets">
|
||||||
<Typography.Text className="label">Number of buckets</Typography.Text>
|
<section className="bucket-config control-container">
|
||||||
<InputNumber
|
<Typography.Text className="section-heading">
|
||||||
value={bucketCount || null}
|
Number of buckets
|
||||||
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>
|
</Typography.Text>
|
||||||
<Switch
|
<InputNumber
|
||||||
checked={combineHistogram}
|
value={bucketCount || null}
|
||||||
size="small"
|
type="number"
|
||||||
onChange={(checked): void => setCombineHistogram(checked)}
|
min={0}
|
||||||
|
rootClassName="bucket-input"
|
||||||
|
placeholder="Default: 30"
|
||||||
|
onChange={(val): void => {
|
||||||
|
setBucketCount(val || 0);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Typography.Text className="section-heading bucket-size-label">
|
||||||
|
Bucket width
|
||||||
|
</Typography.Text>
|
||||||
|
<InputNumber
|
||||||
|
value={bucketWidth || null}
|
||||||
|
type="number"
|
||||||
|
precision={2}
|
||||||
|
placeholder="Default: Auto"
|
||||||
|
step={0.1}
|
||||||
|
min={0.0}
|
||||||
|
rootClassName="bucket-input"
|
||||||
|
onChange={(val): void => {
|
||||||
|
setBucketWidth(val || 0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<section className="combine-hist">
|
||||||
|
<Typography.Text className="section-heading">
|
||||||
|
Merge all series into one
|
||||||
|
</Typography.Text>
|
||||||
|
<Switch
|
||||||
|
checked={combineHistogram}
|
||||||
|
size="small"
|
||||||
|
onChange={(checked): void => setCombineHistogram(checked)}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</SettingsSection>
|
||||||
)}
|
|
||||||
|
|
||||||
{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>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -541,17 +678,25 @@ function RightContainer({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{allowContextLinks && (
|
{allowContextLinks && (
|
||||||
<section className="context-links">
|
<SettingsSection
|
||||||
|
title="Context Links"
|
||||||
|
icon={<Link size={14} />}
|
||||||
|
defaultOpen={!!contextLinks.linksData.length}
|
||||||
|
>
|
||||||
<ContextLinks
|
<ContextLinks
|
||||||
contextLinks={contextLinks}
|
contextLinks={contextLinks}
|
||||||
setContextLinks={setContextLinks}
|
setContextLinks={setContextLinks}
|
||||||
selectedWidget={selectedWidget}
|
selectedWidget={selectedWidget}
|
||||||
/>
|
/>
|
||||||
</section>
|
</SettingsSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{allowThreshold && (
|
{allowThreshold && (
|
||||||
<section>
|
<SettingsSection
|
||||||
|
title="Thresholds"
|
||||||
|
icon={<Antenna size={14} />}
|
||||||
|
defaultOpen={!!thresholds.length}
|
||||||
|
>
|
||||||
<ThresholdSelector
|
<ThresholdSelector
|
||||||
thresholds={thresholds}
|
thresholds={thresholds}
|
||||||
setThresholds={setThresholds}
|
setThresholds={setThresholds}
|
||||||
@@ -559,7 +704,7 @@ function RightContainer({
|
|||||||
selectedGraph={selectedGraph}
|
selectedGraph={selectedGraph}
|
||||||
columnUnits={columnUnits}
|
columnUnits={columnUnits}
|
||||||
/>
|
/>
|
||||||
</section>
|
</SettingsSection>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -615,6 +760,14 @@ export interface RightContainerProps {
|
|||||||
setContextLinks: Dispatch<SetStateAction<ContextLinksData>>;
|
setContextLinks: Dispatch<SetStateAction<ContextLinksData>>;
|
||||||
enableDrillDown?: boolean;
|
enableDrillDown?: boolean;
|
||||||
isNewDashboard: 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 = {
|
RightContainer.defaultProps = {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const checkStackSeriesState = (
|
|||||||
expect(getByTextUtil(container, 'Stack series')).toBeInTheDocument();
|
expect(getByTextUtil(container, 'Stack series')).toBeInTheDocument();
|
||||||
|
|
||||||
const stackSeriesSection = container.querySelector(
|
const stackSeriesSection = container.querySelector(
|
||||||
'section > .stack-chart',
|
'.stack-chart',
|
||||||
) as HTMLElement;
|
) as HTMLElement;
|
||||||
expect(stackSeriesSection).toBeInTheDocument();
|
expect(stackSeriesSection).toBeInTheDocument();
|
||||||
|
|
||||||
@@ -326,7 +326,7 @@ describe('Stacking bar in new panel', () => {
|
|||||||
expect(getByText('Stack series')).toBeInTheDocument();
|
expect(getByText('Stack series')).toBeInTheDocument();
|
||||||
|
|
||||||
// Verify section exists
|
// Verify section exists
|
||||||
const section = container.querySelector('section > .stack-chart');
|
const section = container.querySelector('.stack-chart');
|
||||||
expect(section).toBeInTheDocument();
|
expect(section).toBeInTheDocument();
|
||||||
|
|
||||||
// Verify switch is present and enabled (ant-switch-checked)
|
// Verify switch is present and enabled (ant-switch-checked)
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import { UseQueryResult } from 'react-query';
|
|||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { generatePath } from 'react-router-dom';
|
import { generatePath } from 'react-router-dom';
|
||||||
import { WarningOutlined } from '@ant-design/icons';
|
import { WarningOutlined } from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
ResizableHandle,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from '@signozhq/resizable';
|
||||||
import { Button, Flex, Modal, Space, Typography } from 'antd';
|
import { Button, Flex, Modal, Space, Typography } from 'antd';
|
||||||
import logEvent from 'api/common/logEvent';
|
import logEvent from 'api/common/logEvent';
|
||||||
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
|
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
|
||||||
@@ -30,6 +35,11 @@ import useUrlQuery from 'hooks/useUrlQuery';
|
|||||||
import createQueryParams from 'lib/createQueryParams';
|
import createQueryParams from 'lib/createQueryParams';
|
||||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||||
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
|
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
|
||||||
|
import {
|
||||||
|
FillMode,
|
||||||
|
LineInterpolation,
|
||||||
|
LineStyle,
|
||||||
|
} from 'lib/uPlotV2/config/types';
|
||||||
import { cloneDeep, defaultTo, isEmpty, isUndefined } from 'lodash-es';
|
import { cloneDeep, defaultTo, isEmpty, isUndefined } from 'lodash-es';
|
||||||
import { Check, X } from 'lucide-react';
|
import { Check, X } from 'lucide-react';
|
||||||
import { useScrollToWidgetIdStore } from 'providers/Dashboard/helpers/scrollToWidgetIdHelper';
|
import { useScrollToWidgetIdStore } from 'providers/Dashboard/helpers/scrollToWidgetIdHelper';
|
||||||
@@ -204,6 +214,18 @@ function NewWidget({
|
|||||||
const [legendPosition, setLegendPosition] = useState<LegendPosition>(
|
const [legendPosition, setLegendPosition] = useState<LegendPosition>(
|
||||||
selectedWidget?.legendPosition || LegendPosition.BOTTOM,
|
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<
|
const [customLegendColors, setCustomLegendColors] = useState<
|
||||||
Record<string, string>
|
Record<string, string>
|
||||||
>(selectedWidget?.customLegendColors || {});
|
>(selectedWidget?.customLegendColors || {});
|
||||||
@@ -269,6 +291,10 @@ function NewWidget({
|
|||||||
softMin,
|
softMin,
|
||||||
softMax,
|
softMax,
|
||||||
fillSpans: isFillSpans,
|
fillSpans: isFillSpans,
|
||||||
|
lineInterpolation,
|
||||||
|
fillMode,
|
||||||
|
lineStyle,
|
||||||
|
showPoints,
|
||||||
columnUnits,
|
columnUnits,
|
||||||
bucketCount,
|
bucketCount,
|
||||||
stackedBarChart,
|
stackedBarChart,
|
||||||
@@ -304,6 +330,10 @@ function NewWidget({
|
|||||||
stackedBarChart,
|
stackedBarChart,
|
||||||
isLogScale,
|
isLogScale,
|
||||||
legendPosition,
|
legendPosition,
|
||||||
|
lineInterpolation,
|
||||||
|
fillMode,
|
||||||
|
lineStyle,
|
||||||
|
showPoints,
|
||||||
customLegendColors,
|
customLegendColors,
|
||||||
contextLinks,
|
contextLinks,
|
||||||
selectedWidget.columnWidths,
|
selectedWidget.columnWidths,
|
||||||
@@ -439,6 +469,19 @@ function NewWidget({
|
|||||||
globalSelectedInterval,
|
globalSelectedInterval,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const navigateToDashboardPage = useCallback(() => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
const urlVariablesQueryString = query.get(QueryParams.variables);
|
||||||
|
if (urlVariablesQueryString) {
|
||||||
|
params.set(QueryParams.variables, urlVariablesQueryString);
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = params.toString() ? `?${params.toString()}` : '';
|
||||||
|
|
||||||
|
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId }) + search);
|
||||||
|
}, [dashboardId, query, safeNavigate]);
|
||||||
|
|
||||||
const onClickSaveHandler = useCallback(() => {
|
const onClickSaveHandler = useCallback(() => {
|
||||||
if (!selectedDashboard) {
|
if (!selectedDashboard) {
|
||||||
return;
|
return;
|
||||||
@@ -554,9 +597,7 @@ function NewWidget({
|
|||||||
updateDashboardMutation.mutateAsync(dashboard, {
|
updateDashboardMutation.mutateAsync(dashboard, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setToScrollWidgetId(selectedWidget?.id || '');
|
setToScrollWidgetId(selectedWidget?.id || '');
|
||||||
safeNavigate({
|
navigateToDashboardPage();
|
||||||
pathname: generatePath(ROUTES.DASHBOARD, { dashboardId }),
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
@@ -572,7 +613,7 @@ function NewWidget({
|
|||||||
updateDashboardMutation,
|
updateDashboardMutation,
|
||||||
widgets,
|
widgets,
|
||||||
setToScrollWidgetId,
|
setToScrollWidgetId,
|
||||||
safeNavigate,
|
navigateToDashboardPage,
|
||||||
dashboardId,
|
dashboardId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -581,12 +622,12 @@ function NewWidget({
|
|||||||
setDiscardModal(true);
|
setDiscardModal(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId }));
|
navigateToDashboardPage();
|
||||||
}, [dashboardId, isQueryModified, safeNavigate]);
|
}, [isQueryModified, navigateToDashboardPage]);
|
||||||
|
|
||||||
const discardChanges = useCallback(() => {
|
const discardChanges = useCallback(() => {
|
||||||
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId }));
|
navigateToDashboardPage();
|
||||||
}, [dashboardId, safeNavigate]);
|
}, [navigateToDashboardPage]);
|
||||||
|
|
||||||
const setGraphHandler = (type: PANEL_TYPES): void => {
|
const setGraphHandler = (type: PANEL_TYPES): void => {
|
||||||
setIsLoadingPanelData(true);
|
setIsLoadingPanelData(true);
|
||||||
@@ -728,12 +769,14 @@ function NewWidget({
|
|||||||
}
|
}
|
||||||
const widgetId = query.get('widgetId') || '';
|
const widgetId = query.get('widgetId') || '';
|
||||||
const graphType = query.get('graphType') || '';
|
const graphType = query.get('graphType') || '';
|
||||||
|
const variables = query.get(QueryParams.variables) || '';
|
||||||
const queryParams = {
|
const queryParams = {
|
||||||
[QueryParams.expandedWidgetId]: widgetId,
|
[QueryParams.expandedWidgetId]: widgetId,
|
||||||
[QueryParams.graphType]: graphType,
|
[QueryParams.graphType]: graphType,
|
||||||
[QueryParams.compositeQuery]: encodeURIComponent(
|
[QueryParams.compositeQuery]: encodeURIComponent(
|
||||||
JSON.stringify(currentQuery),
|
JSON.stringify(currentQuery),
|
||||||
),
|
),
|
||||||
|
[QueryParams.variables]: variables,
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatedSearch = createQueryParams(queryParams);
|
const updatedSearch = createQueryParams(queryParams);
|
||||||
@@ -744,7 +787,7 @@ function NewWidget({
|
|||||||
}, [query, safeNavigate, dashboardId, currentQuery]);
|
}, [query, safeNavigate, dashboardId, currentQuery]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container className="new-widget-container">
|
||||||
<div className="edit-header">
|
<div className="edit-header">
|
||||||
<div className="left-header">
|
<div className="left-header">
|
||||||
<X
|
<X
|
||||||
@@ -798,81 +841,104 @@ function NewWidget({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PanelContainer>
|
<PanelContainer>
|
||||||
<LeftContainerWrapper isDarkMode={useIsDarkMode()}>
|
<ResizablePanelGroup direction="horizontal" autoSaveId="panel-editor">
|
||||||
<OverlayScrollbar>
|
<ResizablePanel
|
||||||
{selectedWidget && (
|
minSize={70}
|
||||||
<LeftContainer
|
maxSize={80}
|
||||||
selectedGraph={graphType}
|
defaultSize={80}
|
||||||
selectedLogFields={selectedLogFields}
|
className="resizable-panel-left-container"
|
||||||
setSelectedLogFields={setSelectedLogFields}
|
>
|
||||||
selectedTracesFields={selectedTracesFields}
|
<OverlayScrollbar>
|
||||||
setSelectedTracesFields={setSelectedTracesFields}
|
<LeftContainerWrapper isDarkMode={useIsDarkMode()}>
|
||||||
selectedWidget={selectedWidget}
|
{selectedWidget && (
|
||||||
selectedTime={selectedTime}
|
<LeftContainer
|
||||||
requestData={requestData}
|
selectedDashboard={selectedDashboard}
|
||||||
setRequestData={setRequestData}
|
selectedGraph={graphType}
|
||||||
isLoadingPanelData={isLoadingPanelData}
|
selectedLogFields={selectedLogFields}
|
||||||
setQueryResponse={setQueryResponse}
|
setSelectedLogFields={setSelectedLogFields}
|
||||||
enableDrillDown={enableDrillDown}
|
selectedTracesFields={selectedTracesFields}
|
||||||
selectedDashboard={selectedDashboard}
|
setSelectedTracesFields={setSelectedTracesFields}
|
||||||
isNewPanel={isNewPanel}
|
selectedWidget={selectedWidget}
|
||||||
/>
|
selectedTime={selectedTime}
|
||||||
)}
|
requestData={requestData}
|
||||||
</OverlayScrollbar>
|
setRequestData={setRequestData}
|
||||||
</LeftContainerWrapper>
|
isLoadingPanelData={isLoadingPanelData}
|
||||||
|
setQueryResponse={setQueryResponse}
|
||||||
<RightContainerWrapper>
|
enableDrillDown={enableDrillDown}
|
||||||
<OverlayScrollbar>
|
/>
|
||||||
<RightContainer
|
)}
|
||||||
setGraphHandler={setGraphHandler}
|
</LeftContainerWrapper>
|
||||||
title={title}
|
</OverlayScrollbar>
|
||||||
setTitle={setTitle}
|
</ResizablePanel>
|
||||||
description={description}
|
<ResizableHandle withHandle className="widget-resizable-handle" />
|
||||||
setDescription={setDescription}
|
<ResizablePanel
|
||||||
stackedBarChart={stackedBarChart}
|
minSize={20}
|
||||||
setStackedBarChart={setStackedBarChart}
|
maxSize={30}
|
||||||
opacity={opacity}
|
defaultSize={20}
|
||||||
yAxisUnit={yAxisUnit}
|
className="resizable-panel-right-container"
|
||||||
columnUnits={columnUnits}
|
>
|
||||||
setColumnUnits={setColumnUnits}
|
<OverlayScrollbar>
|
||||||
bucketCount={bucketCount}
|
<RightContainerWrapper>
|
||||||
bucketWidth={bucketWidth}
|
<RightContainer
|
||||||
combineHistogram={combineHistogram}
|
setGraphHandler={setGraphHandler}
|
||||||
setCombineHistogram={setCombineHistogram}
|
title={title}
|
||||||
setBucketWidth={setBucketWidth}
|
setTitle={setTitle}
|
||||||
setBucketCount={setBucketCount}
|
description={description}
|
||||||
setOpacity={setOpacity}
|
setDescription={setDescription}
|
||||||
selectedNullZeroValue={selectedNullZeroValue}
|
stackedBarChart={stackedBarChart}
|
||||||
setSelectedNullZeroValue={setSelectedNullZeroValue}
|
setStackedBarChart={setStackedBarChart}
|
||||||
selectedGraph={graphType}
|
lineInterpolation={lineInterpolation}
|
||||||
setSelectedTime={setSelectedTime}
|
setLineInterpolation={setLineInterpolation}
|
||||||
selectedTime={selectedTime}
|
fillMode={fillMode}
|
||||||
setYAxisUnit={setYAxisUnit}
|
setFillMode={setFillMode}
|
||||||
decimalPrecision={decimalPrecision}
|
lineStyle={lineStyle}
|
||||||
setDecimalPrecision={setDecimalPrecision}
|
setLineStyle={setLineStyle}
|
||||||
thresholds={thresholds}
|
showPoints={showPoints}
|
||||||
setThresholds={setThresholds}
|
setShowPoints={setShowPoints}
|
||||||
selectedWidget={selectedWidget}
|
opacity={opacity}
|
||||||
isFillSpans={isFillSpans}
|
yAxisUnit={yAxisUnit}
|
||||||
setIsFillSpans={setIsFillSpans}
|
columnUnits={columnUnits}
|
||||||
isLogScale={isLogScale}
|
setColumnUnits={setColumnUnits}
|
||||||
setIsLogScale={setIsLogScale}
|
bucketCount={bucketCount}
|
||||||
legendPosition={legendPosition}
|
bucketWidth={bucketWidth}
|
||||||
setLegendPosition={setLegendPosition}
|
combineHistogram={combineHistogram}
|
||||||
customLegendColors={customLegendColors}
|
setCombineHistogram={setCombineHistogram}
|
||||||
setCustomLegendColors={setCustomLegendColors}
|
setBucketWidth={setBucketWidth}
|
||||||
queryResponse={queryResponse}
|
setBucketCount={setBucketCount}
|
||||||
softMin={softMin}
|
setOpacity={setOpacity}
|
||||||
setSoftMin={setSoftMin}
|
selectedNullZeroValue={selectedNullZeroValue}
|
||||||
softMax={softMax}
|
setSelectedNullZeroValue={setSelectedNullZeroValue}
|
||||||
setSoftMax={setSoftMax}
|
selectedGraph={graphType}
|
||||||
contextLinks={contextLinks}
|
setSelectedTime={setSelectedTime}
|
||||||
setContextLinks={setContextLinks}
|
selectedTime={selectedTime}
|
||||||
enableDrillDown={enableDrillDown}
|
setYAxisUnit={setYAxisUnit}
|
||||||
isNewDashboard={isNewDashboard}
|
decimalPrecision={decimalPrecision}
|
||||||
/>
|
setDecimalPrecision={setDecimalPrecision}
|
||||||
</OverlayScrollbar>
|
thresholds={thresholds}
|
||||||
</RightContainerWrapper>
|
setThresholds={setThresholds}
|
||||||
|
selectedWidget={selectedWidget}
|
||||||
|
isFillSpans={isFillSpans}
|
||||||
|
setIsFillSpans={setIsFillSpans}
|
||||||
|
isLogScale={isLogScale}
|
||||||
|
setIsLogScale={setIsLogScale}
|
||||||
|
legendPosition={legendPosition}
|
||||||
|
setLegendPosition={setLegendPosition}
|
||||||
|
customLegendColors={customLegendColors}
|
||||||
|
setCustomLegendColors={setCustomLegendColors}
|
||||||
|
queryResponse={queryResponse}
|
||||||
|
softMin={softMin}
|
||||||
|
setSoftMin={setSoftMin}
|
||||||
|
softMax={softMax}
|
||||||
|
setSoftMax={setSoftMax}
|
||||||
|
contextLinks={contextLinks}
|
||||||
|
setContextLinks={setContextLinks}
|
||||||
|
enableDrillDown={enableDrillDown}
|
||||||
|
isNewDashboard={isNewDashboard}
|
||||||
|
/>
|
||||||
|
</RightContainerWrapper>
|
||||||
|
</OverlayScrollbar>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
</PanelContainer>
|
</PanelContainer>
|
||||||
<Modal
|
<Modal
|
||||||
title={
|
title={
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ export const Container = styled.div`
|
|||||||
|
|
||||||
export const RightContainerWrapper = styled(Col)`
|
export const RightContainerWrapper = styled(Col)`
|
||||||
&&& {
|
&&& {
|
||||||
max-width: 400px;
|
width: 100%;
|
||||||
width: 30%;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import { AppState } from 'store/reducers';
|
|||||||
import AppActions from 'types/actions';
|
import AppActions from 'types/actions';
|
||||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||||
import { addCustomTimeRange } from 'utils/customTimeRangeUtils';
|
import { addCustomTimeRange } from 'utils/customTimeRangeUtils';
|
||||||
import { persistTimeDurationForRoute } from 'utils/metricsTimeStorageUtils';
|
|
||||||
import { normalizeTimeToMs } from 'utils/timeUtils';
|
import { normalizeTimeToMs } from 'utils/timeUtils';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
@@ -235,7 +234,20 @@ function DateTimeSelection({
|
|||||||
|
|
||||||
const updateLocalStorageForRoutes = useCallback(
|
const updateLocalStorageForRoutes = useCallback(
|
||||||
(value: Time | string): void => {
|
(value: Time | string): void => {
|
||||||
persistTimeDurationForRoute(location.pathname, String(value));
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[location.pathname],
|
[location.pathname],
|
||||||
);
|
);
|
||||||
@@ -726,7 +738,6 @@ function DateTimeSelection({
|
|||||||
showRecentlyUsed={showRecentlyUsed}
|
showRecentlyUsed={showRecentlyUsed}
|
||||||
minTime={minTimeForDateTimePicker}
|
minTime={minTimeForDateTimePicker}
|
||||||
maxTime={maxTimeForDateTimePicker}
|
maxTime={maxTimeForDateTimePicker}
|
||||||
isModalTimeSelection={isModalTimeSelection}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showAutoRefresh && selectedTime !== 'custom' && (
|
{showAutoRefresh && selectedTime !== 'custom' && (
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import { renderHook } from '@testing-library/react';
|
||||||
|
import { useDashboardVariablesFromLocalStorage } from 'hooks/dashboard/useDashboardFromLocalStorage';
|
||||||
|
import { useTransformDashboardVariables } from 'hooks/dashboard/useTransformDashboardVariables';
|
||||||
|
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
|
||||||
|
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||||
|
|
||||||
|
jest.mock('hooks/dashboard/useDashboardFromLocalStorage');
|
||||||
|
jest.mock('hooks/dashboard/useVariablesFromUrl');
|
||||||
|
|
||||||
|
const mockUseDashboardVariablesFromLocalStorage = useDashboardVariablesFromLocalStorage as jest.MockedFunction<
|
||||||
|
typeof useDashboardVariablesFromLocalStorage
|
||||||
|
>;
|
||||||
|
const mockUseVariablesFromUrl = useVariablesFromUrl as jest.MockedFunction<
|
||||||
|
typeof useVariablesFromUrl
|
||||||
|
>;
|
||||||
|
|
||||||
|
const makeVariable = (
|
||||||
|
overrides: Partial<IDashboardVariable> = {},
|
||||||
|
): IDashboardVariable => ({
|
||||||
|
id: 'existing-id',
|
||||||
|
name: 'env',
|
||||||
|
description: '',
|
||||||
|
type: 'QUERY',
|
||||||
|
sort: 'DISABLED',
|
||||||
|
multiSelect: false,
|
||||||
|
showALLOption: false,
|
||||||
|
selectedValue: 'prod',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeDashboard = (
|
||||||
|
variables: Record<string, IDashboardVariable>,
|
||||||
|
): Dashboard => ({
|
||||||
|
id: 'dash-1',
|
||||||
|
createdAt: '',
|
||||||
|
updatedAt: '',
|
||||||
|
createdBy: '',
|
||||||
|
updatedBy: '',
|
||||||
|
data: {
|
||||||
|
title: 'Test',
|
||||||
|
variables,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const setupHook = (
|
||||||
|
currentDashboard: Record<string, any> = {},
|
||||||
|
urlVariables: Record<string, any> = {},
|
||||||
|
): ReturnType<typeof useTransformDashboardVariables> => {
|
||||||
|
mockUseDashboardVariablesFromLocalStorage.mockReturnValue({
|
||||||
|
currentDashboard,
|
||||||
|
updateLocalStorageDashboardVariables: jest.fn(),
|
||||||
|
});
|
||||||
|
mockUseVariablesFromUrl.mockReturnValue({
|
||||||
|
getUrlVariables: () => urlVariables,
|
||||||
|
setUrlVariables: jest.fn(),
|
||||||
|
updateUrlVariable: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useTransformDashboardVariables('dash-1'));
|
||||||
|
return result.current;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('useTransformDashboardVariables', () => {
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
describe('order assignment', () => {
|
||||||
|
it('assigns order starting from 0 to variables that have none', () => {
|
||||||
|
const { transformDashboardVariables } = setupHook();
|
||||||
|
const dashboard = makeDashboard({
|
||||||
|
v1: makeVariable({ id: 'id1', name: 'v1', order: undefined }),
|
||||||
|
v2: makeVariable({ id: 'id2', name: 'v2', order: undefined }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = transformDashboardVariables(dashboard);
|
||||||
|
|
||||||
|
const orders = Object.values(result.data.variables).map((v) => v.order);
|
||||||
|
expect(orders).toContain(0);
|
||||||
|
expect(orders).toContain(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves existing order values', () => {
|
||||||
|
const { transformDashboardVariables } = setupHook();
|
||||||
|
const dashboard = makeDashboard({
|
||||||
|
v1: makeVariable({ id: 'id1', name: 'v1', order: 5 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = transformDashboardVariables(dashboard);
|
||||||
|
|
||||||
|
expect(result.data.variables.v1.order).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns unique orders across multiple variables that all lack an order', () => {
|
||||||
|
const { transformDashboardVariables } = setupHook();
|
||||||
|
const dashboard = makeDashboard({
|
||||||
|
v1: makeVariable({ id: 'id1', name: 'v1', order: undefined }),
|
||||||
|
v2: makeVariable({ id: 'id2', name: 'v2', order: undefined }),
|
||||||
|
v3: makeVariable({ id: 'id3', name: 'v3', order: undefined }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = transformDashboardVariables(dashboard);
|
||||||
|
|
||||||
|
const orders = Object.values(result.data.variables).map((v) => v.order);
|
||||||
|
// All three newly assigned orders must be distinct
|
||||||
|
expect(new Set(orders).size).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ID assignment', () => {
|
||||||
|
it('assigns a UUID to variables that have no id', () => {
|
||||||
|
const { transformDashboardVariables } = setupHook();
|
||||||
|
const variable = makeVariable({ name: 'v1' });
|
||||||
|
(variable as any).id = undefined;
|
||||||
|
const dashboard = makeDashboard({ v1: variable });
|
||||||
|
|
||||||
|
const result = transformDashboardVariables(dashboard);
|
||||||
|
|
||||||
|
expect(result.data.variables.v1.id).toMatch(
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves existing IDs', () => {
|
||||||
|
const { transformDashboardVariables } = setupHook();
|
||||||
|
const dashboard = makeDashboard({
|
||||||
|
v1: makeVariable({ id: 'keep-me', name: 'v1' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = transformDashboardVariables(dashboard);
|
||||||
|
|
||||||
|
expect(result.data.variables.v1.id).toBe('keep-me');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TEXTBOX backward compatibility', () => {
|
||||||
|
it('copies textboxValue to defaultValue when defaultValue is missing', () => {
|
||||||
|
const { transformDashboardVariables } = setupHook();
|
||||||
|
const dashboard = makeDashboard({
|
||||||
|
v1: makeVariable({
|
||||||
|
id: 'id1',
|
||||||
|
name: 'v1',
|
||||||
|
type: 'TEXTBOX',
|
||||||
|
textboxValue: 'hello',
|
||||||
|
defaultValue: undefined,
|
||||||
|
order: undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = transformDashboardVariables(dashboard);
|
||||||
|
|
||||||
|
expect(result.data.variables.v1.defaultValue).toBe('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not overwrite an existing defaultValue', () => {
|
||||||
|
const { transformDashboardVariables } = setupHook();
|
||||||
|
const dashboard = makeDashboard({
|
||||||
|
v1: makeVariable({
|
||||||
|
id: 'id1',
|
||||||
|
name: 'v1',
|
||||||
|
type: 'TEXTBOX',
|
||||||
|
textboxValue: 'old',
|
||||||
|
defaultValue: 'keep',
|
||||||
|
order: undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = transformDashboardVariables(dashboard);
|
||||||
|
|
||||||
|
expect(result.data.variables.v1.defaultValue).toBe('keep');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('localStorage merge', () => {
|
||||||
|
it('applies localStorage selectedValue over DB value', () => {
|
||||||
|
const { transformDashboardVariables } = setupHook({
|
||||||
|
env: { selectedValue: 'staging', allSelected: false },
|
||||||
|
});
|
||||||
|
const dashboard = makeDashboard({
|
||||||
|
v1: makeVariable({ id: 'id1', name: 'env', selectedValue: 'prod' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = transformDashboardVariables(dashboard);
|
||||||
|
|
||||||
|
expect(result.data.variables.v1.selectedValue).toBe('staging');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies localStorage allSelected over DB value', () => {
|
||||||
|
const { transformDashboardVariables } = setupHook({
|
||||||
|
env: { selectedValue: undefined, allSelected: true },
|
||||||
|
});
|
||||||
|
const dashboard = makeDashboard({
|
||||||
|
v1: makeVariable({
|
||||||
|
id: 'id1',
|
||||||
|
name: 'env',
|
||||||
|
allSelected: false,
|
||||||
|
showALLOption: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = transformDashboardVariables(dashboard);
|
||||||
|
|
||||||
|
expect(result.data.variables.v1.allSelected).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('URL variable override', () => {
|
||||||
|
it('sets allSelected=true when URL value is __ALL__', () => {
|
||||||
|
const { transformDashboardVariables } = setupHook(
|
||||||
|
{ env: { selectedValue: 'prod', allSelected: false } },
|
||||||
|
{ env: '__ALL__' },
|
||||||
|
);
|
||||||
|
const dashboard = makeDashboard({
|
||||||
|
v1: makeVariable({
|
||||||
|
id: 'id1',
|
||||||
|
name: 'env',
|
||||||
|
showALLOption: true,
|
||||||
|
allSelected: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = transformDashboardVariables(dashboard);
|
||||||
|
|
||||||
|
expect(result.data.variables.v1.allSelected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets selectedValue from URL and clears allSelected when showALLOption is true', () => {
|
||||||
|
const { transformDashboardVariables } = setupHook(
|
||||||
|
{ env: { selectedValue: undefined, allSelected: true } },
|
||||||
|
{ env: 'dev' },
|
||||||
|
);
|
||||||
|
const dashboard = makeDashboard({
|
||||||
|
v1: makeVariable({
|
||||||
|
id: 'id1',
|
||||||
|
name: 'env',
|
||||||
|
showALLOption: true,
|
||||||
|
allSelected: true,
|
||||||
|
multiSelect: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = transformDashboardVariables(dashboard);
|
||||||
|
|
||||||
|
expect(result.data.variables.v1.selectedValue).toBe('dev');
|
||||||
|
expect(result.data.variables.v1.allSelected).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not set allSelected=false when showALLOption is false', () => {
|
||||||
|
const { transformDashboardVariables } = setupHook(
|
||||||
|
{ env: { selectedValue: undefined, allSelected: true } },
|
||||||
|
{ env: 'dev' },
|
||||||
|
);
|
||||||
|
const dashboard = makeDashboard({
|
||||||
|
v1: makeVariable({
|
||||||
|
id: 'id1',
|
||||||
|
name: 'env',
|
||||||
|
showALLOption: false,
|
||||||
|
allSelected: true,
|
||||||
|
multiSelect: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = transformDashboardVariables(dashboard);
|
||||||
|
|
||||||
|
expect(result.data.variables.v1.selectedValue).toBe('dev');
|
||||||
|
expect(result.data.variables.v1.allSelected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes array URL value to single value for single-select variable', () => {
|
||||||
|
const { transformDashboardVariables } = setupHook(
|
||||||
|
{},
|
||||||
|
{ env: ['prod', 'dev'] },
|
||||||
|
);
|
||||||
|
const dashboard = makeDashboard({
|
||||||
|
v1: makeVariable({
|
||||||
|
id: 'id1',
|
||||||
|
name: 'env',
|
||||||
|
multiSelect: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = transformDashboardVariables(dashboard);
|
||||||
|
|
||||||
|
expect(result.data.variables.v1.selectedValue).toBe('prod');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps single URL value in array for multi-select variable', () => {
|
||||||
|
const { transformDashboardVariables } = setupHook({}, { env: 'prod' });
|
||||||
|
const dashboard = makeDashboard({
|
||||||
|
v1: makeVariable({
|
||||||
|
id: 'id1',
|
||||||
|
name: 'env',
|
||||||
|
multiSelect: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = transformDashboardVariables(dashboard);
|
||||||
|
|
||||||
|
expect(result.data.variables.v1.selectedValue).toEqual(['prod']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('looks up URL variable by variable id when name is absent', () => {
|
||||||
|
const { transformDashboardVariables } = setupHook(
|
||||||
|
{},
|
||||||
|
{ 'var-uuid': 'fallback' },
|
||||||
|
);
|
||||||
|
const variable = makeVariable({ id: 'var-uuid', multiSelect: false });
|
||||||
|
delete variable.name;
|
||||||
|
const dashboard = makeDashboard({ v1: variable });
|
||||||
|
|
||||||
|
const result = transformDashboardVariables(dashboard);
|
||||||
|
|
||||||
|
expect(result.data.variables.v1.selectedValue).toBe('fallback');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('returns data unchanged when there are no variables', () => {
|
||||||
|
const { transformDashboardVariables } = setupHook();
|
||||||
|
const dashboard = makeDashboard({});
|
||||||
|
|
||||||
|
const result = transformDashboardVariables(dashboard);
|
||||||
|
|
||||||
|
expect(result.data.variables).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mutate the original dashboard', () => {
|
||||||
|
const { transformDashboardVariables } = setupHook({
|
||||||
|
env: { selectedValue: 'staging', allSelected: false },
|
||||||
|
});
|
||||||
|
const dashboard = makeDashboard({
|
||||||
|
v1: makeVariable({ id: 'id1', name: 'env', selectedValue: 'prod' }),
|
||||||
|
});
|
||||||
|
const originalValue = dashboard.data.variables.v1.selectedValue;
|
||||||
|
|
||||||
|
transformDashboardVariables(dashboard);
|
||||||
|
|
||||||
|
expect(dashboard.data.variables.v1.selectedValue).toBe(originalValue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,7 +15,7 @@ interface DashboardLocalStorageVariables {
|
|||||||
[id: string]: LocalStoreDashboardVariables;
|
[id: string]: LocalStoreDashboardVariables;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseDashboardVariablesFromLocalStorageReturn {
|
export interface UseDashboardVariablesFromLocalStorageReturn {
|
||||||
currentDashboard: LocalStoreDashboardVariables;
|
currentDashboard: LocalStoreDashboardVariables;
|
||||||
updateLocalStorageDashboardVariables: (
|
updateLocalStorageDashboardVariables: (
|
||||||
id: string,
|
id: string,
|
||||||
|
|||||||
128
frontend/src/hooks/dashboard/useTransformDashboardVariables.ts
Normal file
128
frontend/src/hooks/dashboard/useTransformDashboardVariables.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { ALL_SELECTED_VALUE } from 'components/NewSelect/utils';
|
||||||
|
import {
|
||||||
|
useDashboardVariablesFromLocalStorage,
|
||||||
|
UseDashboardVariablesFromLocalStorageReturn,
|
||||||
|
} from 'hooks/dashboard/useDashboardFromLocalStorage';
|
||||||
|
import useVariablesFromUrl, {
|
||||||
|
UseVariablesFromUrlReturn,
|
||||||
|
} from 'hooks/dashboard/useVariablesFromUrl';
|
||||||
|
import { isEmpty } from 'lodash-es';
|
||||||
|
import { normalizeUrlValueForVariable } from 'providers/Dashboard/normalizeUrlValue';
|
||||||
|
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||||
|
import { v4 as generateUUID } from 'uuid';
|
||||||
|
|
||||||
|
export function useTransformDashboardVariables(
|
||||||
|
dashboardId: string,
|
||||||
|
): Pick<UseVariablesFromUrlReturn, 'getUrlVariables' | 'updateUrlVariable'> &
|
||||||
|
UseDashboardVariablesFromLocalStorageReturn & {
|
||||||
|
transformDashboardVariables: (data: Dashboard) => Dashboard;
|
||||||
|
} {
|
||||||
|
const {
|
||||||
|
currentDashboard,
|
||||||
|
updateLocalStorageDashboardVariables,
|
||||||
|
} = useDashboardVariablesFromLocalStorage(dashboardId);
|
||||||
|
const { getUrlVariables, updateUrlVariable } = useVariablesFromUrl();
|
||||||
|
|
||||||
|
const mergeDBWithLocalStorage = (
|
||||||
|
data: Dashboard,
|
||||||
|
localStorageVariables: any,
|
||||||
|
): Dashboard => {
|
||||||
|
const updatedData = data;
|
||||||
|
if (data && localStorageVariables) {
|
||||||
|
const updatedVariables = data.data.variables;
|
||||||
|
const variablesFromUrl = getUrlVariables();
|
||||||
|
Object.keys(data.data.variables).forEach((variable) => {
|
||||||
|
const variableData = data.data.variables[variable];
|
||||||
|
|
||||||
|
// values from url
|
||||||
|
const urlVariable = variableData?.name
|
||||||
|
? variablesFromUrl[variableData?.name] || variablesFromUrl[variableData.id]
|
||||||
|
: variablesFromUrl[variableData.id];
|
||||||
|
|
||||||
|
let updatedVariable = {
|
||||||
|
...data.data.variables[variable],
|
||||||
|
...localStorageVariables[variableData.name as any],
|
||||||
|
};
|
||||||
|
|
||||||
|
// respect the url variable if it is set, override the others
|
||||||
|
if (!isEmpty(urlVariable)) {
|
||||||
|
if (urlVariable === ALL_SELECTED_VALUE) {
|
||||||
|
updatedVariable = {
|
||||||
|
...updatedVariable,
|
||||||
|
allSelected: true,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Normalize URL value to match variable's multiSelect configuration
|
||||||
|
const normalizedValue = normalizeUrlValueForVariable(
|
||||||
|
urlVariable,
|
||||||
|
variableData,
|
||||||
|
);
|
||||||
|
|
||||||
|
updatedVariable = {
|
||||||
|
...updatedVariable,
|
||||||
|
selectedValue: normalizedValue,
|
||||||
|
// Only set allSelected to false if showALLOption is available
|
||||||
|
...(updatedVariable?.showALLOption && { allSelected: false }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedVariables[variable] = updatedVariable;
|
||||||
|
});
|
||||||
|
updatedData.data.variables = updatedVariables;
|
||||||
|
}
|
||||||
|
return updatedData;
|
||||||
|
};
|
||||||
|
|
||||||
|
// As we do not have order and ID's in the variables object, we have to process variables to add order and ID if they do not exist in the variables object
|
||||||
|
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||||
|
const transformDashboardVariables = (data: Dashboard): Dashboard => {
|
||||||
|
if (data && data.data && data.data.variables) {
|
||||||
|
const clonedDashboardData = mergeDBWithLocalStorage(
|
||||||
|
JSON.parse(JSON.stringify(data)),
|
||||||
|
currentDashboard,
|
||||||
|
);
|
||||||
|
const { variables } = clonedDashboardData.data;
|
||||||
|
const existingOrders: Set<number> = new Set();
|
||||||
|
|
||||||
|
for (const key in variables) {
|
||||||
|
// eslint-disable-next-line no-prototype-builtins
|
||||||
|
if (variables.hasOwnProperty(key)) {
|
||||||
|
const variable: IDashboardVariable = variables[key];
|
||||||
|
|
||||||
|
// Check if 'order' property doesn't exist or is undefined
|
||||||
|
if (variable.order === undefined) {
|
||||||
|
// Find a unique order starting from 0
|
||||||
|
let order = 0;
|
||||||
|
while (existingOrders.has(order)) {
|
||||||
|
order += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
variable.order = order;
|
||||||
|
existingOrders.add(order);
|
||||||
|
// ! BWC - Specific case for backward compatibility where textboxValue was used instead of defaultValue
|
||||||
|
if (variable.type === 'TEXTBOX' && !variable.defaultValue) {
|
||||||
|
variable.defaultValue = variable.textboxValue || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variable.id === undefined) {
|
||||||
|
variable.id = generateUUID();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return clonedDashboardData;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
transformDashboardVariables,
|
||||||
|
getUrlVariables,
|
||||||
|
updateUrlVariable,
|
||||||
|
currentDashboard,
|
||||||
|
updateLocalStorageDashboardVariables,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ export interface LocalStoreDashboardVariables {
|
|||||||
| IDashboardVariable['selectedValue'];
|
| IDashboardVariable['selectedValue'];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseVariablesFromUrlReturn {
|
export interface UseVariablesFromUrlReturn {
|
||||||
getUrlVariables: () => LocalStoreDashboardVariables;
|
getUrlVariables: () => LocalStoreDashboardVariables;
|
||||||
setUrlVariables: (variables: LocalStoreDashboardVariables) => void;
|
setUrlVariables: (variables: LocalStoreDashboardVariables) => void;
|
||||||
updateUrlVariable: (
|
updateUrlVariable: (
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
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,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
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 { calculateWidthBasedOnStepInterval } from 'lib/uPlotV2/utils';
|
||||||
import uPlot, { Series } from 'uplot';
|
import uPlot, { Series } from 'uplot';
|
||||||
|
|
||||||
|
import { generateGradientFill } from '../utils/generateGradientFill';
|
||||||
import {
|
import {
|
||||||
BarAlignment,
|
BarAlignment,
|
||||||
ConfigBuilder,
|
ConfigBuilder,
|
||||||
DrawStyle,
|
DrawStyle,
|
||||||
|
FillMode,
|
||||||
LineInterpolation,
|
LineInterpolation,
|
||||||
LineStyle,
|
LineStyle,
|
||||||
SeriesProps,
|
SeriesProps,
|
||||||
VisibilityMode,
|
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,7 +53,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
|
|||||||
}: {
|
}: {
|
||||||
resolvedLineColor: string;
|
resolvedLineColor: string;
|
||||||
}): Partial<Series> {
|
}): Partial<Series> {
|
||||||
const { lineWidth, lineStyle, lineCap, fillColor } = this.props;
|
const { lineWidth, lineStyle, lineCap, fillColor, fillMode } = this.props;
|
||||||
const lineConfig: Partial<Series> = {
|
const lineConfig: Partial<Series> = {
|
||||||
stroke: resolvedLineColor,
|
stroke: resolvedLineColor,
|
||||||
width: lineWidth ?? DEFAULT_LINE_WIDTH,
|
width: lineWidth ?? DEFAULT_LINE_WIDTH,
|
||||||
@@ -66,12 +67,19 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
|
|||||||
lineConfig.cap = lineCap;
|
lineConfig.cap = lineCap;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fillColor) {
|
const finalFillColor = fillColor ?? resolvedLineColor;
|
||||||
lineConfig.fill = fillColor;
|
|
||||||
} else if (this.props.drawStyle === DrawStyle.Bar) {
|
if (this.props.drawStyle === DrawStyle.Bar) {
|
||||||
lineConfig.fill = resolvedLineColor;
|
lineConfig.fill = finalFillColor;
|
||||||
} else if (this.props.drawStyle === DrawStyle.Histogram) {
|
} 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;
|
return lineConfig;
|
||||||
@@ -159,12 +167,8 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
|
|||||||
pointsConfig.show = pointsBuilder;
|
pointsConfig.show = pointsBuilder;
|
||||||
} else if (drawStyle === DrawStyle.Points) {
|
} else if (drawStyle === DrawStyle.Points) {
|
||||||
pointsConfig.show = true;
|
pointsConfig.show = true;
|
||||||
} else if (showPoints === VisibilityMode.Never) {
|
|
||||||
pointsConfig.show = false;
|
|
||||||
} else if (showPoints === VisibilityMode.Always) {
|
|
||||||
pointsConfig.show = true;
|
|
||||||
} else {
|
} else {
|
||||||
pointsConfig.show = false; // default to hidden
|
pointsConfig.show = !!showPoints;
|
||||||
}
|
}
|
||||||
|
|
||||||
return pointsConfig;
|
return pointsConfig;
|
||||||
|
|||||||
@@ -2,12 +2,7 @@ import { themeColors } from 'constants/theme';
|
|||||||
import uPlot from 'uplot';
|
import uPlot from 'uplot';
|
||||||
|
|
||||||
import type { SeriesProps } from '../types';
|
import type { SeriesProps } from '../types';
|
||||||
import {
|
import { DrawStyle, LineInterpolation, LineStyle } from '../types';
|
||||||
DrawStyle,
|
|
||||||
LineInterpolation,
|
|
||||||
LineStyle,
|
|
||||||
VisibilityMode,
|
|
||||||
} from '../types';
|
|
||||||
import { POINT_SIZE_FACTOR, UPlotSeriesBuilder } from '../UPlotSeriesBuilder';
|
import { POINT_SIZE_FACTOR, UPlotSeriesBuilder } from '../UPlotSeriesBuilder';
|
||||||
|
|
||||||
const createBaseProps = (
|
const createBaseProps = (
|
||||||
@@ -168,17 +163,17 @@ describe('UPlotSeriesBuilder', () => {
|
|||||||
expect(config.points?.show).toBe(pointsBuilder);
|
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(
|
const neverPointsBuilder = new UPlotSeriesBuilder(
|
||||||
createBaseProps({
|
createBaseProps({
|
||||||
drawStyle: DrawStyle.Line,
|
drawStyle: DrawStyle.Line,
|
||||||
showPoints: VisibilityMode.Never,
|
showPoints: false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const alwaysPointsBuilder = new UPlotSeriesBuilder(
|
const alwaysPointsBuilder = new UPlotSeriesBuilder(
|
||||||
createBaseProps({
|
createBaseProps({
|
||||||
drawStyle: DrawStyle.Line,
|
drawStyle: DrawStyle.Line,
|
||||||
showPoints: VisibilityMode.Always,
|
showPoints: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -122,12 +122,6 @@ export enum LineInterpolation {
|
|||||||
StepBefore = 'stepBefore',
|
StepBefore = 'stepBefore',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum VisibilityMode {
|
|
||||||
Always = 'always',
|
|
||||||
Auto = 'auto',
|
|
||||||
Never = 'never',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for configuring lines
|
* Props for configuring lines
|
||||||
*/
|
*/
|
||||||
@@ -163,7 +157,13 @@ export interface BarConfig {
|
|||||||
export interface PointsConfig {
|
export interface PointsConfig {
|
||||||
pointColor?: string;
|
pointColor?: string;
|
||||||
pointSize?: number;
|
pointSize?: number;
|
||||||
showPoints?: VisibilityMode;
|
showPoints?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum FillMode {
|
||||||
|
Solid = 'solid',
|
||||||
|
Gradient = 'gradient',
|
||||||
|
None = 'none',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
|
export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
|
||||||
@@ -177,6 +177,7 @@ export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
|
|||||||
show?: boolean;
|
show?: boolean;
|
||||||
spanGaps?: boolean;
|
spanGaps?: boolean;
|
||||||
fillColor?: string;
|
fillColor?: string;
|
||||||
|
fillMode?: FillMode;
|
||||||
isDarkMode?: boolean;
|
isDarkMode?: boolean;
|
||||||
stepInterval?: number;
|
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}70`);
|
||||||
|
g.addColorStop(0.6, `${startColor}40`);
|
||||||
|
g.addColorStop(1, endColor);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { Route } from 'react-router-dom';
|
||||||
|
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
|
import { rest, server } from 'mocks-server/server';
|
||||||
|
import { render, screen, waitFor } from 'tests/test-utils';
|
||||||
|
|
||||||
|
import DashboardWidget from '../index';
|
||||||
|
|
||||||
|
const DASHBOARD_ID = 'dash-1';
|
||||||
|
const WIDGET_ID = 'widget-abc';
|
||||||
|
|
||||||
|
const mockDashboardResponse = {
|
||||||
|
status: 'success',
|
||||||
|
data: {
|
||||||
|
id: DASHBOARD_ID,
|
||||||
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
|
createdBy: 'test',
|
||||||
|
updatedAt: '2024-01-01T00:00:00Z',
|
||||||
|
updatedBy: 'test',
|
||||||
|
isLocked: false,
|
||||||
|
data: {
|
||||||
|
collapsableRowsMigrated: true,
|
||||||
|
description: '',
|
||||||
|
name: '',
|
||||||
|
panelMap: {},
|
||||||
|
tags: [],
|
||||||
|
title: 'Test Dashboard',
|
||||||
|
uploadedGrafana: false,
|
||||||
|
uuid: '',
|
||||||
|
version: '',
|
||||||
|
variables: {},
|
||||||
|
widgets: [],
|
||||||
|
layout: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSafeNavigate = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('hooks/useSafeNavigate', () => ({
|
||||||
|
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||||
|
safeNavigate: mockSafeNavigate,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('container/NewWidget', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: (): JSX.Element => <div data-testid="new-widget">NewWidget</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// nuqs's useQueryState doesn't read from MemoryRouter, so we mock it to return
|
||||||
|
// controlled values via the `mockQueryState` map below.
|
||||||
|
const mockQueryState: Record<string, string | null> = {};
|
||||||
|
|
||||||
|
jest.mock('nuqs', () => ({
|
||||||
|
...jest.requireActual('nuqs'),
|
||||||
|
useQueryState: (key: string): [string | null, jest.Mock] => [
|
||||||
|
mockQueryState[key] ?? null,
|
||||||
|
jest.fn(),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Wrap component in a Route so useParams can resolve dashboardId
|
||||||
|
function renderAtRoute(
|
||||||
|
queryState: Record<string, string | null> = {},
|
||||||
|
): ReturnType<typeof render> {
|
||||||
|
Object.assign(mockQueryState, queryState);
|
||||||
|
return render(
|
||||||
|
<Route path="/dashboard/:dashboardId/new">
|
||||||
|
<DashboardWidget />
|
||||||
|
</Route>,
|
||||||
|
undefined,
|
||||||
|
{ initialRoute: `/dashboard/${DASHBOARD_ID}/new` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockSafeNavigate.mockClear();
|
||||||
|
Object.keys(mockQueryState).forEach((k) => delete mockQueryState[k]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DashboardWidget', () => {
|
||||||
|
it('redirects to dashboard when widgetId is missing', async () => {
|
||||||
|
renderAtRoute({ graphType: PANEL_TYPES.TIME_SERIES });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSafeNavigate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const [navigatedTo] = mockSafeNavigate.mock.calls[0];
|
||||||
|
expect(navigatedTo).toContain(`/dashboard/${DASHBOARD_ID}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects to dashboard when graphType is missing', async () => {
|
||||||
|
renderAtRoute({ widgetId: WIDGET_ID });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSafeNavigate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const [navigatedTo] = mockSafeNavigate.mock.calls[0];
|
||||||
|
expect(navigatedTo).toContain(`/dashboard/${DASHBOARD_ID}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows spinner while dashboard is loading', () => {
|
||||||
|
server.use(
|
||||||
|
rest.get(
|
||||||
|
`http://localhost/api/v1/dashboards/${DASHBOARD_ID}`,
|
||||||
|
(_req, res, ctx) => res(ctx.delay('infinite')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderAtRoute({ widgetId: WIDGET_ID, graphType: PANEL_TYPES.TIME_SERIES });
|
||||||
|
|
||||||
|
expect(screen.getByRole('img', { name: 'loading' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error message when dashboard fetch fails', async () => {
|
||||||
|
server.use(
|
||||||
|
rest.get(
|
||||||
|
`http://localhost/api/v1/dashboards/${DASHBOARD_ID}`,
|
||||||
|
(_req, res, ctx) => res(ctx.status(500), ctx.json({ status: 'error' })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderAtRoute({ widgetId: WIDGET_ID, graphType: PANEL_TYPES.TIME_SERIES });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders NewWidget when dashboard loads successfully', async () => {
|
||||||
|
server.use(
|
||||||
|
rest.get(
|
||||||
|
`http://localhost/api/v1/dashboards/${DASHBOARD_ID}`,
|
||||||
|
(_req, res, ctx) => res(ctx.status(200), ctx.json(mockDashboardResponse)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderAtRoute({ widgetId: WIDGET_ID, graphType: PANEL_TYPES.TIME_SERIES });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('new-widget')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useQuery } from 'react-query';
|
import { useQuery } from 'react-query';
|
||||||
import { generatePath, useParams } from 'react-router-dom';
|
import { generatePath, useParams } from 'react-router-dom';
|
||||||
import { Card, Typography } from 'antd';
|
import { Card, Typography } from 'antd';
|
||||||
@@ -11,9 +11,11 @@ import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
|||||||
import ROUTES from 'constants/routes';
|
import ROUTES from 'constants/routes';
|
||||||
import NewWidget from 'container/NewWidget';
|
import NewWidget from 'container/NewWidget';
|
||||||
import { isDrilldownEnabled } from 'container/QueryTable/Drilldown/drilldownUtils';
|
import { isDrilldownEnabled } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||||
|
import { useTransformDashboardVariables } from 'hooks/dashboard/useTransformDashboardVariables';
|
||||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||||
import { parseAsStringEnum, useQueryState } from 'nuqs';
|
import { parseAsStringEnum, useQueryState } from 'nuqs';
|
||||||
import { setDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
|
import { setDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
|
||||||
|
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||||
|
|
||||||
function DashboardWidget(): JSX.Element | null {
|
function DashboardWidget(): JSX.Element | null {
|
||||||
const { dashboardId } = useParams<{
|
const { dashboardId } = useParams<{
|
||||||
@@ -57,8 +59,15 @@ function DashboardWidgetInternal({
|
|||||||
widgetId: string;
|
widgetId: string;
|
||||||
graphType: PANEL_TYPES;
|
graphType: PANEL_TYPES;
|
||||||
}): JSX.Element | null {
|
}): JSX.Element | null {
|
||||||
|
const [selectedDashboard, setSelectedDashboard] = useState<
|
||||||
|
Dashboard | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
const { transformDashboardVariables } = useTransformDashboardVariables(
|
||||||
|
dashboardId,
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: dashboardResponse,
|
|
||||||
isFetching: isFetchingDashboardResponse,
|
isFetching: isFetchingDashboardResponse,
|
||||||
isError: isErrorDashboardResponse,
|
isError: isErrorDashboardResponse,
|
||||||
} = useQuery([REACT_QUERY_KEY.DASHBOARD_BY_ID, dashboardId, widgetId], {
|
} = useQuery([REACT_QUERY_KEY.DASHBOARD_BY_ID, dashboardId, widgetId], {
|
||||||
@@ -70,17 +79,15 @@ function DashboardWidgetInternal({
|
|||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
cacheTime: DASHBOARD_CACHE_TIME,
|
cacheTime: DASHBOARD_CACHE_TIME,
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
|
const updatedDashboardData = transformDashboardVariables(response.data);
|
||||||
|
setSelectedDashboard(updatedDashboardData);
|
||||||
setDashboardVariablesStore({
|
setDashboardVariablesStore({
|
||||||
dashboardId,
|
dashboardId,
|
||||||
variables: response.data.data.variables,
|
variables: updatedDashboardData.data.variables,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedDashboard = useMemo(() => dashboardResponse?.data, [
|
|
||||||
dashboardResponse?.data,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (isFetchingDashboardResponse) {
|
if (isFetchingDashboardResponse) {
|
||||||
return <Spinner tip="Loading.." />;
|
return <Spinner tip="Loading.." />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,21 +17,18 @@ import { useDispatch, useSelector } from 'react-redux';
|
|||||||
import { Modal } from 'antd';
|
import { Modal } from 'antd';
|
||||||
import getDashboard from 'api/v1/dashboards/id/get';
|
import getDashboard from 'api/v1/dashboards/id/get';
|
||||||
import locked from 'api/v1/dashboards/id/lock';
|
import locked from 'api/v1/dashboards/id/lock';
|
||||||
import { ALL_SELECTED_VALUE } from 'components/NewSelect/utils';
|
|
||||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||||
import dayjs, { Dayjs } from 'dayjs';
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
import { useDashboardVariablesFromLocalStorage } from 'hooks/dashboard/useDashboardFromLocalStorage';
|
import { useTransformDashboardVariables } from 'hooks/dashboard/useTransformDashboardVariables';
|
||||||
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
|
|
||||||
import useTabVisibility from 'hooks/useTabFocus';
|
import useTabVisibility from 'hooks/useTabFocus';
|
||||||
import { getUpdatedLayout } from 'lib/dashboard/getUpdatedLayout';
|
import { getUpdatedLayout } from 'lib/dashboard/getUpdatedLayout';
|
||||||
import { getMinMaxForSelectedTime } from 'lib/getMinMax';
|
import { getMinMaxForSelectedTime } from 'lib/getMinMax';
|
||||||
import { defaultTo, isEmpty } from 'lodash-es';
|
import { defaultTo } from 'lodash-es';
|
||||||
import isEqual from 'lodash-es/isEqual';
|
import isEqual from 'lodash-es/isEqual';
|
||||||
import isUndefined from 'lodash-es/isUndefined';
|
import isUndefined from 'lodash-es/isUndefined';
|
||||||
import omitBy from 'lodash-es/omitBy';
|
import omitBy from 'lodash-es/omitBy';
|
||||||
import { useAppContext } from 'providers/App/App';
|
import { useAppContext } from 'providers/App/App';
|
||||||
import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables';
|
import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables';
|
||||||
import { normalizeUrlValueForVariable } from 'providers/Dashboard/normalizeUrlValue';
|
|
||||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||||
// eslint-disable-next-line no-restricted-imports
|
// eslint-disable-next-line no-restricted-imports
|
||||||
import { Dispatch } from 'redux';
|
import { Dispatch } from 'redux';
|
||||||
@@ -39,10 +36,9 @@ import { AppState } from 'store/reducers';
|
|||||||
import AppActions from 'types/actions';
|
import AppActions from 'types/actions';
|
||||||
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
|
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
|
||||||
import { SuccessResponseV2 } from 'types/api';
|
import { SuccessResponseV2 } from 'types/api';
|
||||||
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll';
|
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||||
import APIError from 'types/api/error';
|
import APIError from 'types/api/error';
|
||||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||||
import { v4 as generateUUID } from 'uuid';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DASHBOARD_CACHE_TIME,
|
DASHBOARD_CACHE_TIME,
|
||||||
@@ -137,9 +133,10 @@ export function DashboardProvider({
|
|||||||
const {
|
const {
|
||||||
currentDashboard,
|
currentDashboard,
|
||||||
updateLocalStorageDashboardVariables,
|
updateLocalStorageDashboardVariables,
|
||||||
} = useDashboardVariablesFromLocalStorage(dashboardId);
|
getUrlVariables,
|
||||||
|
updateUrlVariable,
|
||||||
const { getUrlVariables, updateUrlVariable } = useVariablesFromUrl();
|
transformDashboardVariables,
|
||||||
|
} = useTransformDashboardVariables(dashboardId);
|
||||||
|
|
||||||
const updatedTimeRef = useRef<Dayjs | null>(null); // Using ref to store the updated time
|
const updatedTimeRef = useRef<Dayjs | null>(null); // Using ref to store the updated time
|
||||||
const modalRef = useRef<any>(null);
|
const modalRef = useRef<any>(null);
|
||||||
@@ -151,99 +148,6 @@ export function DashboardProvider({
|
|||||||
|
|
||||||
const [isDashboardFetching, setIsDashboardFetching] = useState<boolean>(false);
|
const [isDashboardFetching, setIsDashboardFetching] = useState<boolean>(false);
|
||||||
|
|
||||||
const mergeDBWithLocalStorage = (
|
|
||||||
data: Dashboard,
|
|
||||||
localStorageVariables: any,
|
|
||||||
): Dashboard => {
|
|
||||||
const updatedData = data;
|
|
||||||
if (data && localStorageVariables) {
|
|
||||||
const updatedVariables = data.data.variables;
|
|
||||||
const variablesFromUrl = getUrlVariables();
|
|
||||||
Object.keys(data.data.variables).forEach((variable) => {
|
|
||||||
const variableData = data.data.variables[variable];
|
|
||||||
|
|
||||||
// values from url
|
|
||||||
const urlVariable = variableData?.name
|
|
||||||
? variablesFromUrl[variableData?.name] || variablesFromUrl[variableData.id]
|
|
||||||
: variablesFromUrl[variableData.id];
|
|
||||||
|
|
||||||
let updatedVariable = {
|
|
||||||
...data.data.variables[variable],
|
|
||||||
...localStorageVariables[variableData.name as any],
|
|
||||||
};
|
|
||||||
|
|
||||||
// respect the url variable if it is set, override the others
|
|
||||||
if (!isEmpty(urlVariable)) {
|
|
||||||
if (urlVariable === ALL_SELECTED_VALUE) {
|
|
||||||
updatedVariable = {
|
|
||||||
...updatedVariable,
|
|
||||||
allSelected: true,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// Normalize URL value to match variable's multiSelect configuration
|
|
||||||
const normalizedValue = normalizeUrlValueForVariable(
|
|
||||||
urlVariable,
|
|
||||||
variableData,
|
|
||||||
);
|
|
||||||
|
|
||||||
updatedVariable = {
|
|
||||||
...updatedVariable,
|
|
||||||
selectedValue: normalizedValue,
|
|
||||||
// Only set allSelected to false if showALLOption is available
|
|
||||||
...(updatedVariable?.showALLOption && { allSelected: false }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updatedVariables[variable] = updatedVariable;
|
|
||||||
});
|
|
||||||
updatedData.data.variables = updatedVariables;
|
|
||||||
}
|
|
||||||
return updatedData;
|
|
||||||
};
|
|
||||||
// As we do not have order and ID's in the variables object, we have to process variables to add order and ID if they do not exist in the variables object
|
|
||||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
|
||||||
const transformDashboardVariables = (data: Dashboard): Dashboard => {
|
|
||||||
if (data && data.data && data.data.variables) {
|
|
||||||
const clonedDashboardData = mergeDBWithLocalStorage(
|
|
||||||
JSON.parse(JSON.stringify(data)),
|
|
||||||
currentDashboard,
|
|
||||||
);
|
|
||||||
const { variables } = clonedDashboardData.data;
|
|
||||||
const existingOrders: Set<number> = new Set();
|
|
||||||
|
|
||||||
for (const key in variables) {
|
|
||||||
// eslint-disable-next-line no-prototype-builtins
|
|
||||||
if (variables.hasOwnProperty(key)) {
|
|
||||||
const variable: IDashboardVariable = variables[key];
|
|
||||||
|
|
||||||
// Check if 'order' property doesn't exist or is undefined
|
|
||||||
if (variable.order === undefined) {
|
|
||||||
// Find a unique order starting from 0
|
|
||||||
let order = 0;
|
|
||||||
while (existingOrders.has(order)) {
|
|
||||||
order += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
variable.order = order;
|
|
||||||
existingOrders.add(order);
|
|
||||||
// ! BWC - Specific case for backward compatibility where textboxValue was used instead of defaultValue
|
|
||||||
if (variable.type === 'TEXTBOX' && !variable.defaultValue) {
|
|
||||||
variable.defaultValue = variable.textboxValue || '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (variable.id === undefined) {
|
|
||||||
variable.id = generateUUID();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return clonedDashboardData;
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
const dashboardResponse = useQuery(
|
const dashboardResponse = useQuery(
|
||||||
[
|
[
|
||||||
REACT_QUERY_KEY.DASHBOARD_BY_ID,
|
REACT_QUERY_KEY.DASHBOARD_BY_ID,
|
||||||
@@ -274,13 +178,14 @@ export function DashboardProvider({
|
|||||||
},
|
},
|
||||||
|
|
||||||
onSuccess: (data: SuccessResponseV2<Dashboard>) => {
|
onSuccess: (data: SuccessResponseV2<Dashboard>) => {
|
||||||
// if the url variable is not set for any variable, set it to the default value
|
const updatedDashboardData = transformDashboardVariables(data?.data);
|
||||||
const variables = data?.data?.data?.variables;
|
|
||||||
|
// initialize URL variables after dashboard state is set to avoid race conditions
|
||||||
|
const variables = updatedDashboardData?.data?.variables;
|
||||||
if (variables) {
|
if (variables) {
|
||||||
initializeDefaultVariables(variables, getUrlVariables, updateUrlVariable);
|
initializeDefaultVariables(variables, getUrlVariables, updateUrlVariable);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedDashboardData = transformDashboardVariables(data?.data);
|
|
||||||
const updatedDate = dayjs(updatedDashboardData?.updatedAt);
|
const updatedDate = dayjs(updatedDashboardData?.updatedAt);
|
||||||
|
|
||||||
setIsDashboardLocked(updatedDashboardData?.locked || false);
|
setIsDashboardLocked(updatedDashboardData?.locked || false);
|
||||||
|
|||||||
@@ -381,6 +381,7 @@ describe('Dashboard Provider - URL Variables Integration', () => {
|
|||||||
multiSelect: false,
|
multiSelect: false,
|
||||||
allSelected: false,
|
allSelected: false,
|
||||||
showALLOption: true,
|
showALLOption: true,
|
||||||
|
order: 0,
|
||||||
},
|
},
|
||||||
services: {
|
services: {
|
||||||
id: 'svc-id',
|
id: 'svc-id',
|
||||||
@@ -388,6 +389,7 @@ describe('Dashboard Provider - URL Variables Integration', () => {
|
|||||||
multiSelect: true,
|
multiSelect: true,
|
||||||
allSelected: false,
|
allSelected: false,
|
||||||
showALLOption: true,
|
showALLOption: true,
|
||||||
|
order: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mockGetUrlVariables,
|
mockGetUrlVariables,
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
|||||||
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
|
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
|
||||||
import { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems';
|
import { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems';
|
||||||
import { QueryTableProps } from 'container/QueryTable/QueryTable.intefaces';
|
import { QueryTableProps } from 'container/QueryTable/QueryTable.intefaces';
|
||||||
|
import {
|
||||||
|
FillMode,
|
||||||
|
LineInterpolation,
|
||||||
|
LineStyle,
|
||||||
|
} from 'lib/uPlotV2/config/types';
|
||||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
|
||||||
import { IField } from '../logs/fields';
|
import { IField } from '../logs/fields';
|
||||||
@@ -132,6 +137,10 @@ export interface IBaseWidget {
|
|||||||
legendPosition?: LegendPosition;
|
legendPosition?: LegendPosition;
|
||||||
customLegendColors?: Record<string, string>;
|
customLegendColors?: Record<string, string>;
|
||||||
contextLinks?: ContextLinksData;
|
contextLinks?: ContextLinksData;
|
||||||
|
lineInterpolation?: LineInterpolation;
|
||||||
|
showPoints?: boolean;
|
||||||
|
lineStyle?: LineStyle;
|
||||||
|
fillMode?: FillMode;
|
||||||
}
|
}
|
||||||
export interface Widgets extends IBaseWidget {
|
export interface Widgets extends IBaseWidget {
|
||||||
query: Query;
|
query: Query;
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
|
||||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
|
||||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the stored time duration for a route in localStorage.
|
|
||||||
* Used by both DateTimeSelectionV2 (manual time pick) and useZoomOut (zoom out button).
|
|
||||||
*
|
|
||||||
* @param pathname - The route path (e.g. /infrastructure-monitoring/hosts)
|
|
||||||
* @param value - The time value to store (preset string like '1w' or JSON string for custom range)
|
|
||||||
*/
|
|
||||||
export function persistTimeDurationForRoute(
|
|
||||||
pathname: string,
|
|
||||||
value: string,
|
|
||||||
): void {
|
|
||||||
const preRoutes = getLocalStorageKey(LOCALSTORAGE.METRICS_TIME_IN_DURATION);
|
|
||||||
let preRoutesObject: Record<string, string> = {};
|
|
||||||
try {
|
|
||||||
preRoutesObject = preRoutes ? JSON.parse(preRoutes) : {};
|
|
||||||
} catch {
|
|
||||||
preRoutesObject = {};
|
|
||||||
}
|
|
||||||
const preRoute = { ...preRoutesObject, [pathname]: value };
|
|
||||||
setLocalStorageKey(
|
|
||||||
LOCALSTORAGE.METRICS_TIME_IN_DURATION,
|
|
||||||
JSON.stringify(preRoute),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5646,7 +5646,7 @@
|
|||||||
tailwind-merge "^2.5.2"
|
tailwind-merge "^2.5.2"
|
||||||
tailwindcss-animate "^1.0.7"
|
tailwindcss-animate "^1.0.7"
|
||||||
|
|
||||||
"@signozhq/toggle-group@^0.0.1":
|
"@signozhq/toggle-group@0.0.1":
|
||||||
version "0.0.1"
|
version "0.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/@signozhq/toggle-group/-/toggle-group-0.0.1.tgz#c82ff1da34e77b24da53c2d595ad6b4a0d1b1de4"
|
resolved "https://registry.yarnpkg.com/@signozhq/toggle-group/-/toggle-group-0.0.1.tgz#c82ff1da34e77b24da53c2d595ad6b4a0d1b1de4"
|
||||||
integrity sha512-871bQayL5MaqsuNOFHKexidu9W2Hlg1y4xmH8C5mGmlfZ4bd0ovJ9OweQrM6Puys3jeMwi69xmJuesYCfKQc1g==
|
integrity sha512-871bQayL5MaqsuNOFHKexidu9W2Hlg1y4xmH8C5mGmlfZ4bd0ovJ9OweQrM6Puys3jeMwi69xmJuesYCfKQc1g==
|
||||||
|
|||||||
Reference in New Issue
Block a user