Compare commits

...

5 Commits

Author SHA1 Message Date
Nityananda Gohain
ac37026888 Merge branch 'main' into tvats-fix-dashboard-date-refresh 2026-05-06 12:19:12 +05:30
Tushar Vats
d267f77ccf fix: dashboard invalid date state upon refresh 2026-05-06 11:37:34 +05:30
Tushar Vats
edec92f51c fix: dashboard invalid date state upon refresh 2026-05-06 11:27:25 +05:30
Abhi kumar
acdaef6c2e chore: added reference to crosspanel sync docs (#11200)
* chore: added reference to crosspanel sync docs

* chore: minor changes
2026-05-06 05:44:03 +00:00
Abhi kumar
a7690bdaa2 fix: added fix for all series in tooltip sync mode (#11197)
* fix: added fix for all series in tooltip sync mode

* chore: minor cleanup
2026-05-06 04:24:33 +00:00
6 changed files with 250 additions and 15 deletions

View File

@@ -24,6 +24,43 @@
line-height: 20px;
}
.crossPanelSyncSectionHeader {
display: flex;
align-items: center;
gap: 6px;
align-self: flex-start;
}
.crossPanelSyncInfoIcon {
cursor: help;
color: var(--l3-foreground);
}
.crossPanelSyncTooltipContent {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 300px;
}
.crossPanelSyncTooltipTitle {
font-size: 14px;
}
.crossPanelSyncTooltipDescription {
font-size: 12px;
line-height: 1.5;
}
.crossPanelSyncTooltipDocLink {
display: flex;
align-items: center;
gap: 4px;
color: var(--primary-background);
font-size: 12px;
margin-top: 4px;
}
.crossPanelSyncRow {
display: flex;
flex-direction: row;

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Col, Input, Radio, Select, Space, Typography } from 'antd';
import { Col, Input, Radio, Select, Space, Tooltip, Typography } from 'antd';
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddTags';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
@@ -10,7 +10,7 @@ import {
SyncTooltipFilterMode,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { isEqual } from 'lodash-es';
import { Check, X } from 'lucide-react';
import { Check, ExternalLink, Info, X } from '@signozhq/icons';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import styles from './GeneralSettings.module.scss';
@@ -173,9 +173,36 @@ function GeneralDashboardSettings(): JSX.Element {
</Space>
</Col>
<Col className={`${styles.overviewSettings} ${styles.crossPanelSyncGroup}`}>
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
Cross-Panel Sync
</Typography.Text>
<div className={styles.crossPanelSyncSectionHeader}>
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
Cross-Panel Sync
</Typography.Text>
<Tooltip
title={
<div className={styles.crossPanelSyncTooltipContent}>
<strong className={styles.crossPanelSyncTooltipTitle}>
Cross-Panel Sync
</strong>
<span className={styles.crossPanelSyncTooltipDescription}>
Sync crosshair and tooltip across all the dashboard panels
</span>
<a
href="https://signoz.io/docs/dashboards/interactivity/#cross-panel-sync"
target="_blank"
rel="noopener noreferrer"
className={styles.crossPanelSyncTooltipDocLink}
>
Learn more
<ExternalLink size={12} />
</a>
</div>
}
placement="top"
mouseEnterDelay={0.5}
>
<Info size={14} className={styles.crossPanelSyncInfoIcon} />
</Tooltip>
</div>
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
<Typography.Text className={styles.crossPanelSyncTitle}>

View File

@@ -0,0 +1,158 @@
import { act, render } from '@testing-library/react';
import { Modal } from 'antd';
import { useDashboardBootstrap } from 'hooks/dashboard/useDashboardBootstrap';
import { useTransformDashboardVariables } from 'hooks/dashboard/useTransformDashboardVariables';
import useTabVisibility from 'hooks/useTabFocus';
import { getMinMaxForSelectedTime } from 'lib/getMinMax';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { useDashboardQuery } from './useDashboardQuery';
const mockDispatch = jest.fn();
const mockSetDashboardData = jest.fn();
const mockSetLayouts = jest.fn();
const mockSetPanelMap = jest.fn();
const mockResetDashboardStore = jest.fn();
const mockGetUrlVariables = jest.fn();
const mockUpdateUrlVariable = jest.fn();
const mockRefetch = jest.fn();
let mockGlobalTime = {
selectedTime: 'custom',
minTime: 1710000000000000000,
maxTime: 1710000300000000000,
isAutoRefreshDisabled: true,
};
let currentQueryData: unknown;
jest.mock('react-i18next', () => ({
useTranslation: (): { t: (key: string) => string } => ({
t: (key: string): string => key,
}),
}));
jest.mock('react-redux', () => ({
useDispatch: jest.fn(() => mockDispatch),
useSelector: jest.fn(
(
selectorFn: (state: { globalTime: typeof mockGlobalTime }) => unknown,
): unknown => selectorFn({ globalTime: mockGlobalTime }),
),
}));
jest.mock('hooks/useTabFocus', () => jest.fn(() => true));
jest.mock('hooks/dashboard/useDashboardVariablesSync', () => ({
useDashboardVariablesSync: jest.fn(),
}));
jest.mock('./useDashboardQuery', () => ({
useDashboardQuery: jest.fn(),
}));
jest.mock('hooks/dashboard/useTransformDashboardVariables', () => ({
useTransformDashboardVariables: jest.fn(),
}));
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: jest.fn(),
}));
jest.mock('providers/Dashboard/initializeDefaultVariables', () => ({
initializeDefaultVariables: jest.fn(),
}));
jest.mock('lib/dashboard/getUpdatedLayout', () => ({
getUpdatedLayout: jest.fn(() => []),
}));
jest.mock('providers/Dashboard/util', () => ({
sortLayout: jest.fn((layout) => layout),
}));
jest.mock('lib/getMinMax', () => ({
getMinMaxForSelectedTime: jest.fn(),
}));
function TestComponent({ confirm }: { confirm: typeof Modal.confirm }): null {
useDashboardBootstrap('dashboard-1', { confirm });
return null;
}
describe('useDashboardBootstrap', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGlobalTime = {
selectedTime: 'custom',
minTime: 1710000000000000000,
maxTime: 1710000300000000000,
isAutoRefreshDisabled: true,
};
jest.mocked(useDashboardStore as unknown as jest.Mock).mockReturnValue({
setDashboardData: mockSetDashboardData,
setLayouts: mockSetLayouts,
setPanelMap: mockSetPanelMap,
resetDashboardStore: mockResetDashboardStore,
});
jest
.mocked(useTransformDashboardVariables as unknown as jest.Mock)
.mockReturnValue({
getUrlVariables: mockGetUrlVariables,
updateUrlVariable: mockUpdateUrlVariable,
transformDashboardVariables: <T,>(data: T): T => data,
});
jest.mocked(useTabVisibility as unknown as jest.Mock).mockReturnValue(true);
jest
.mocked(useDashboardQuery as unknown as jest.Mock)
.mockImplementation(() => ({
data: currentQueryData,
isLoading: false,
isError: false,
isFetching: false,
error: null,
refetch: mockRefetch,
}));
});
it('keeps minTime and maxTime unchanged for custom range on refresh confirm', () => {
const initialDashboard = {
id: 'dashboard-1',
updatedAt: '2024-01-01T00:00:00.000Z',
data: { layout: [], panelMap: {}, variables: {} },
};
const updatedDashboard = {
id: 'dashboard-1',
updatedAt: '2024-01-01T01:00:00.000Z',
data: { layout: [], panelMap: {}, variables: {} },
};
const mockConfirm = jest.fn<
ReturnType<typeof Modal.confirm>,
Parameters<typeof Modal.confirm>
>(() => ({ destroy: jest.fn(), update: jest.fn() }));
currentQueryData = { data: initialDashboard };
const { rerender } = render(<TestComponent confirm={mockConfirm} />);
expect(mockConfirm).not.toHaveBeenCalled();
currentQueryData = { data: updatedDashboard };
rerender(<TestComponent confirm={mockConfirm} />);
expect(mockConfirm).toHaveBeenCalledTimes(1);
const firstCall = mockConfirm.mock.calls[0];
expect(firstCall).toBeDefined();
const [confirmProps] = firstCall as Parameters<typeof Modal.confirm>;
act(() => {
confirmProps.onOk?.();
});
expect(getMinMaxForSelectedTime).not.toHaveBeenCalled();
expect(mockDispatch).toHaveBeenCalledWith({
type: 'UPDATE_TIME_INTERVAL',
payload: {
selectedTime: 'custom',
minTime: mockGlobalTime.minTime,
maxTime: mockGlobalTime.maxTime,
},
});
});
});

View File

@@ -102,11 +102,19 @@ export function useDashboardBootstrap(
onOk() {
setDashboardData(updatedDashboardData);
const { maxTime, minTime } = getMinMaxForSelectedTime(
globalTime.selectedTime,
globalTime.minTime,
globalTime.maxTime,
);
const { maxTime, minTime } =
globalTime.selectedTime === 'custom'
? {
// For custom ranges, min/max are already stored in nanoseconds.
// Recomputing via getMinMaxForSelectedTime would multiply them again.
maxTime: globalTime.maxTime,
minTime: globalTime.minTime,
}
: getMinMaxForSelectedTime(
globalTime.selectedTime,
globalTime.minTime,
globalTime.maxTime,
);
dispatch({
type: UPDATE_TIME_INTERVAL,
payload: { maxTime, minTime, selectedTime: globalTime.selectedTime },

View File

@@ -24,10 +24,15 @@ export default function Tooltip({
);
const showHeader = showTooltipHeader || activeItem != null;
// With a single series the active item is fully represented in the header —
// hide the divider and list to avoid showing a duplicate row.
const showList = tooltipContent.length > 1;
const showDivider = showList && showHeader;
// A single row collapses into the header when it's the active item, but
// must stay in the list when there's no active item (e.g. sync-driven
// tooltips with no focused series) — otherwise the row would vanish.
const showList =
tooltipContent.length > 1 ||
(tooltipContent.length === 1 && activeItem == null);
// The divider separates the active row in the header from the list; with
// no active item it has nothing to separate.
const showDivider = showList && showHeader && activeItem != null;
return (
<div

View File

@@ -137,7 +137,7 @@ function applyReceiverSync({
if (commonKeys.length === 0) {
uPlotInstance.setSeries(null, { focus: false });
return [];
return noMatchResult;
}
if ((uPlotInstance.cursor.left ?? -1) < 0) {