Compare commits

..

16 Commits

Author SHA1 Message Date
Abhi kumar
e2b8b56591 Merge branch 'main' into enh/external-api-charts 2026-03-05 16:19:15 +05:30
Abhi kumar
48ccdfcf64 enh: updated tooltip pinning structure to be used across different charts (#10459)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* chore: made baseconfigbuilder generic to be used across different charts

* chore: updated baseconfigbuilder test

* chore: updated timezone types

* chore: fixed tsc + test

* chore: fixed tsc + test

* chore: fixed tsc + test

* feat: added the base changes for pinnedtooltip element

* chore: fix tsc + test

* chore: fixed tooltipplugin test

* chore: restructured the code

* chore: restrucuted tooltip schema and resolved pr comments

* chore: fixed cursor comments

* chore: fixed cursor comments

* chore: fixed cursor comments
2026-03-05 16:12:34 +05:30
Yunus M
8c7dc942d0 fix: handling of input changes in CustomTimePicker to ensure value is applied on popover close (#10484)
* fix: enhance input handling to prevent popover closure on input click

* fix: handling of input changes in CustomTimePicker to ensure value is applied on popover close

* chore: add test cases
2026-03-05 14:19:14 +05:30
Yunus M
16761bc574 Merge branch 'main' into enh/external-api-charts 2026-03-05 14:01:48 +05:30
Naman Verma
0e1bb5fd91 fix: exclude internal attributes from promQL results (#10465)
* fix: exclude internal attributes from promQL results

* fix: __name__ can stay
2026-03-05 13:07:06 +05:30
Abhi kumar
91ee0fb06f Merge branch 'main' into enh/external-api-charts 2026-03-03 15:12:59 +05:30
Abhi Kumar
6a138fa422 fix: tests 2026-03-03 15:10:10 +05:30
Abhi Kumar
28b21f6c48 Merge branch 'main' of https://github.com/SigNoz/signoz into enh/external-api-charts 2026-03-03 14:25:32 +05:30
Abhi Kumar
7974f4fb08 feat: replaced external apis barchart with the new bar chart 2026-03-01 19:16:45 +05:30
Abhi kumar
f11153cdec Merge branch 'main' into chore/uplot-builder-restructure 2026-02-28 18:55:41 +05:30
Abhi Kumar
a380c4d6be chore: fixed tsc + test 2026-02-28 18:08:39 +05:30
Abhi Kumar
a0f576e5fb chore: fixed tsc + test 2026-02-28 17:53:44 +05:30
Abhi Kumar
98013ee3b9 chore: fixed tsc + test 2026-02-28 15:51:58 +05:30
Abhi Kumar
97b94fc4f5 chore: updated timezone types 2026-02-28 15:20:24 +05:30
Abhi Kumar
0f3f49b96a chore: updated baseconfigbuilder test 2026-02-28 15:15:21 +05:30
Abhi Kumar
0928a30863 chore: made baseconfigbuilder generic to be used across different charts 2026-02-28 15:10:16 +05:30
18 changed files with 669 additions and 97 deletions

View File

@@ -0,0 +1,279 @@
import { useState } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import dayjs from 'dayjs';
import * as timeUtils from 'utils/timeUtils';
import CustomTimePicker from './CustomTimePicker';
jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom');
return {
...actual,
useLocation: jest.fn().mockReturnValue({
pathname: '/test-path',
}),
};
});
jest.mock('providers/Timezone', () => {
const actual = jest.requireActual('providers/Timezone');
return {
...actual,
useTimezone: jest.fn().mockReturnValue({
timezone: {
value: 'UTC',
offset: '+00:00',
name: 'UTC',
},
browserTimezone: {
value: 'UTC',
offset: '+00:00',
name: 'UTC',
},
}),
};
});
interface WrapperProps {
initialValue?: string;
showLiveLogs?: boolean;
onValidCustomDateChange?: () => void;
onError?: () => void;
onSelect?: (value: string) => void;
onCustomDateHandler?: () => void;
onCustomTimeStatusUpdate?: () => void;
}
function Wrapper({
initialValue = '2024-01-01 00:00:00 - 2024-01-01 01:00:00',
showLiveLogs = false,
onValidCustomDateChange = (): void => {},
onError = (): void => {},
onSelect = (): void => {},
onCustomDateHandler = (): void => {},
onCustomTimeStatusUpdate = (): void => {},
}: WrapperProps): JSX.Element {
const [open, setOpen] = useState(false);
const [selectedTime, setSelectedTime] = useState('custom');
const [selectedValue, setSelectedValue] = useState(initialValue);
const handleSelect = (value: string): void => {
setSelectedTime(value);
onSelect(value);
};
return (
<CustomTimePicker
open={open}
setOpen={setOpen}
onSelect={handleSelect}
onError={onError}
selectedTime={selectedTime}
selectedValue={selectedValue}
onValidCustomDateChange={({ timeStr }): void => {
setSelectedValue(timeStr);
onValidCustomDateChange();
}}
onCustomDateHandler={(): void => {
onCustomDateHandler();
}}
onCustomTimeStatusUpdate={(): void => {
onCustomTimeStatusUpdate();
}}
items={[
{ label: 'Last 5 minutes', value: '5m' },
{ label: 'Custom', value: 'custom' },
]}
minTime={dayjs('2024-01-01 00:00:00').valueOf() * 1000_000}
maxTime={dayjs('2024-01-01 01:00:00').valueOf() * 1000_000}
showLiveLogs={showLiveLogs}
/>
);
}
describe('CustomTimePicker', () => {
it('does not close or reset when clicking input while open', () => {
render(<Wrapper />);
const input = screen.getByRole('textbox');
// Open popover
fireEvent.focus(input);
// Type some text
fireEvent.change(input, { target: { value: '5m' } });
// Click the input again while open
fireEvent.mouseDown(input);
fireEvent.click(input);
// Value should remain as typed
expect((input as HTMLInputElement).value).toBe('5m');
});
it('applies valid shorthand on Enter', () => {
const onValid = jest.fn();
const onError = jest.fn();
render(<Wrapper onValidCustomDateChange={onValid} onError={onError} />);
const input = screen.getByRole('textbox');
fireEvent.focus(input);
fireEvent.change(input, { target: { value: '5m' } });
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
expect(onValid).toHaveBeenCalledTimes(1);
// onError(false) may be called by internal reset logic; we only assert that
// it was never called with a truthy error state
expect(onError).not.toHaveBeenCalledWith(true);
});
it('sets error and updates custom time status for invalid shorthand exceeding max allowed window', () => {
const onValid = jest.fn();
const onError = jest.fn();
const onCustomTimeStatusUpdate = jest.fn();
render(
<Wrapper
onValidCustomDateChange={onValid}
onError={onError}
onCustomTimeStatusUpdate={onCustomTimeStatusUpdate}
/>,
);
const input = screen.getByRole('textbox');
fireEvent.focus(input);
// large number of days to ensure it exceeds the 15 months allowed window
fireEvent.change(input, { target: { value: '9999d' } });
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
expect(onError).toHaveBeenCalledWith(true);
expect(onCustomTimeStatusUpdate).toHaveBeenCalledWith();
expect(onValid).not.toHaveBeenCalled();
});
it('treats close after change like pressing Enter (blur + chevron)', () => {
const onValid = jest.fn();
const onError = jest.fn();
render(<Wrapper onValidCustomDateChange={onValid} onError={onError} />);
const input = screen.getByRole('textbox');
// Open and change value so "changed since open" is true
fireEvent.focus(input);
fireEvent.change(input, { target: { value: '5m' } });
fireEvent.blur(input);
// Click the chevron (which triggers handleClose)
const chevron = document.querySelector(
'.time-input-suffix-icon-badge',
) as HTMLElement;
fireEvent.click(chevron);
// Should have applied the value (same as Enter)
expect(onValid).toHaveBeenCalledTimes(1);
expect(onError).not.toHaveBeenCalledWith(true);
});
it('applies epoch start/end range on Enter via onCustomDateHandler', () => {
const onCustomDateHandler = jest.fn();
const onError = jest.fn();
render(
<Wrapper onCustomDateHandler={onCustomDateHandler} onError={onError} />,
);
const now = dayjs().valueOf();
const later = dayjs().add(1, 'hour').valueOf();
const input = screen.getByRole('textbox');
fireEvent.focus(input);
fireEvent.change(input, {
target: { value: `${now} - ${later}` },
});
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
expect(onCustomDateHandler).toHaveBeenCalledTimes(1);
expect(onError).not.toHaveBeenCalledWith(true);
});
it('uses validateTimeRange result for generic formatted ranges (valid case)', () => {
const validateTimeRangeSpy = jest.spyOn(timeUtils, 'validateTimeRange');
const onCustomDateHandler = jest.fn();
const onError = jest.fn();
validateTimeRangeSpy.mockReturnValue({
isValid: true,
errorDetails: undefined,
startTimeMs: dayjs('2024-01-01 00:00:00').valueOf(),
endTimeMs: dayjs('2024-01-01 01:00:00').valueOf(),
});
render(
<Wrapper onCustomDateHandler={onCustomDateHandler} onError={onError} />,
);
const input = screen.getByRole('textbox');
fireEvent.focus(input);
fireEvent.change(input, {
target: { value: 'foo - bar' },
});
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
expect(validateTimeRangeSpy).toHaveBeenCalled();
expect(onCustomDateHandler).toHaveBeenCalledTimes(1);
expect(onError).not.toHaveBeenCalledWith(true);
validateTimeRangeSpy.mockRestore();
});
it('uses validateTimeRange result for generic formatted ranges (invalid case)', () => {
const validateTimeRangeSpy = jest.spyOn(timeUtils, 'validateTimeRange');
const onValid = jest.fn();
const onError = jest.fn();
validateTimeRangeSpy.mockReturnValue({
isValid: false,
errorDetails: {
message: 'Invalid range',
code: 'INVALID_RANGE',
description: 'Start must be before end',
},
startTimeMs: 0,
endTimeMs: 0,
});
render(<Wrapper onValidCustomDateChange={onValid} onError={onError} />);
const input = screen.getByRole('textbox');
fireEvent.focus(input);
fireEvent.change(input, {
target: { value: 'foo - bar' },
});
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
expect(validateTimeRangeSpy).toHaveBeenCalled();
expect(onError).toHaveBeenCalledWith(true);
expect(onValid).not.toHaveBeenCalled();
validateTimeRangeSpy.mockRestore();
});
it('opens live mode with correct label', () => {
render(<Wrapper showLiveLogs />);
const input = screen.getByRole('textbox');
fireEvent.focus(input);
expect((input as HTMLInputElement).value).toBe('Live');
});
});

View File

@@ -104,6 +104,10 @@ function CustomTimePicker({
const location = useLocation();
const inputRef = useRef<InputRef>(null);
const initialInputValueOnOpenRef = useRef<string>('');
const hasChangedSinceOpenRef = useRef<boolean>(false);
// Tracks if the last pointer down was on the input so we don't close the popover when user clicks the input again
const isClickFromInputRef = useRef(false);
const [activeView, setActiveView] = useState<ViewType>(DEFAULT_VIEW);
@@ -238,6 +242,21 @@ function CustomTimePicker({
};
const handleOpenChange = (newOpen: boolean): void => {
// Don't close when the user clicked the input (trigger); Ant Design treats trigger as "outside" overlay
if (!newOpen && isClickFromInputRef.current) {
isClickFromInputRef.current = false;
return;
}
isClickFromInputRef.current = false;
// If the popover is trying to close and the value changed since opening,
// treat it as if the user pressed Enter (attempt to apply the value)
if (!newOpen && hasChangedSinceOpenRef.current) {
hasChangedSinceOpenRef.current = false;
handleInputPressEnter();
return;
}
setOpen(newOpen);
if (!newOpen) {
@@ -406,10 +425,18 @@ function CustomTimePicker({
const handleOpen = (e?: React.SyntheticEvent): void => {
e?.stopPropagation?.();
// If the popover is already open, avoid resetting the input value
// so that any in-progress edits are preserved.
if (open) {
return;
}
if (showLiveLogs) {
setOpen(true);
setSelectedTimePlaceholderValue('Live');
setInputValue('Live');
initialInputValueOnOpenRef.current = 'Live';
hasChangedSinceOpenRef.current = false;
return;
}
@@ -424,11 +451,21 @@ function CustomTimePicker({
.tz(timezone.value)
.format(DATE_TIME_FORMATS.UK_DATETIME_SECONDS);
setInputValue(`${startTime} - ${endTime}`);
const nextValue = `${startTime} - ${endTime}`;
setInputValue(nextValue);
initialInputValueOnOpenRef.current = nextValue;
hasChangedSinceOpenRef.current = false;
};
const handleClose = (e: React.MouseEvent): void => {
e.stopPropagation();
// If the value changed since opening, treat this like pressing Enter
if (hasChangedSinceOpenRef.current) {
hasChangedSinceOpenRef.current = false;
handleInputPressEnter();
return;
}
setOpen(false);
setCustomDTPickerVisible?.(false);
@@ -450,6 +487,9 @@ function CustomTimePicker({
}, [location.pathname]);
const handleInputBlur = (): void => {
// Track whether the value was changed since the input was opened for editing
hasChangedSinceOpenRef.current =
inputValue !== initialInputValueOnOpenRef.current;
resetErrorStatus();
};
@@ -552,6 +592,12 @@ function CustomTimePicker({
readOnly={!open || showLiveLogs}
placeholder={selectedTimePlaceholderValue}
value={inputValue}
onMouseDown={(e): void => {
// Only treat as "click from input" when the actual input element is clicked (not suffix/chevron)
if (e.target === inputRef.current?.input) {
isClickFromInputRef.current = true;
}
}}
onFocus={handleOpen}
onClick={handleOpen}
onChange={handleInputChange}

View File

@@ -3,16 +3,14 @@ import { UseQueryResult } from 'react-query';
import { Color } from '@signozhq/design-tokens';
import { Button, Card, Skeleton, Typography } from 'antd';
import cx from 'classnames';
import { useGetGraphCustomSeries } from 'components/CeleryTask/useGetGraphCustomSeries';
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
import Uplot from 'components/Uplot';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
getCustomFiltersForBarChart,
getFormattedEndPointStatusCodeChartData,
getStatusCodeBarChartWidgetData,
statusCodeWidgetInfo,
} from 'container/ApiMonitoring/utils';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
import { handleGraphClick } from 'container/GridCardLayout/GridCard/utils';
import { useGraphClickToShowButton } from 'container/GridCardLayout/useGraphClickToShowButton';
import useNavigateToExplorerPages from 'container/GridCardLayout/useNavigateToExplorerPages';
@@ -20,15 +18,16 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { useNotifications } from 'hooks/useNotifications';
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { getStartAndEndTimesInMilliseconds } from 'pages/MessagingQueues/MessagingQueuesUtils';
import { useTimezone } from 'providers/Timezone';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { Options } from 'uplot';
import ErrorState from './ErrorState';
import { prepareStatusCodeBarChartsConfig } from './utils';
function StatusCodeBarCharts({
endPointStatusCodeBarChartsDataQuery,
@@ -67,13 +66,6 @@ function StatusCodeBarCharts({
} = endPointStatusCodeLatencyBarChartsDataQuery;
const { startTime: minTime, endTime: maxTime } = timeRange;
const legendScrollPositionRef = useRef<{
scrollTop: number;
scrollLeft: number;
}>({
scrollTop: 0,
scrollLeft: 0,
});
const graphRef = useRef<HTMLDivElement>(null);
const dimensions = useResizeObserver(graphRef);
@@ -119,6 +111,7 @@ function StatusCodeBarCharts({
const navigateToExplorer = useNavigateToExplorer();
const { currentQuery } = useQueryBuilder();
const { timezone } = useTimezone();
const navigateToExplorerPages = useNavigateToExplorerPages();
const { notifications } = useNotifications();
@@ -134,12 +127,6 @@ function StatusCodeBarCharts({
[],
);
const { getCustomSeries } = useGetGraphCustomSeries({
isDarkMode,
drawStyle: 'bars',
colorMapping,
});
const widget = useMemo<Widgets>(
() =>
getStatusCodeBarChartWidgetData(domainName, {
@@ -193,49 +180,36 @@ function StatusCodeBarCharts({
],
);
const options = useMemo(
() =>
getUPlotChartOptions({
apiResponse:
currentWidgetInfoIndex === 0
? formattedEndPointStatusCodeBarChartsDataPayload
: formattedEndPointStatusCodeLatencyBarChartsDataPayload,
isDarkMode,
dimensions,
yAxisUnit: statusCodeWidgetInfo[currentWidgetInfoIndex].yAxisUnit,
softMax: null,
softMin: null,
minTimeScale: minTime,
maxTimeScale: maxTime,
panelType: PANEL_TYPES.BAR,
onClickHandler: graphClickHandler,
customSeries: getCustomSeries,
onDragSelect,
colorMapping,
query: currentQuery,
legendScrollPosition: legendScrollPositionRef.current,
setLegendScrollPosition: (position: {
scrollTop: number;
scrollLeft: number;
}) => {
legendScrollPositionRef.current = position;
},
}),
[
minTime,
maxTime,
currentWidgetInfoIndex,
dimensions,
formattedEndPointStatusCodeBarChartsDataPayload,
formattedEndPointStatusCodeLatencyBarChartsDataPayload,
const config = useMemo(() => {
const apiResponse =
currentWidgetInfoIndex === 0
? formattedEndPointStatusCodeBarChartsDataPayload
: formattedEndPointStatusCodeLatencyBarChartsDataPayload;
return prepareStatusCodeBarChartsConfig({
timezone,
isDarkMode,
graphClickHandler,
getCustomSeries,
query: currentQuery,
onDragSelect,
onClick: graphClickHandler,
apiResponse,
minTimeScale: minTime,
maxTimeScale: maxTime,
yAxisUnit: statusCodeWidgetInfo[currentWidgetInfoIndex].yAxisUnit,
colorMapping,
currentQuery,
],
);
});
}, [
currentQuery,
isDarkMode,
minTime,
maxTime,
graphClickHandler,
onDragSelect,
formattedEndPointStatusCodeBarChartsDataPayload,
formattedEndPointStatusCodeLatencyBarChartsDataPayload,
timezone,
currentWidgetInfoIndex,
colorMapping,
]);
const renderCardContent = useCallback(
(query: UseQueryResult<SuccessResponse<any>, unknown>): JSX.Element => {
@@ -253,11 +227,20 @@ function StatusCodeBarCharts({
!query.isLoading && !query?.data?.payload?.data?.result?.length,
})}
>
<Uplot options={options as Options} data={chartData} />
<BarChart
config={config}
data={chartData}
width={dimensions.width}
height={dimensions.height}
timezone={timezone}
legendConfig={{
position: LegendPosition.BOTTOM,
}}
/>
</div>
);
},
[options, chartData],
[config, chartData, dimensions, timezone],
);
return (

View File

@@ -0,0 +1,83 @@
import { ExecStats } from 'api/v5/v5';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { get } from 'lodash-es';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryData } from 'types/api/widgets/getQuery';
import { v4 as uuid } from 'uuid';
export const prepareStatusCodeBarChartsConfig = ({
timezone,
isDarkMode,
query,
onDragSelect,
onClick,
apiResponse,
minTimeScale,
maxTimeScale,
yAxisUnit,
colorMapping,
}: {
timezone: Timezone;
isDarkMode: boolean;
query: Query;
onDragSelect: (startTime: number, endTime: number) => void;
onClick?: OnClickPluginOpts['onClick'];
minTimeScale?: number;
maxTimeScale?: number;
apiResponse: MetricRangePayloadProps;
yAxisUnit?: string;
colorMapping?: Record<string, string>;
}): UPlotConfigBuilder => {
const stepIntervals: ExecStats['stepIntervals'] = get(
apiResponse,
'data.newResult.meta.stepIntervals',
{},
);
const minStepInterval = Math.min(...Object.values(stepIntervals));
const config = buildBaseConfig({
id: uuid(),
yAxisUnit: yAxisUnit,
apiResponse,
isDarkMode,
onDragSelect,
timezone,
onClick,
minTimeScale,
maxTimeScale,
stepInterval: minStepInterval,
panelType: PANEL_TYPES.BAR,
});
const seriesList: QueryData[] = apiResponse?.data?.result || [];
seriesList.forEach((series) => {
const baseLabelName = getLabelName(
series.metric,
series.queryName || '', // query
series.legend || '',
);
const label = query ? getLegend(series, query, baseLabelName) : baseLabelName;
const currentStepInterval = get(stepIntervals, series.queryName, undefined);
config.addSeries({
scaleKey: 'y',
drawStyle: DrawStyle.Bar,
label: label,
colorMapping: colorMapping ?? {},
isDarkMode,
stepInterval: currentStepInterval,
});
});
return config;
};

View File

@@ -21,10 +21,15 @@ interface MockQueryResult {
}
// Mocks
jest.mock('components/Uplot', () => ({
__esModule: true,
default: jest.fn().mockImplementation(() => <div data-testid="uplot-mock" />),
}));
jest.mock(
'container/DashboardContainer/visualization/charts/BarChart/BarChart',
() => ({
__esModule: true,
default: jest
.fn()
.mockImplementation(() => <div data-testid="bar-chart-mock" />),
}),
);
jest.mock('components/CeleryTask/useGetGraphCustomSeries', () => ({
useGetGraphCustomSeries: (): { getCustomSeries: jest.Mock } => ({
@@ -70,6 +75,24 @@ jest.mock('hooks/useNotifications', () => ({
useNotifications: (): { notifications: [] } => ({ notifications: [] }),
}));
jest.mock('providers/Timezone', () => ({
useTimezone: (): {
timezone: {
name: string;
value: string;
offset: string;
searchIndex: string;
};
} => ({
timezone: {
name: 'UTC',
value: 'UTC',
offset: '+00:00',
searchIndex: 'UTC',
},
}),
}));
jest.mock('lib/uPlotLib/getUplotChartOptions', () => ({
getUPlotChartOptions: jest.fn().mockReturnValue({}),
}));
@@ -319,7 +342,7 @@ describe('StatusCodeBarCharts', () => {
mockData.payload,
'sum',
);
expect(screen.getByTestId('uplot-mock')).toBeInTheDocument();
expect(screen.getByTestId('bar-chart-mock')).toBeInTheDocument();
expect(screen.getByText('Number of calls')).toBeInTheDocument();
expect(screen.getByText('Latency')).toBeInTheDocument();
});

View File

@@ -10,7 +10,15 @@ import { useBarChartStacking } from '../../hooks/useBarChartStacking';
import { BarChartProps } from '../types';
export default function BarChart(props: BarChartProps): JSX.Element {
const { children, isStackedBarChart, config, data, ...rest } = props;
const {
children,
isStackedBarChart,
customTooltip,
config,
data,
pinnedTooltipElement,
...rest
} = props;
const chartData = useBarChartStacking({
data,
@@ -20,6 +28,9 @@ export default function BarChart(props: BarChartProps): JSX.Element {
const renderTooltip = useCallback(
(props: TooltipRenderArgs): React.ReactNode => {
if (customTooltip) {
return customTooltip(props);
}
const tooltipProps: BarTooltipProps = {
...props,
timezone: rest.timezone,
@@ -29,7 +40,13 @@ export default function BarChart(props: BarChartProps): JSX.Element {
};
return <BarChartTooltip {...tooltipProps} />;
},
[rest.timezone, rest.yAxisUnit, rest.decimalPrecision, isStackedBarChart],
[
customTooltip,
rest.timezone,
rest.yAxisUnit,
rest.decimalPrecision,
isStackedBarChart,
],
);
return (
@@ -37,7 +54,8 @@ export default function BarChart(props: BarChartProps): JSX.Element {
{...rest}
config={config}
data={chartData}
renderTooltip={renderTooltip}
customTooltip={renderTooltip}
pinnedTooltipElement={pinnedTooltipElement}
>
{children}
</ChartWrapper>

View File

@@ -30,7 +30,8 @@ export default function ChartWrapper({
onDestroy = noop,
children,
layoutChildren,
renderTooltip,
customTooltip,
pinnedTooltipElement,
'data-testid': testId,
}: ChartProps): JSX.Element {
const plotInstanceRef = useRef<uPlot | null>(null);
@@ -53,12 +54,12 @@ export default function ChartWrapper({
const renderTooltipCallback = useCallback(
(args: TooltipRenderArgs): React.ReactNode => {
if (renderTooltip) {
return renderTooltip(args);
if (customTooltip) {
return customTooltip(args);
}
return null;
},
[renderTooltip],
[customTooltip],
);
return (
@@ -99,6 +100,7 @@ export default function ChartWrapper({
)}
syncKey={syncKey}
render={renderTooltipCallback}
pinnedTooltipElement={pinnedTooltipElement}
/>
)}
</UPlotChart>

View File

@@ -11,15 +11,16 @@ import { HistogramChartProps } from '../types';
export default function Histogram(props: HistogramChartProps): JSX.Element {
const {
children,
renderTooltip: customRenderTooltip,
customTooltip,
isQueriesMerged,
pinnedTooltipElement,
...rest
} = props;
const renderTooltip = useCallback(
(props: TooltipRenderArgs): React.ReactNode => {
if (customRenderTooltip) {
return customRenderTooltip(props);
if (customTooltip) {
return customTooltip(props);
}
const tooltipProps: HistogramTooltipProps = {
...props,
@@ -29,14 +30,15 @@ export default function Histogram(props: HistogramChartProps): JSX.Element {
};
return <HistogramTooltip {...tooltipProps} />;
},
[customRenderTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
[customTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
);
return (
<ChartWrapper
showLegend={!isQueriesMerged}
{...rest}
renderTooltip={renderTooltip}
customTooltip={renderTooltip}
pinnedTooltipElement={pinnedTooltipElement}
>
{children}
</ChartWrapper>

View File

@@ -9,12 +9,12 @@ import {
import { TimeSeriesChartProps } from '../types';
export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
const { children, renderTooltip: customRenderTooltip, ...rest } = props;
const { children, customTooltip, pinnedTooltipElement, ...rest } = props;
const renderTooltip = useCallback(
(props: TooltipRenderArgs): React.ReactNode => {
if (customRenderTooltip) {
return customRenderTooltip(props);
if (customTooltip) {
return customTooltip(props);
}
const tooltipProps: TimeSeriesTooltipProps = {
...props,
@@ -24,11 +24,15 @@ export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
};
return <TimeSeriesTooltip {...tooltipProps} />;
},
[customRenderTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
[customTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
);
return (
<ChartWrapper {...rest} renderTooltip={renderTooltip}>
<ChartWrapper
{...rest}
customTooltip={renderTooltip}
pinnedTooltipElement={pinnedTooltipElement}
>
{children}
</ChartWrapper>
);

View File

@@ -2,7 +2,10 @@ import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PrecisionOption } from 'components/Graph/types';
import { LegendConfig, TooltipRenderArgs } from 'lib/uPlotV2/components/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import {
DashboardCursorSync,
TooltipClickData,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
interface BaseChartProps {
width: number;
@@ -13,7 +16,8 @@ interface BaseChartProps {
canPinTooltip?: boolean;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
renderTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
pinnedTooltipElement?: (clickData: TooltipClickData) => React.ReactNode;
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
'data-testid'?: string;
}
interface UPlotBasedChartProps {

View File

@@ -66,7 +66,7 @@ export const prepareUPlotConfig = ({
widget: Widgets;
isDarkMode: boolean;
currentQuery: Query;
onClick: OnClickPluginOpts['onClick'];
onClick?: OnClickPluginOpts['onClick'];
onDragSelect: (startTime: number, endTime: number) => void;
apiResponse: MetricRangePayloadProps;
timezone: Timezone;

View File

@@ -92,6 +92,7 @@ function renderTooltip(props: Partial<TooltipTestProps> = {}): RenderResult {
isPinned: false,
dismiss: jest.fn(),
viaSync: false,
clickData: null,
} as TooltipTestProps;
return render(

View File

@@ -1,6 +1,7 @@
import { useLayoutEffect, useRef, useState } from 'react';
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import cx from 'classnames';
import { getFocusedSeriesAtPosition } from 'lib/uPlotLib/plugins/onClickPlugin';
import uPlot from 'uplot';
import {
@@ -15,6 +16,7 @@ import {
} from './tooltipController';
import {
DashboardCursorSync,
TooltipClickData,
TooltipControllerContext,
TooltipControllerState,
TooltipLayoutInfo,
@@ -38,6 +40,7 @@ export default function TooltipPlugin({
maxHeight = 400,
syncMode = DashboardCursorSync.None,
syncKey = '_tooltip_sync_global_',
pinnedTooltipElement,
canPinTooltip = false,
}: TooltipPluginProps): JSX.Element | null {
const containerRef = useRef<HTMLDivElement>(null);
@@ -73,11 +76,6 @@ export default function TooltipPlugin({
layoutRef.current?.observer.disconnect();
layoutRef.current = createLayoutObserver(layoutRef);
/**
* Plot lifecycle and GC: viewState uses hasPlot (boolean), not the plot
* reference; clearPlotReferences runs in cleanup so
* detached canvases can be garbage collected.
*/
// Controller holds the mutable interaction state for this tooltip
// instance. It is intentionally *not* React state so uPlot hooks
// and DOM listeners can update it freely without triggering a
@@ -85,7 +83,9 @@ export default function TooltipPlugin({
const controller: TooltipControllerState = createInitialControllerState();
/**
* Clear plot references so detached canvases can be garbage collected.
* Plot lifecycle and GC: viewState uses hasPlot (boolean), not the plot
* reference; clearPlotReferences runs in cleanup so
* detached canvases can be garbage collected.
*/
const clearPlotReferences = (): void => {
controller.plot = null;
@@ -157,6 +157,7 @@ export default function TooltipPlugin({
const isPinnedBeforeDismiss = controller.pinned;
controller.pinned = false;
controller.hoverActive = false;
controller.clickData = null;
const plot = getPlot(controller);
if (plot) {
plot.setCursor({ left: -10, top: -10 });
@@ -196,6 +197,7 @@ export default function TooltipPlugin({
style: controller.style,
isPinned: controller.pinned,
isHovering: controller.hoverActive,
clickData: controller.clickData,
contents: createTooltipContents(),
dismiss: dismissTooltip,
});
@@ -268,6 +270,37 @@ export default function TooltipPlugin({
!controller.pinned &&
controller.focusedSeriesIndex != null
) {
const xValue = plot.posToVal(event.offsetX, 'x');
const yValue = plot.posToVal(event.offsetY, 'y');
const focusedSeries = getFocusedSeriesAtPosition(event, plot);
let clickedDataTimestamp = xValue;
if (focusedSeries) {
const dataIndex = plot.posToIdx(event.offsetX);
const xSeriesData = plot.data[0];
if (
xSeriesData &&
dataIndex >= 0 &&
dataIndex < xSeriesData.length &&
xSeriesData[dataIndex] !== undefined
) {
clickedDataTimestamp = xSeriesData[dataIndex];
}
}
const clickData: TooltipClickData = {
xValue,
yValue,
focusedSeries,
clickedDataTimestamp,
mouseX: event.offsetX,
mouseY: event.offsetY,
absoluteMouseX: event.clientX,
absoluteMouseY: event.clientY,
};
controller.clickData = clickData;
setTimeout(() => {
controller.pinned = true;
scheduleRender(true);
@@ -374,6 +407,25 @@ export default function TooltipPlugin({
}
}, [isHovering, hasPlot]);
const tooltipBody = useMemo(() => {
if (isPinned && pinnedTooltipElement != null && viewState.clickData != null) {
return pinnedTooltipElement(viewState.clickData);
}
if (isHovering) {
return contents;
}
return null;
}, [
isPinned,
pinnedTooltipElement,
viewState.clickData,
isHovering,
contents,
]);
const isTooltipVisible = isHovering || tooltipBody != null;
if (!hasPlot) {
return null;
}
@@ -382,7 +434,7 @@ export default function TooltipPlugin({
<div
className={cx('tooltip-plugin-container', {
pinned: isPinned,
visible: isHovering,
visible: isTooltipVisible,
})}
style={{
...style,
@@ -391,11 +443,11 @@ export default function TooltipPlugin({
}}
aria-live="polite"
aria-atomic="true"
aria-hidden={!isHovering}
aria-hidden={!isTooltipVisible}
ref={containerRef}
data-testid="tooltip-plugin-container"
>
{isHovering ? contents : null}
{tooltipBody}
</div>,
portalRoot.current,
);

View File

@@ -21,6 +21,7 @@ export function createInitialControllerState(): TooltipControllerState {
hoverActive: false,
isAnySeriesActive: false,
pinned: false,
clickData: null,
style: { transform: '', pointerEvents: 'none' },
horizontalOffset: 0,
verticalOffset: 0,

View File

@@ -24,6 +24,7 @@ export interface TooltipViewState {
isHovering: boolean;
isPinned: boolean;
dismiss: () => void;
clickData: TooltipClickData | null;
contents?: ReactNode;
}
@@ -39,10 +40,27 @@ export interface TooltipPluginProps {
syncMode?: DashboardCursorSync;
syncKey?: string;
render: (args: TooltipRenderArgs) => ReactNode;
pinnedTooltipElement?: (clickData: TooltipClickData) => ReactNode;
maxWidth?: number;
maxHeight?: number;
}
export interface TooltipClickData {
xValue: number;
yValue: number;
focusedSeries: {
seriesIndex: number;
seriesName: string;
value: number;
color: string;
} | null;
clickedDataTimestamp: number;
mouseX: number;
mouseY: number;
absoluteMouseX: number;
absoluteMouseY: number;
}
/**
* Mutable, non-React state that drives tooltip behaviour:
* - whether the tooltip is active / pinned
@@ -59,6 +77,7 @@ export interface TooltipControllerState {
hoverActive: boolean;
isAnySeriesActive: boolean;
pinned: boolean;
clickData: TooltipClickData | null;
style: TooltipViewState['style'];
horizontalOffset: number;
verticalOffset: number;

View File

@@ -125,6 +125,7 @@ export function createInitialViewState(): TooltipViewState {
contents: null,
hasPlot: false,
dismiss: (): void => {},
clickData: null,
};
}

View File

@@ -9,6 +9,12 @@ import { UPlotConfigBuilder } from '../../config/UPlotConfigBuilder';
import TooltipPlugin from '../TooltipPlugin/TooltipPlugin';
import { DashboardCursorSync } from '../TooltipPlugin/types';
// Avoid depending on the full uPlot + onClickPlugin behaviour in these tests.
// We only care that pinning logic runs without throwing, not which series is focused.
jest.mock('lib/uPlotLib/plugins/onClickPlugin', () => ({
getFocusedSeriesAtPosition: jest.fn(() => null),
}));
// ---------------------------------------------------------------------------
// Mock helpers
// ---------------------------------------------------------------------------
@@ -55,11 +61,21 @@ function createFakePlot(): {
over: HTMLDivElement;
setCursor: jest.Mock<void, [uPlot.Cursor]>;
cursor: { event: Record<string, unknown> };
posToVal: jest.Mock<number, [value: number]>;
posToIdx: jest.Mock<number, []>;
data: [number[], number[]];
} {
const over = document.createElement('div');
// Provide the minimal uPlot surface used by TooltipPlugin's pin logic.
return {
over: document.createElement('div'),
over,
setCursor: jest.fn(),
cursor: { event: {} },
// In real uPlot these map overlay coordinates to data-space values.
posToVal: jest.fn((value: number) => value),
posToIdx: jest.fn(() => 0),
data: [[0], [0]],
};
}
@@ -198,6 +214,34 @@ describe('TooltipPlugin', () => {
});
});
it('renders pinnedTooltipElement after pinning and hides hover content', async () => {
const config = createConfigMock();
const pinnedTooltipElement = jest.fn(() =>
React.createElement('div', null, 'pinned-tooltip'),
);
const fakePlot = renderAndActivateHover(
config,
() => React.createElement('div', null, 'hover-tooltip'),
{
canPinTooltip: true,
pinnedTooltipElement,
},
);
expect(screen.getByText('hover-tooltip')).toBeInTheDocument();
act(() => {
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
await waitFor(() => {
expect(pinnedTooltipElement).toHaveBeenCalled();
expect(screen.getByText('pinned-tooltip')).toBeInTheDocument();
expect(screen.queryByText('hover-tooltip')).not.toBeInTheDocument();
});
});
it('dismisses a pinned tooltip via the dismiss callback', async () => {
const config = createConfigMock();

View File

@@ -237,11 +237,21 @@ func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
return nil, errors.WrapInternalf(promErr, errors.CodeInternal, "error getting matrix from promql query %q", query)
}
excludeLabel := func(labelName string) bool {
if labelName == "__name__" {
return false
}
return strings.HasPrefix(labelName, "__") || labelName == "fingerprint"
}
var series []*qbv5.TimeSeries
for _, v := range matrix {
var s qbv5.TimeSeries
lbls := make([]*qbv5.Label, 0, len(v.Metric))
for name, value := range v.Metric.Copy().Map() {
if excludeLabel(name) {
continue
}
lbls = append(lbls, &qbv5.Label{
Key: telemetrytypes.TelemetryFieldKey{Name: name},
Value: value,