Compare commits

..

2 Commits

Author SHA1 Message Date
Naman Verma
cb9c9db6b1 fix: resolve aggregate column for exp histograms before samples table 2026-08-21 11:27:15 +05:30
Naman Verma
24596ef470 test: add fixtures and tests for exponential histograms 2026-08-21 11:08:43 +05:30
85 changed files with 1503 additions and 1703 deletions

View File

@@ -5,7 +5,6 @@ import BarChart from 'container/DashboardContainer/visualization/charts/BarChart
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
import { StackMode } from 'lib/uPlotV2/config/types';
import {
LegendPosition,
TooltipRenderArgs,
@@ -132,9 +131,9 @@ export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element {
<div ref={graphRef} className={styles.graphContainer}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<BarChart
stack={StackMode.Normal}
config={config}
data={chartData}
isStackedBarChart
legendConfig={{ position: LegendPosition.BOTTOM }}
customTooltip={renderBillingTooltip}
width={containerDimensions.width}

View File

@@ -58,17 +58,26 @@ describe('prepareBillingBarConfig', () => {
expect(config.series?.[4]?.stroke).toBe(Color.BG_AMBER_500);
});
it('sets padding and focus alpha for behavioral parity', () => {
it('sets stacking bands, padding, and focus alpha for behavioral parity', () => {
const builder = prepareBillingBarConfig({
...baseProps,
apiResponse: makeApiResponse(['Logs', 'Traces', 'Metrics']),
});
const config = builder.getConfig();
// Stacking bands come from the chart now — see useChartStacking.
expect(config.bands).toStrictEqual([{ series: [1, 2] }, { series: [2, 3] }]);
expect(config.padding).toStrictEqual([32, 32, 16, 16]);
expect(config.focus).toStrictEqual({ alpha: 0.3 });
});
it('sets no bands when result is empty', () => {
const builder = prepareBillingBarConfig({
...baseProps,
apiResponse: makeApiResponse([]),
});
const config = builder.getConfig();
expect(config.bands).toBeUndefined();
});
it('uses queryName as label when legend is undefined', () => {
const apiResponse: MetricRangePayloadProps = {
data: {

View File

@@ -1,6 +1,7 @@
import { Color } from '@signozhq/design-tokens';
import type { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import type { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
@@ -62,6 +63,7 @@ export function prepareBillingBarConfig({
});
});
builder.setBands(getInitialStackedBands(results.length));
builder.setPadding([32, 32, 16, 16]);
builder.setFocus({ alpha: 0.3 });

View File

@@ -6,24 +6,25 @@ import {
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import { StackMode } from 'lib/uPlotV2/config/types';
import { useBarChartStacking } from '../../hooks/useBarChartStacking';
import { BarChartProps } from '../types';
export default function BarChart(props: BarChartProps): JSX.Element {
const {
children,
isStackedBarChart,
customTooltip,
config,
data,
stack = StackMode.None,
pinnedTooltipElement,
...rest
} = props;
// Written during render so it lands before UPlotChart's effect reads the config,
// which derives the fill bands, percent axis unit and percent range from it.
config.setStackMode(stack);
const chartData = useBarChartStacking({
data,
isStackedBarChart,
config,
});
const renderTooltip = useCallback(
(props: TooltipRenderArgs): React.ReactNode => {
@@ -36,6 +37,7 @@ export default function BarChart(props: BarChartProps): JSX.Element {
timezone: rest.timezone,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
isStackedBarChart: isStackedBarChart,
canPinTooltip: rest.canPinTooltip,
renderTooltipFooter: rest.renderTooltipFooter,
};
@@ -46,6 +48,7 @@ export default function BarChart(props: BarChartProps): JSX.Element {
rest.timezone,
rest.yAxisUnit,
rest.decimalPrecision,
isStackedBarChart,
rest.canPinTooltip,
rest.renderTooltipFooter,
],
@@ -55,7 +58,7 @@ export default function BarChart(props: BarChartProps): JSX.Element {
<ChartWrapper
{...rest}
config={config}
data={data}
data={chartData}
customTooltip={renderTooltip}
pinnedTooltipElement={pinnedTooltipElement}
>

View File

@@ -6,15 +6,12 @@ import {
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import UPlotChart from 'lib/uPlotV2/components/UPlotChart/UPlotChart';
import { StackMode } from 'lib/uPlotV2/config/types';
import { prepareAlignedData } from 'lib/uPlotV2/components/UPlotChart/utils';
import { PlotContextProvider } from 'lib/uPlotV2/context/PlotContext';
import TooltipPlugin from 'lib/uPlotV2/plugins/TooltipPlugin/TooltipPlugin';
import noop from 'lodash-es/noop';
import uPlot from 'uplot';
import { ChartWrapperProps } from '../types';
import { useChartStacking } from './useChartStacking';
import { ChartProps } from '../types';
const TOOLTIP_WIDTH_PADDING = 120;
const TOOLTIP_MIN_WIDTH = 300;
@@ -42,20 +39,9 @@ export default function ChartWrapper({
pinnedTooltipElement,
tooltipPortalRoot,
'data-testid': testId,
}: ChartWrapperProps): JSX.Element {
}: ChartProps): JSX.Element {
const plotInstanceRef = useRef<uPlot | null>(null);
const stack = config.getStackMode();
const chartData = useChartStacking({ data, config });
// Tooltips need pre-stack values, gap-processed exactly as UPlotChart processes the
// plot data — otherwise the cursor's index addresses a shorter array.
const unstackedData = useMemo(
() =>
stack === StackMode.None ? undefined : prepareAlignedData({ data, config }),
[data, config, stack],
);
const legendComponent = useCallback(
(averageLegendWidth: number): React.ReactNode => {
if (!showLegend) {
@@ -75,11 +61,11 @@ export default function ChartWrapper({
const renderTooltipCallback = useCallback(
(args: TooltipRenderArgs): React.ReactNode => {
if (customTooltip) {
return customTooltip({ ...args, unstackedData });
return customTooltip(args);
}
return null;
},
[customTooltip, unstackedData],
[customTooltip],
);
const syncMetadata = useMemo(
@@ -105,7 +91,7 @@ export default function ChartWrapper({
{({ chartWidth, chartHeight, averageLegendWidth }): JSX.Element => (
<UPlotChart
config={config}
data={chartData}
data={data}
width={chartWidth}
height={chartHeight}
plotRef={(plot): void => {

View File

@@ -1,98 +0,0 @@
import { renderHook } from '@testing-library/react';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { StackMode } from 'lib/uPlotV2/config/types';
import uPlot from 'uplot';
import { useChartStacking } from '../useChartStacking';
type Hooks = Record<string, (...args: unknown[]) => void>;
function createConfig(stack: StackMode): {
config: UPlotConfigBuilder;
hooks: Hooks;
} {
const hooks: Hooks = {};
const config = {
getStackMode: (): StackMode => stack,
addHook: jest.fn((type: string, hook: (...args: unknown[]) => void) => {
hooks[type] = hook;
return jest.fn();
}),
} as unknown as UPlotConfigBuilder;
return { config, hooks };
}
const data = [[1], [30], [10]] as unknown as uPlot.AlignedData;
describe('useChartStacking', () => {
it('returns the data untouched and registers nothing when the config says `none`', () => {
const { config } = createConfig(StackMode.None);
const { result } = renderHook(() => useChartStacking({ data, config }));
expect(result.current).toBe(data);
expect(config.addHook).not.toHaveBeenCalled();
});
it('treats a missing config as unstacked', () => {
const { result } = renderHook(() => useChartStacking({ data, config: null }));
expect(result.current).toBe(data);
});
it('accumulates raw values when the config declares `normal`', () => {
const { config } = createConfig(StackMode.Normal);
const { result } = renderHook(() => useChartStacking({ data, config }));
expect(result.current).toStrictEqual([[1], [40], [10]]);
});
it('rescales each column to its total when the config declares `percent`', () => {
const { config } = createConfig(StackMode.Percent);
const { result } = renderHook(() => useChartStacking({ data, config }));
expect(result.current).toStrictEqual([[1], [100], [25]]);
});
it('registers the uPlot hooks that re-stack on data and visibility changes', () => {
const { config } = createConfig(StackMode.Normal);
renderHook(() => useChartStacking({ data, config }));
expect(
(config.addHook as jest.Mock).mock.calls.map(([type]) => type),
).toStrictEqual(['setData', 'setSeries']);
});
it('re-stacks from the raw values when the legend hides a series', () => {
const { config, hooks } = createConfig(StackMode.Normal);
renderHook(() => useChartStacking({ data, config }));
const plot = {
data: [[1]],
series: [{}, { show: true }, { show: false }],
delBand: jest.fn(),
addBand: jest.fn(),
setData: jest.fn(),
};
hooks.setSeries(plot, 2, { show: false });
// The hidden series keeps its raw value and stops contributing to the total.
expect(plot.setData).toHaveBeenCalledWith([[1], [30], [10]]);
expect(plot.delBand).toHaveBeenCalledWith(null);
});
it('ignores a focus-only setSeries so hovering does not re-stack', () => {
const { config, hooks } = createConfig(StackMode.Normal);
renderHook(() => useChartStacking({ data, config }));
const plot = {
data: [[1]],
series: [{}, { show: true }, { show: true }],
delBand: jest.fn(),
addBand: jest.fn(),
setData: jest.fn(),
};
hooks.setSeries(plot, 1, { focus: true });
expect(plot.setData).not.toHaveBeenCalled();
});
});

View File

@@ -1,132 +0,0 @@
import {
MutableRefObject,
useCallback,
useLayoutEffect,
useMemo,
useRef,
} from 'react';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { StackMode } from 'lib/uPlotV2/config/types';
import { has } from 'lodash-es';
import uPlot from 'uplot';
import { stackSeries } from '../utils/stackSeriesUtils';
/** Returns true if the series at the given index is hidden (e.g. via legend toggle). */
function isSeriesHidden(plot: uPlot, seriesIndex: number): boolean {
return !plot.series[seriesIndex]?.show;
}
function canApplyStacking(
unstackedData: uPlot.AlignedData | null,
plot: uPlot,
isUpdating: boolean,
): boolean {
return (
!isUpdating &&
!!unstackedData &&
!!plot.data &&
unstackedData[0]?.length === plot.data[0]?.length
);
}
function setupStackingHooks(
config: UPlotConfigBuilder,
updateStacksInChart: (plot: uPlot) => void,
isUpdatingRef: MutableRefObject<boolean>,
): () => void {
const onDataChange = (plot: uPlot): void => {
if (!isUpdatingRef.current) {
updateStacksInChart(plot);
}
};
const onSeriesVisibilityChange = (
plot: uPlot,
_seriesIdx: number | null,
opts: uPlot.Series,
): void => {
// uPlot fires setSeries for hover focus too; only visibility changes restack.
if (!has(opts, 'focus')) {
updateStacksInChart(plot);
}
};
const removeSetDataHook = config.addHook('setData', onDataChange);
const removeSetSeriesHook = config.addHook(
'setSeries',
onSeriesVisibilityChange,
);
return (): void => {
removeSetDataHook?.();
removeSetSeriesHook?.();
};
}
export interface UseChartStackingParams {
data: uPlot.AlignedData;
config: UPlotConfigBuilder | null;
}
/**
* Stacks a chart's data for the mode declared on its config, and re-stacks on data or
* visibility changes. The pre-stack values live in a ref because the uPlot hooks that
* read them run outside React's render cycle.
*/
export function useChartStacking({
data,
config,
}: UseChartStackingParams): uPlot.AlignedData {
const stack = config?.getStackMode() ?? StackMode.None;
const unstackedDataRef = useRef<uPlot.AlignedData | null>(null);
unstackedDataRef.current = stack === 'none' ? null : data;
// Guards the re-entrant setData below, which would otherwise re-trigger our own hook.
const isUpdatingChartRef = useRef(false);
const chartData = useMemo((): uPlot.AlignedData => {
if (stack === StackMode.None || !data || data.length < 2) {
return data;
}
const noSeriesHidden = (): boolean => false; // include all series in initial stack
return stackSeries(data, noSeriesHidden, stack).data;
}, [data, stack]);
const updateStacksInChart = useCallback(
(plot: uPlot): void => {
const unstacked = unstackedDataRef.current;
if (
!unstacked ||
!canApplyStacking(unstacked, plot, isUpdatingChartRef.current)
) {
return;
}
const shouldExcludeSeries = (idx: number): boolean =>
isSeriesHidden(plot, idx);
const { data: stacked, bands } = stackSeries(
unstacked,
shouldExcludeSeries,
stack,
);
plot.delBand(null);
bands.forEach((band: uPlot.Band) => plot.addBand(band));
isUpdatingChartRef.current = true;
plot.setData(stacked);
isUpdatingChartRef.current = false;
},
[stack],
);
useLayoutEffect(() => {
if (stack === StackMode.None || !config) {
return undefined;
}
return setupStackingHooks(config, updateStacksInChart, isUpdatingChartRef);
}, [stack, config, updateStacksInChart]);
return chartData;
}

View File

@@ -6,16 +6,10 @@ import {
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import { StackMode } from 'lib/uPlotV2/config/types';
import { TimeSeriesChartProps } from '../types';
export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
const { children, customTooltip, stack = StackMode.None, ...rest } = props;
// Written during render so it lands before UPlotChart's effect reads the config,
// which derives the fill bands, percent axis unit and percent range from it.
rest.config.setStackMode(stack);
const { children, customTooltip, ...rest } = props;
const renderTooltip = useCallback(
(props: TooltipRenderArgs): React.ReactNode => {

View File

@@ -14,7 +14,6 @@ import {
ChartClickData,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type { StackMode } from 'lib/uPlotV2/config/types';
interface BaseChartProps {
width: number;
@@ -53,26 +52,27 @@ interface UPlotChartDataProps {
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
}
/** Everything the shared uPlot shell consumes; each chart's props narrow it. */
export interface ChartWrapperProps
extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps {}
export interface TimeSeriesChartProps extends ChartWrapperProps {
export interface TimeSeriesChartProps
extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps {
timezone?: Timezone;
/** How series compose. Defaults to `none`, which draws them independently. */
stack?: StackMode;
}
export interface BarChartProps extends ChartWrapperProps {
timezone?: Timezone;
/** How series compose. Defaults to `none`, which draws them independently. */
stack?: StackMode;
}
export interface HistogramChartProps extends ChartWrapperProps {
export interface HistogramChartProps
extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps {
isQueriesMerged?: boolean;
}
export interface BarChartProps
extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps {
isStackedBarChart?: boolean;
timezone?: Timezone;
}
export type ChartProps =
| TimeSeriesChartProps
| BarChartProps
| HistogramChartProps;
/**
* One resolved pie/donut slice: a display label, its (already parsed) positive
* numeric value, and the colour used for the arc + legend swatch.

View File

@@ -1,158 +0,0 @@
import { AlignedData } from 'uplot';
import { StackMode } from 'lib/uPlotV2/config/types';
import { stackSeries } from '../stackSeriesUtils';
const includeAll = (): boolean => false;
// Stacking is top-down: the first series carries the column total, the last its own
// raw value. Every expectation below reads in that order.
describe('stackSeries', () => {
it('is a no-op under `none`, returning the data and no bands', () => {
const data: AlignedData = [[1], [30], [10]];
const { data: result, bands } = stackSeries(data, includeAll, StackMode.None);
expect(result).toBe(data);
expect(bands).toStrictEqual([]);
});
describe('normal', () => {
it('accumulates raw values from the bottom series upward', () => {
const data: AlignedData = [
[1, 2],
[10, 20],
[1, 2],
];
expect(stackSeries(data, includeAll, StackMode.Normal).data).toStrictEqual([
[1, 2],
[11, 22],
[1, 2],
]);
});
it('treats nulls as 0 without breaking the running total', () => {
const data: AlignedData = [
[1, 2],
[10, null],
[1, 2],
];
expect(stackSeries(data, includeAll, StackMode.Normal).data).toStrictEqual([
[1, 2],
[11, 2],
[1, 2],
]);
});
it('emits one band per adjacent pair of participating series', () => {
const data: AlignedData = [[1], [10], [5], [1]];
expect(stackSeries(data, includeAll, StackMode.Normal).bands).toStrictEqual([
{ series: [1, 2] },
{ series: [2, 3] },
]);
});
it('copies omitted series through unstacked and skips their bands', () => {
const data: AlignedData = [[1], [10], [5], [1]];
const omitMiddle = (seriesIndex: number): boolean => seriesIndex === 2;
const { data: stacked, bands } = stackSeries(
data,
omitMiddle,
StackMode.Normal,
);
expect(stacked).toStrictEqual([[1], [11], [5], [1]]);
expect(bands).toStrictEqual([{ series: [1, 3] }]);
});
});
describe('percent', () => {
it('rescales each column to its total so the top series reads 100', () => {
const data: AlignedData = [
[1, 2],
[30, 10],
[10, 10],
];
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
[1, 2],
[100, 100],
[25, 50],
]);
});
it('normalises per column, so an identical series differs across x', () => {
const data: AlignedData = [
[1, 2],
[1, 3],
[1, 1],
];
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
[1, 2],
[100, 100],
[50, 25],
]);
});
it('excludes omitted series from the total, so the visible ones still reach 100', () => {
const data: AlignedData = [[1], [30], [10], [60]];
const omitLast = (seriesIndex: number): boolean => seriesIndex === 3;
expect(stackSeries(data, omitLast, StackMode.Percent).data).toStrictEqual([
[1],
[100],
[25],
[60],
]);
});
it('yields 0 for a column whose participating series sum to zero', () => {
const data: AlignedData = [
[1, 2],
[0, 5],
[0, 5],
];
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
[1, 2],
[0, 100],
[0, 50],
]);
});
it('divides by the signed total when a column mixes signs', () => {
// 30 + (-10) = 20, so the shares are 150% and -50% and still sum to 100.
const data: AlignedData = [[1], [30], [-10]];
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
[1],
[100],
[-50],
]);
});
it('yields 0 across a column whose signed total cancels to zero', () => {
const data: AlignedData = [[1], [10], [-10]];
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
[1],
[0],
[0],
]);
});
});
it('defaults to normal when no mode is given', () => {
const data: AlignedData = [[1], [30], [10]];
expect(stackSeries(data, includeAll).data).toStrictEqual(
stackSeries(data, includeAll, StackMode.Normal).data,
);
});
});

View File

@@ -0,0 +1,117 @@
import { AlignedData } from 'uplot';
import { getInitialStackedBands, stack } from '../stackUtils';
describe('stackUtils', () => {
describe('stack', () => {
const neverOmit = (): boolean => false;
it('preserves time axis as first row', () => {
const data: AlignedData = [
[100, 200, 300],
[1, 2, 3],
[4, 5, 6],
];
const { data: result } = stack(data, neverOmit);
expect(result[0]).toStrictEqual([100, 200, 300]);
});
it('stacks value series cumulatively (last = raw, first = total)', () => {
// Time, then 3 value series. Stack order: last series stays raw, then we add upward.
const data: AlignedData = [
[0, 1, 2],
[1, 2, 3], // series 1
[4, 5, 6], // series 2
[7, 8, 9], // series 3
];
const { data: result } = stack(data, neverOmit);
// result[1] = s1+s2+s3, result[2] = s2+s3, result[3] = s3
expect(result[1]).toStrictEqual([12, 15, 18]); // 1+4+7, 2+5+8, 3+6+9
expect(result[2]).toStrictEqual([11, 13, 15]); // 4+7, 5+8, 6+9
expect(result[3]).toStrictEqual([7, 8, 9]);
});
it('treats null values as 0 when stacking', () => {
const data: AlignedData = [
[0, 1],
[1, null],
[null, 10],
];
const { data: result } = stack(data, neverOmit);
expect(result[1]).toStrictEqual([1, 10]); // total
expect(result[2]).toStrictEqual([0, 10]); // last series with null→0
});
it('copies omitted series as-is without accumulating', () => {
// Omit series 2 (index 2); series 1 and 3 are stacked.
const data: AlignedData = [
[0, 1],
[10, 20], // series 1
[100, 200], // series 2 - omitted
[1, 2], // series 3
];
const omitSeries2 = (i: number): boolean => i === 2;
const { data: result } = stack(data, omitSeries2);
// series 3 raw: [1, 2]; series 2 omitted: [100, 200] as-is; series 1 stacked with s3: [11, 22]
expect(result[1]).toStrictEqual([11, 22]); // 10+1, 20+2
expect(result[2]).toStrictEqual([100, 200]); // copied, not stacked
expect(result[3]).toStrictEqual([1, 2]);
});
it('returns bands between consecutive visible series when none omitted', () => {
const data: AlignedData = [
[0, 1],
[1, 2],
[3, 4],
[5, 6],
];
const { bands } = stack(data, neverOmit);
expect(bands).toStrictEqual([{ series: [1, 2] }, { series: [2, 3] }]);
});
it('returns bands only between visible series when some are omitted', () => {
// 4 value series; omit index 2. Visible: 1, 3, 4. Bands: [1,3], [3,4]
const data: AlignedData = [[0], [1], [2], [3], [4]];
const omitSeries2 = (i: number): boolean => i === 2;
const { bands } = stack(data, omitSeries2);
expect(bands).toStrictEqual([{ series: [1, 3] }, { series: [3, 4] }]);
});
it('returns empty bands when only one value series', () => {
const data: AlignedData = [
[0, 1],
[1, 2],
];
const { bands } = stack(data, neverOmit);
expect(bands).toStrictEqual([]);
});
});
describe('getInitialStackedBands', () => {
it('returns one band between each consecutive pair for seriesCount 3', () => {
expect(getInitialStackedBands(3)).toStrictEqual([
{ series: [1, 2] },
{ series: [2, 3] },
]);
});
it('returns empty array for seriesCount 0 or 1', () => {
expect(getInitialStackedBands(0)).toStrictEqual([]);
expect(getInitialStackedBands(1)).toStrictEqual([]);
});
it('returns single band for seriesCount 2', () => {
expect(getInitialStackedBands(2)).toStrictEqual([{ series: [1, 2] }]);
});
it('returns bands [1,2], [2,3], ..., [n-1, n] for seriesCount n', () => {
const bands = getInitialStackedBands(5);
expect(bands).toStrictEqual([
{ series: [1, 2] },
{ series: [2, 3] },
{ series: [3, 4] },
{ series: [4, 5] },
]);
});
});
});

View File

@@ -1,20 +1,13 @@
import { StackMode } from 'lib/uPlotV2/config/types';
import uPlot, { AlignedData } from 'uplot';
/**
* Stack data cumulatively (top-down: first series = top, last = bottom).
* When `omit(seriesIndex)` returns true, that series keeps its raw values and
* contributes nothing to the total. `None` is a no-op.
* When `omit(seriesIndex)` returns true, that series is excluded from stacking.
*/
export function stackSeries(
data: AlignedData,
omit: (seriesIndex: number) => boolean,
mode: StackMode = StackMode.Normal,
): { data: AlignedData; bands: uPlot.Band[] } {
if (mode === StackMode.None) {
return { data, bands: [] };
}
const timeAxis = data[0];
const pointCount = timeAxis.length;
const valueSeriesCount = data.length - 1; // exclude time axis
@@ -24,7 +17,6 @@ export function stackSeries(
valueSeriesCount,
pointCount,
omit,
mode,
});
const bands = buildFillBands(valueSeriesCount + 1, omit); // +1 for 1-based series indices
@@ -39,46 +31,6 @@ interface BuildStackedSeriesParams {
valueSeriesCount: number;
pointCount: number;
omit: (seriesIndex: number) => boolean;
mode: StackMode;
}
/** Per-point total. Mixed-sign columns sum signed, as "share of total" implies. */
function columnTotals({
data,
valueSeriesCount,
pointCount,
omit,
}: Omit<BuildStackedSeriesParams, 'mode'>): number[] {
const totals = Array(pointCount).fill(0) as number[];
for (let seriesIndex = 1; seriesIndex <= valueSeriesCount; seriesIndex++) {
if (omit(seriesIndex)) {
continue;
}
const rawValues = data[seriesIndex] as (number | null)[];
rawValues.forEach((rawValue, pointIndex) => {
totals[pointIndex] += rawValue == null ? 0 : Number(rawValue);
});
}
return totals;
}
/** A column whose participating series sum to 0 has no share to divide, so every slice is 0. */
function toPercent(value: number, total: number): number {
return total === 0 ? 0 : (value / total) * 100;
}
/** What a raw value adds to the stack at a given point. */
type Contribution = (value: number, pointIndex: number) => number;
function contributionForMode(params: BuildStackedSeriesParams): Contribution {
if (params.mode !== StackMode.Percent) {
return (value): number => value;
}
// Resolved up front: totals span series the accumulation below has not reached yet.
const totals = columnTotals(params);
return (value, pointIndex): number => toPercent(value, totals[pointIndex]);
}
/**
@@ -90,17 +42,9 @@ function buildStackedSeries({
valueSeriesCount,
pointCount,
omit,
mode,
}: BuildStackedSeriesParams): (number | null)[][] {
const stackedSeries: (number | null)[][] = Array(valueSeriesCount);
const cumulativeSums = Array(pointCount).fill(0) as number[];
const contributionOf = contributionForMode({
data,
valueSeriesCount,
pointCount,
omit,
mode,
});
for (let seriesIndex = valueSeriesCount; seriesIndex >= 1; seriesIndex--) {
const rawValues = data[seriesIndex] as (number | null)[];
@@ -110,10 +54,7 @@ function buildStackedSeries({
} else {
stackedSeries[seriesIndex - 1] = rawValues.map((rawValue, pointIndex) => {
const numericValue = rawValue == null ? 0 : Number(rawValue);
return (cumulativeSums[pointIndex] += contributionOf(
numericValue,
pointIndex,
));
return (cumulativeSums[pointIndex] += numericValue);
});
}
}
@@ -160,3 +101,16 @@ function findNextVisibleSeriesIndex(
}
return -1;
}
/**
* Returns band indices for initial stacked state (no series omitted).
* Top-down: first series at top, band fills between consecutive series.
* uPlot band format: [upperSeriesIdx, lowerSeriesIdx].
*/
export function getInitialStackedBands(seriesCount: number): uPlot.Band[] {
const bands: uPlot.Band[] = [];
for (let seriesIndex = 1; seriesIndex < seriesCount; seriesIndex++) {
bands.push({ series: [seriesIndex, seriesIndex + 1] });
}
return bands;
}

View File

@@ -0,0 +1,116 @@
import uPlot, { AlignedData } from 'uplot';
/**
* Stack data cumulatively (top-down: first series = top, last = bottom).
* When `omit(seriesIndex)` returns true, that series is excluded from stacking.
*/
export function stack(
data: AlignedData,
omit: (seriesIndex: number) => boolean,
): { data: AlignedData; bands: uPlot.Band[] } {
const timeAxis = data[0];
const pointCount = timeAxis.length;
const valueSeriesCount = data.length - 1; // exclude time axis
const stackedSeries = buildStackedSeries({
data,
valueSeriesCount,
pointCount,
omit,
});
const bands = buildFillBands(valueSeriesCount + 1, omit); // +1 for 1-based series indices
return {
data: [timeAxis, ...stackedSeries] as AlignedData,
bands,
};
}
interface BuildStackedSeriesParams {
data: AlignedData;
valueSeriesCount: number;
pointCount: number;
omit: (seriesIndex: number) => boolean;
}
/**
* Accumulate from last series upward: last series = raw values, first = total.
* Omitted series are copied as-is (no accumulation).
*/
function buildStackedSeries({
data,
valueSeriesCount,
pointCount,
omit,
}: BuildStackedSeriesParams): (number | null)[][] {
const stackedSeries: (number | null)[][] = Array(valueSeriesCount);
const cumulativeSums = Array(pointCount).fill(0) as number[];
for (let seriesIndex = valueSeriesCount; seriesIndex >= 1; seriesIndex--) {
const rawValues = data[seriesIndex] as (number | null)[];
if (omit(seriesIndex)) {
stackedSeries[seriesIndex - 1] = rawValues;
} else {
stackedSeries[seriesIndex - 1] = rawValues.map((rawValue, pointIndex) => {
const numericValue = rawValue == null ? 0 : Number(rawValue);
return (cumulativeSums[pointIndex] += numericValue);
});
}
}
return stackedSeries;
}
/**
* Bands define fill between consecutive visible series for stacked appearance.
* uPlot format: [upperSeriesIdx, lowerSeriesIdx].
*/
function buildFillBands(
seriesLength: number,
omit: (seriesIndex: number) => boolean,
): uPlot.Band[] {
const bands: uPlot.Band[] = [];
for (let seriesIndex = 1; seriesIndex < seriesLength; seriesIndex++) {
if (omit(seriesIndex)) {
continue;
}
const nextVisibleSeriesIndex = findNextVisibleSeriesIndex(
seriesLength,
seriesIndex,
omit,
);
if (nextVisibleSeriesIndex !== -1) {
bands.push({ series: [seriesIndex, nextVisibleSeriesIndex] });
}
}
return bands;
}
function findNextVisibleSeriesIndex(
seriesLength: number,
afterIndex: number,
omit: (seriesIndex: number) => boolean,
): number {
for (let i = afterIndex + 1; i < seriesLength; i++) {
if (!omit(i)) {
return i;
}
}
return -1;
}
/**
* Returns band indices for initial stacked state (no series omitted).
* Top-down: first series at top, band fills between consecutive series.
* uPlot band format: [upperSeriesIdx, lowerSeriesIdx].
*/
export function getInitialStackedBands(seriesCount: number): uPlot.Band[] {
const bands: uPlot.Band[] = [];
for (let seriesIndex = 1; seriesIndex < seriesCount; seriesIndex++) {
bands.push({ series: [seriesIndex, seriesIndex + 1] });
}
return bands;
}

View File

@@ -0,0 +1,313 @@
import { renderHook } from '@testing-library/react';
import uPlot from 'uplot';
import type { UseBarChartStackingParams } from '../useBarChartStacking';
import { useBarChartStacking } from '../useBarChartStacking';
type MockConfig = { addHook: jest.Mock };
function asConfig(c: MockConfig): UseBarChartStackingParams['config'] {
return c as unknown as UseBarChartStackingParams['config'];
}
function createMockConfig(): {
config: MockConfig;
invokeSetData: (plot: uPlot) => void;
invokeSetSeries: (
plot: uPlot,
seriesIndex: number | null,
opts: Partial<uPlot.Series> & { focus?: boolean },
) => void;
removeSetData: jest.Mock;
removeSetSeries: jest.Mock;
} {
let setDataHandler: ((plot: uPlot) => void) | null = null;
let setSeriesHandler:
| ((plot: uPlot, seriesIndex: number | null, opts: uPlot.Series) => void)
| null = null;
const removeSetData = jest.fn();
const removeSetSeries = jest.fn();
const addHook = jest.fn(
(
hookName: string,
handler: (plot: uPlot, ...args: unknown[]) => void,
): (() => void) => {
if (hookName === 'setData') {
setDataHandler = handler as (plot: uPlot) => void;
return removeSetData;
}
if (hookName === 'setSeries') {
setSeriesHandler = handler as (
plot: uPlot,
seriesIndex: number | null,
opts: uPlot.Series,
) => void;
return removeSetSeries;
}
return jest.fn();
},
);
const config: MockConfig = { addHook };
const invokeSetData = (plot: uPlot): void => {
setDataHandler?.(plot);
};
const invokeSetSeries = (
plot: uPlot,
seriesIndex: number | null,
opts: Partial<uPlot.Series> & { focus?: boolean },
): void => {
setSeriesHandler?.(plot, seriesIndex, opts as uPlot.Series);
};
return {
config,
invokeSetData,
invokeSetSeries,
removeSetData,
removeSetSeries,
};
}
function createMockPlot(overrides: Partial<uPlot> = {}): uPlot {
return {
data: [
[0, 1, 2],
[1, 2, 3],
[4, 5, 6],
],
series: [{ show: true }, { show: true }, { show: true }],
delBand: jest.fn(),
addBand: jest.fn(),
setData: jest.fn(),
...overrides,
} as unknown as uPlot;
}
describe('useBarChartStacking', () => {
it('returns data as-is when isStackedBarChart is false', () => {
const data: uPlot.AlignedData = [
[100, 200],
[1, 2],
[3, 4],
];
const { result } = renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: false,
config: null,
}),
);
expect(result.current).toBe(data);
});
it('returns data as-is when config is null and isStackedBarChart is true', () => {
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
[4, 5],
];
const { result } = renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: null,
}),
);
// Still returns stacked data (computed in useMemo); no hooks registered
expect(result.current[0]).toStrictEqual([0, 1]);
expect(result.current[1]).toStrictEqual([5, 7]); // stacked
expect(result.current[2]).toStrictEqual([4, 5]);
});
it('returns stacked data when isStackedBarChart is true and multiple value series', () => {
const data: uPlot.AlignedData = [
[0, 1, 2],
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
];
const { result } = renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: null,
}),
);
expect(result.current[0]).toStrictEqual([0, 1, 2]);
expect(result.current[1]).toStrictEqual([12, 15, 18]); // s1+s2+s3
expect(result.current[2]).toStrictEqual([11, 13, 15]); // s2+s3
expect(result.current[3]).toStrictEqual([7, 8, 9]);
});
it('returns data as-is when only one value series (no stacking needed)', () => {
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
];
const { result } = renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: null,
}),
);
expect(result.current).toStrictEqual(data);
});
it('registers setData and setSeries hooks when isStackedBarChart and config provided', () => {
const { config } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
[3, 4],
];
renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: asConfig(config),
}),
);
expect(config.addHook).toHaveBeenCalledWith('setData', expect.any(Function));
expect(config.addHook).toHaveBeenCalledWith(
'setSeries',
expect.any(Function),
);
});
it('does not register hooks when isStackedBarChart is false', () => {
const { config } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
[3, 4],
];
renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: false,
config: asConfig(config),
}),
);
expect(config.addHook).not.toHaveBeenCalled();
});
it('calls cleanup when unmounted', () => {
const { config, removeSetData, removeSetSeries } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
[3, 4],
];
const { unmount } = renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: asConfig(config),
}),
);
unmount();
expect(removeSetData).toHaveBeenCalled();
expect(removeSetSeries).toHaveBeenCalled();
});
it('re-stacks and updates plot when setData hook is invoked', () => {
const { config, invokeSetData } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1, 2],
[1, 2, 3],
[4, 5, 6],
];
const plot = createMockPlot({
data: [
[0, 1, 2],
[5, 7, 9],
[4, 5, 6],
],
});
renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: asConfig(config),
}),
);
invokeSetData(plot);
expect(plot.delBand).toHaveBeenCalledWith(null);
expect(plot.addBand).toHaveBeenCalled();
expect(plot.setData).toHaveBeenCalledWith(
expect.arrayContaining([
[0, 1, 2],
expect.any(Array), // stacked row 1
expect.any(Array), // stacked row 2
]),
);
});
it('re-stacks when setSeries hook is invoked (e.g. legend toggle)', () => {
const { config, invokeSetSeries } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1],
[10, 20],
[5, 10],
];
// Plot data must match unstacked length so canApplyStacking passes
const plot = createMockPlot({
data: [
[0, 1],
[15, 30],
[5, 10],
],
});
renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: asConfig(config),
}),
);
invokeSetSeries(plot, 1, { show: false });
expect(plot.setData).toHaveBeenCalled();
});
it('does not re-stack when setSeries is called with focus option', () => {
const { config, invokeSetSeries } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
[3, 4],
];
const plot = createMockPlot();
renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: asConfig(config),
}),
);
(plot.setData as jest.Mock).mockClear();
invokeSetSeries(plot, 1, { focus: true } as uPlot.Series);
expect(plot.setData).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,125 @@
import {
MutableRefObject,
useCallback,
useLayoutEffect,
useMemo,
useRef,
} from 'react';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { has } from 'lodash-es';
import uPlot from 'uplot';
import { stackSeries } from '../charts/utils/stackSeriesUtils';
/** Returns true if the series at the given index is hidden (e.g. via legend toggle). */
function isSeriesHidden(plot: uPlot, seriesIndex: number): boolean {
return !plot.series[seriesIndex]?.show;
}
function canApplyStacking(
unstackedData: uPlot.AlignedData | null,
plot: uPlot,
isUpdating: boolean,
): boolean {
return (
!isUpdating &&
!!unstackedData &&
!!plot.data &&
unstackedData[0]?.length === plot.data[0]?.length
);
}
function setupStackingHooks(
config: UPlotConfigBuilder,
applyStackingToChart: (plot: uPlot) => void,
isUpdatingRef: MutableRefObject<boolean>,
): () => void {
const onDataChange = (plot: uPlot): void => {
if (!isUpdatingRef.current) {
applyStackingToChart(plot);
}
};
const onSeriesVisibilityChange = (
plot: uPlot,
_seriesIdx: number | null,
opts: uPlot.Series,
): void => {
if (!has(opts, 'focus')) {
applyStackingToChart(plot);
}
};
const removeSetDataHook = config.addHook('setData', onDataChange);
const removeSetSeriesHook = config.addHook(
'setSeries',
onSeriesVisibilityChange,
);
return (): void => {
removeSetDataHook?.();
removeSetSeriesHook?.();
};
}
export interface UseBarChartStackingParams {
data: uPlot.AlignedData;
isStackedBarChart?: boolean;
config: UPlotConfigBuilder | null;
}
/**
* Handles stacking for bar charts: computes initial stacked data and re-stacks
* when data or series visibility changes (e.g. legend toggles).
*/
export function useBarChartStacking({
data,
isStackedBarChart = false,
config,
}: UseBarChartStackingParams): uPlot.AlignedData {
// Store unstacked source data so uPlot hooks can access it (hooks run outside React's render cycle)
const unstackedDataRef = useRef<uPlot.AlignedData | null>(null);
unstackedDataRef.current = isStackedBarChart ? data : null;
// Prevents re-entrant calls when we update chart data (avoids infinite loop in setData hook)
const isUpdatingChartRef = useRef(false);
const chartData = useMemo((): uPlot.AlignedData => {
if (!isStackedBarChart || !data || data.length < 2) {
return data;
}
const noSeriesHidden = (): boolean => false; // include all series in initial stack
const { data: stacked } = stackSeries(data, noSeriesHidden);
return stacked;
}, [data, isStackedBarChart]);
const applyStackingToChart = useCallback((plot: uPlot): void => {
const unstacked = unstackedDataRef.current;
if (
!unstacked ||
!canApplyStacking(unstacked, plot, isUpdatingChartRef.current)
) {
return;
}
const shouldExcludeSeries = (idx: number): boolean =>
isSeriesHidden(plot, idx);
const { data: stacked, bands } = stackSeries(unstacked, shouldExcludeSeries);
plot.delBand(null);
bands.forEach((band: uPlot.Band) => plot.addBand(band));
isUpdatingChartRef.current = true;
plot.setData(stacked);
isUpdatingChartRef.current = false;
}, []);
useLayoutEffect(() => {
if (!isStackedBarChart || !config) {
return undefined;
}
return setupStackingHooks(config, applyStackingToChart, isUpdatingChartRef);
}, [isStackedBarChart, config, applyStackingToChart]);
return chartData;
}

View File

@@ -22,7 +22,6 @@ import { prepareBarPanelConfig } from './utils';
import '../Panel.styles.scss';
import TooltipFooter from '../components/TooltipFooter';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
import { StackMode } from 'lib/uPlotV2/config/types';
function BarPanel(props: PanelWrapperProps): JSX.Element {
const {
@@ -148,7 +147,6 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<BarChart
key={`${syncMode}-${syncFilterMode}`}
stack={widget.stackedBarChart ? StackMode.Normal : StackMode.None}
config={config}
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
@@ -161,6 +159,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
height={containerDimensions.height}
layoutChildren={layoutChildren}
groupByPerQuery={groupByPerQuery}
isStackedBarChart={widget.stackedBarChart ?? false}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
timezone={timezone}

View File

@@ -35,10 +35,20 @@ jest.mock('lib/getLabelName', () => ({
),
}));
jest.mock(
'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils',
() => ({
getInitialStackedBands: jest.fn().mockReturnValue([]),
}),
);
const getLegendMock = jest.requireMock('lib/dashboard/getQueryResults')
.getLegend as jest.Mock;
const getLabelNameMock = jest.requireMock('lib/getLabelName')
.default as jest.Mock;
const getInitialStackedBandsMock = jest.requireMock(
'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils',
).getInitialStackedBands as jest.Mock;
const createApiResponse = (
result: MetricRangePayloadProps['data']['result'] = [],
@@ -237,5 +247,36 @@ describe('BarPanel utils', () => {
}).getConfig();
expect(config.series?.[1]).toMatchObject({ stroke: '#ff0000' });
});
it('calls getInitialStackedBands when widget is stackedBarChart', () => {
const widget = createWidget({ stackedBarChart: true });
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q1',
values: [[1000, '1']],
} as MetricRangePayloadProps['data']['result'][0],
{
metric: {},
queryName: 'Q2',
values: [[1000, '2']],
} as MetricRangePayloadProps['data']['result'][0],
]);
prepareBarPanelConfig({ ...baseParams, widget, apiResponse });
// seriesCount = result.length + 1 = 3
expect(getInitialStackedBandsMock).toHaveBeenCalledWith(3);
});
it('does not call getInitialStackedBands for non-stacked chart', () => {
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q1',
values: [[1000, '1']],
} as MetricRangePayloadProps['data']['result'][0],
]);
prepareBarPanelConfig({ ...baseParams, apiResponse });
expect(getInitialStackedBandsMock).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,6 +1,7 @@
import { ExecStats } from 'api/v5/v5';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
@@ -68,6 +69,11 @@ export function prepareBarPanelConfig({
return builder;
}
if (widget.stackedBarChart) {
const seriesCount = (apiResponse.data.result.length ?? 0) + 1; // +1 for 1-based uPlot series indices
builder.setBands(getInitialStackedBands(seriesCount));
}
apiResponse.data.result.forEach((series) => {
const baseLabelName = getLabelName(
series.metric,

View File

@@ -124,9 +124,7 @@ export function buildBaseConfig({
side: 2,
isDarkMode,
isLogScale,
// Graph and bar plot time on X; every other panel type here does not.
isTimeAxis:
panelType === PANEL_TYPES.TIME_SERIES || panelType === PANEL_TYPES.BAR,
panelType,
});
builder.addAxis({
@@ -136,6 +134,7 @@ export function buildBaseConfig({
isDarkMode,
isLogScale,
yAxisUnit,
panelType,
});
return builder;

View File

@@ -1,4 +1,5 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import {
@@ -75,7 +76,7 @@ export function buildEntityMetricsChartConfig({
show: true,
side: 2,
isDarkMode,
isTimeAxis: true,
panelType: PANEL_TYPES.TIME_SERIES,
});
builder.addAxis({
@@ -84,6 +85,7 @@ export function buildEntityMetricsChartConfig({
side: 3,
isDarkMode,
yAxisUnit,
panelType: PANEL_TYPES.TIME_SERIES,
});
if (!apiResponse?.data?.result) {

View File

@@ -9,7 +9,6 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { StackMode } from 'lib/uPlotV2/config/types';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
import { useTimezone } from 'providers/Timezone';
import { AppState } from 'store/reducers';
@@ -138,7 +137,6 @@ function TimeSeries({
key={`${WIDGET_ID}-${index}`}
>
<BarChart
stack={StackMode.Normal}
config={chart.config}
legendConfig={{
position: LegendPosition.BOTTOM,
@@ -146,6 +144,7 @@ function TimeSeries({
data={chart.chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}
isStackedBarChart
yAxisUnit={yAxisUnit || 'short'}
timezone={timezone}
/>

View File

@@ -1,4 +1,6 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import {
@@ -71,7 +73,7 @@ export function buildMeterChartConfig({
show: true,
side: 2,
isDarkMode,
isTimeAxis: true,
panelType: PANEL_TYPES.BAR,
});
builder.addAxis({
@@ -80,12 +82,16 @@ export function buildMeterChartConfig({
side: 3,
isDarkMode,
yAxisUnit,
panelType: PANEL_TYPES.BAR,
});
if (!apiResponse?.data?.result) {
return builder;
}
const seriesCount = (apiResponse.data.result.length ?? 0) + 1;
builder.setBands(getInitialStackedBands(seriesCount));
apiResponse.data.result.forEach((series) => {
const baseLabelName = getLabelName(
series.metric,

View File

@@ -9,7 +9,6 @@ export default function BarChartTooltip(props: BarTooltipProps): JSX.Element {
(): TooltipContentItem[] =>
buildTooltipContent({
data: props.uPlotInstance.data,
unstackedData: props.unstackedData,
series: props.uPlotInstance.series,
dataIndexes: props.dataIndexes,
activeSeriesIndex: props.seriesIndex,
@@ -22,7 +21,6 @@ export default function BarChartTooltip(props: BarTooltipProps): JSX.Element {
}),
[
props.uPlotInstance,
props.unstackedData,
props.seriesIndex,
props.dataIndexes,
props.yAxisUnit,

View File

@@ -11,7 +11,6 @@ export default function TimeSeriesTooltip(
(): TooltipContentItem[] =>
buildTooltipContent({
data: props.uPlotInstance.data,
unstackedData: props.unstackedData,
series: props.uPlotInstance.series,
dataIndexes: props.dataIndexes,
activeSeriesIndex: props.seriesIndex,
@@ -23,7 +22,6 @@ export default function TimeSeriesTooltip(
}),
[
props.uPlotInstance,
props.unstackedData,
props.seriesIndex,
props.dataIndexes,
props.yAxisUnit,

View File

@@ -72,35 +72,6 @@ describe('Tooltip utils', () => {
expect(result).toBe(20);
});
it('reports the pre-stack value, identically for normal and percent', () => {
const unstackedData: AlignedData = [[0], [30], [10]];
const series = [{}, { show: true }, { show: true }] as Series[];
const read = (data: AlignedData): number | null =>
getTooltipBaseValue({
data,
unstackedData,
index: 1,
dataIndex: 0,
isStackedBarChart: true,
series,
});
expect(read([[0], [40], [10]])).toBe(30);
expect(read([[0], [100], [25]])).toBe(30);
});
it('falls back to subtraction when no pre-stack data is given', () => {
const result = getTooltipBaseValue({
data: [[0], [40], [10]],
index: 1,
dataIndex: 0,
isStackedBarChart: true,
series: [{}, { show: true }, { show: true }] as Series[],
});
expect(result).toBe(30);
});
it('returns null when value is missing', () => {
const data: AlignedData = [
[0, 1],

View File

@@ -23,25 +23,17 @@ export function resolveSeriesColor(
export function getTooltipBaseValue({
data,
unstackedData,
index,
dataIndex,
isStackedBarChart,
series,
}: {
data: AlignedData;
unstackedData?: AlignedData;
index: number;
dataIndex: number;
isStackedBarChart?: boolean;
series?: Series[];
}): number | null {
// The subtraction below only recovers the raw value under `normal` stacking.
const unstackedSeries = unstackedData?.[index];
if (unstackedSeries) {
return unstackedSeries[dataIndex] ?? null;
}
let baseValue = data[index][dataIndex] ?? null;
// Top-down stacking (first series at top): raw = stacked[i] - stacked[nextVisible].
// When series are hidden, we must use the next *visible* series, not index+1,
@@ -64,7 +56,6 @@ export function getTooltipBaseValue({
export function buildTooltipContent({
data,
unstackedData,
series,
dataIndexes,
activeSeriesIndex,
@@ -76,7 +67,6 @@ export function buildTooltipContent({
syncFilterMode,
}: {
data: AlignedData;
unstackedData?: AlignedData;
series: Series[];
dataIndexes: Array<number | null>;
activeSeriesIndex: number | null;
@@ -125,7 +115,6 @@ export function buildTooltipContent({
const baseValue = getTooltipBaseValue({
data,
unstackedData,
index: seriesIndex,
dataIndex,
isStackedBarChart,

View File

@@ -69,11 +69,6 @@ export interface TooltipRenderArgs {
syncedSeriesIndexes?: number[] | null;
/** Receiver-side filter mode for the synced tooltip. Defaults to Filtered. */
syncFilterMode?: SyncTooltipFilterMode;
/**
* Pre-stack values, injected by `ChartWrapper`. `Percent` discards the column total,
* so the raw value cannot be recovered from the plot's own cumulative data.
*/
unstackedData?: uPlot.AlignedData;
}
export interface IRenderTooltipFooterArgs {

View File

@@ -1,4 +1,5 @@
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import { PANEL_TYPES } from 'constants/queryBuilder';
import uPlot, { Axis } from 'uplot';
import { uPlotXAxisValuesFormat } from '../../uPlotLib/utils/constants';
@@ -6,6 +7,11 @@ import getGridColor from '../../uPlotLib/utils/getGridColor';
import { buildYAxisSizeCalculator } from '../utils/axis';
import { AxisProps, ConfigBuilder } from './types';
const PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT = [
PANEL_TYPES.TIME_SERIES,
PANEL_TYPES.BAR,
];
/**
* Builder for uPlot axis configuration
* Handles creation and merging of axis settings
@@ -61,9 +67,12 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
* Build values formatter for X-axis (time)
*/
private buildXAxisValuesFormatter(): uPlot.Axis.Values | undefined {
const { isTimeAxis } = this.props;
const { panelType } = this.props;
if (isTimeAxis) {
if (
panelType &&
PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT.includes(panelType)
) {
return uPlotXAxisValuesFormat as uPlot.Axis.Values;
}

View File

@@ -20,7 +20,6 @@ import {
ConfigBuilderProps,
LegendItem,
SelectionPreferencesSource,
StackMode,
} from './types';
import { AxisProps, UPlotAxisBuilder } from './UPlotAxisBuilder';
import { ScaleProps, UPlotScaleBuilder } from './UPlotScaleBuilder';
@@ -29,11 +28,6 @@ import { SeriesProps, UPlotSeriesBuilder } from './UPlotSeriesBuilder';
/**
* Type definitions for uPlot option objects
*/
/** Renders a 0100 number as `50%`, unlike the 01 `percentunit`. */
const PERCENT_AXIS_UNIT = 'percent';
const PERCENT_AXIS_MAX = 100;
type LegendConfig = {
show?: boolean;
live?: boolean;
@@ -63,8 +57,6 @@ export class UPlotConfigBuilder extends ConfigBuilder<
private bands: uPlot.Band[] = [];
private stackMode: StackMode = StackMode.None;
private cursor: Cursor | undefined;
private hooks: Hooks.Arrays = {};
@@ -151,15 +143,6 @@ export class UPlotConfigBuilder extends ConfigBuilder<
this.axes[scaleKey] = new UPlotAxisBuilder(props);
}
/** Drives the fill bands, the percent axis unit and the percent range below. */
setStackMode(stackMode: StackMode): void {
this.stackMode = stackMode;
}
getStackMode(): StackMode {
return this.stackMode;
}
/**
* Add or merge a scale configuration
*/
@@ -228,41 +211,6 @@ export class UPlotConfigBuilder extends ConfigBuilder<
this.bands = bands;
}
/**
* The panel's own limits are in the source unit, which means nothing once values are
* normalised. Soft rather than hard, so mixed-sign shares outside 0100 stay visible.
*/
private resolveScale(scale: UPlotScaleBuilder): UPlotScaleBuilder {
if (this.stackMode !== StackMode.Percent || scale.props.scaleKey !== 'y') {
return scale;
}
return new UPlotScaleBuilder({
...scale.props,
min: undefined,
max: undefined,
softMin: 0,
softMax: PERCENT_AXIS_MAX,
// Thresholds still draw, but a 500ms one must not stretch the axis to 0500.
thresholds: undefined,
});
}
/** Explicit bands win; otherwise a stack fills between consecutive series. */
private resolveBands(): uPlot.Band[] | undefined {
if (this.bands.length > 0) {
return this.bands;
}
if (this.stackMode === StackMode.None || this.series.length < 2) {
return undefined;
}
return (
this.series
.slice(0, -1)
// uPlot series are 1-based (index 0 is the timestamp axis).
.map((_, index) => ({ series: [index + 1, index + 2] as [number, number] }))
);
}
/**
* Set cursor configuration
*/
@@ -496,19 +444,9 @@ export class UPlotConfigBuilder extends ConfigBuilder<
};
}),
];
config.axes = Object.entries(this.axes).map(([scaleKey, axis]) => {
if (scaleKey !== 'y' || this.stackMode !== StackMode.Percent) {
return axis.getConfig();
}
// Ticks read as percentages; the panel unit still applies to tooltips and
// thresholds, so build from a copy rather than touching the axis props.
return new UPlotAxisBuilder({
...axis.props,
yAxisUnit: PERCENT_AXIS_UNIT,
}).getConfig();
});
config.axes = Object.values(this.axes).map((a) => a.getConfig());
config.scales = this.scales.reduce(
(acc, s) => ({ ...acc, ...this.resolveScale(s).getConfig() }),
(acc, s) => ({ ...acc, ...s.getConfig() }),
{} as Record<string, uPlot.Scale>,
);
@@ -518,7 +456,7 @@ export class UPlotConfigBuilder extends ConfigBuilder<
config.cursor = this.getCursorConfig();
config.tzDate = this.tzDate;
config.plugins = this.plugins.length > 0 ? this.plugins : undefined;
config.bands = this.resolveBands();
config.bands = this.bands.length > 0 ? this.bands : undefined;
if (Array.isArray(this.padding)) {
config.padding = this.padding;

View File

@@ -1,4 +1,5 @@
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { uPlotXAxisValuesFormat } from 'lib/uPlotLib/utils/constants';
import type uPlot from 'uplot';
@@ -136,11 +137,11 @@ describe('UPlotAxisBuilder', () => {
});
});
it('uses time-based X-axis values formatter when the caller declares a time axis', () => {
it('uses time-based X-axis values formatter for time-series like panels', () => {
const builder = new UPlotAxisBuilder(
createAxisProps({
scaleKey: 'x',
isTimeAxis: true,
panelType: PANEL_TYPES.TIME_SERIES,
}),
);
@@ -149,11 +150,11 @@ describe('UPlotAxisBuilder', () => {
expect(config.values).toBe(uPlotXAxisValuesFormat);
});
it('does not attach X-axis datetime formatter for a non-time axis', () => {
it('does not attach X-axis datetime formatter when panel type is not supported', () => {
const builder = new UPlotAxisBuilder(
createAxisProps({
scaleKey: 'x',
isTimeAxis: false,
panelType: PANEL_TYPES.LIST, // not in PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT
}),
);
@@ -289,9 +290,22 @@ describe('UPlotAxisBuilder', () => {
expect(config.space).toBe(50);
});
it('omits the X-axis datetime formatter when no time axis is declared', () => {
const builder = new UPlotAxisBuilder(createAxisProps({ scaleKey: 'x' }));
expect(builder.getConfig().values).toBeUndefined();
it('includes PANEL_TYPES.BAR and PANEL_TYPES.TIME_SERIES in X-axis datetime formatter', () => {
const barBuilder = new UPlotAxisBuilder(
createAxisProps({
scaleKey: 'x',
panelType: PANEL_TYPES.BAR,
}),
);
expect(barBuilder.getConfig().values).toBe(uPlotXAxisValuesFormat);
const timeSeriesBuilder = new UPlotAxisBuilder(
createAxisProps({
scaleKey: 'x',
panelType: PANEL_TYPES.TIME_SERIES,
}),
);
expect(timeSeriesBuilder.getConfig().values).toBe(uPlotXAxisValuesFormat);
});
it('should return the existing size when cycleNum > 1', () => {

View File

@@ -5,7 +5,7 @@ import {
STEP_INTERVAL_MULTIPLIER,
} from '../../constants';
import type { SeriesProps } from '../types';
import { DrawStyle, SelectionPreferencesSource, StackMode } from '../types';
import { DrawStyle, SelectionPreferencesSource } from '../types';
import { UPlotConfigBuilder } from '../UPlotConfigBuilder';
// Mock only the real boundary that hits localStorage
@@ -496,161 +496,3 @@ describe('UPlotConfigBuilder', () => {
expect(config.bands).toBeUndefined();
});
});
describe('UPlotConfigBuilder stacking', () => {
beforeEach(() => {
jest.clearAllMocks();
getStoredSeriesVisibilityMock.getStoredSeriesVisibility.mockReturnValue([]);
});
/**
* Soft limits end up captured in the scale's range closure, so the only way to read
* them back is to run it and inspect the range config it hands uPlot.
*/
function scaleSoftLimits(
builder: UPlotConfigBuilder,
scaleKey: string,
): { min: number; max: number } {
const rangeNum = jest.fn().mockReturnValue([0, 0]);
(uPlot as unknown as { rangeNum: unknown }).rangeNum = rangeNum;
const range = builder.getConfig().scales?.[scaleKey]?.range as (
u: unknown,
min: number,
max: number,
key: string,
) => void;
range({ scales: { [scaleKey]: { distr: 1 } } }, 40, 60, scaleKey);
const [, , rangeConfig] = rangeNum.mock.calls[0] as [
number,
number,
{ min: { soft: number }; max: { soft: number } },
];
return { min: rangeConfig.min.soft, max: rangeConfig.max.soft };
}
/** Renders y-axis ticks the way uPlot would, so unit formatting is observable. */
function yAxisTicks(builder: UPlotConfigBuilder, ticks: number[]): string[] {
const yAxis = builder.getConfig().axes?.find((a) => a.scale === 'y');
const values = yAxis?.values as (
u: unknown,
splits: number[],
) => (string | null)[];
return values(null, ticks).map((v) => String(v));
}
function builderFor(stack?: StackMode, seriesCount = 3): UPlotConfigBuilder {
const builder = new UPlotConfigBuilder({ id: 'stack-test' });
if (stack) {
builder.setStackMode(stack);
}
builder.addAxis({ scaleKey: 'y', show: true, side: 3, yAxisUnit: 'ms' });
for (let i = 0; i < seriesCount; i++) {
builder.addSeries({
scaleKey: 'y',
label: `S${i}`,
drawStyle: DrawStyle.Bar,
colorMapping: {},
isDarkMode: false,
} as SeriesProps);
}
return builder;
}
it('defaults to no stacking, so no bands and the panel unit on the axis', () => {
const builder = builderFor();
expect(builder.getStackMode()).toBe('none');
expect(builder.getConfig().bands).toBeUndefined();
expect(yAxisTicks(builder, [1000])).toStrictEqual(['1 s']);
});
it('derives one band per adjacent series pair once a stack is declared', () => {
expect(builderFor(StackMode.Normal).getConfig().bands).toStrictEqual([
{ series: [1, 2] },
{ series: [2, 3] },
]);
});
it('emits no bands for a single series', () => {
expect(builderFor(StackMode.Normal, 1).getConfig().bands).toBeUndefined();
});
it('keeps the panel unit on the axis for a normal stack', () => {
expect(yAxisTicks(builderFor(StackMode.Normal), [1000])).toStrictEqual([
'1 s',
]);
});
it('formats the axis as percentages for a percent stack', () => {
expect(yAxisTicks(builderFor(StackMode.Percent), [0, 50, 100])).toStrictEqual(
['0%', '50%', '100%'],
);
});
it('leaves other axes on their own unit under a percent stack', () => {
const builder = builderFor(StackMode.Percent);
builder.addAxis({ scaleKey: 'x', show: true, side: 2 });
expect(builder.getConfig().axes?.map((a) => a.scale)).toStrictEqual([
'y',
'x',
]);
});
it('pins the y scale to the 0100 band under a percent stack, dropping panel limits', () => {
const builder = new UPlotConfigBuilder({ id: 'stack-scale' });
builder.setStackMode(StackMode.Percent);
builder.addScale({ scaleKey: 'y', softMin: 5, softMax: 500 });
// Soft, not hard: mixed-sign shares fall outside 0100 and must stay visible.
expect(builder.getConfig().scales?.y).toMatchObject({ auto: true });
expect(scaleSoftLimits(builder, 'y')).toStrictEqual({ min: 0, max: 100 });
});
it('leaves the panel limits alone when the stack is not percent', () => {
const builder = new UPlotConfigBuilder({ id: 'stack-scale' });
builder.setStackMode(StackMode.Normal);
builder.addScale({ scaleKey: 'y', softMin: 5, softMax: 500 });
expect(scaleSoftLimits(builder, 'y')).toStrictEqual({ min: 5, max: 500 });
});
it.each([StackMode.Normal, StackMode.Percent])(
'draws thresholds under a %s stack',
(stack) => {
const builder = new UPlotConfigBuilder({ id: 'stack-thr' });
builder.setStackMode(stack);
builder.addThresholds({
scaleKey: 'y',
thresholds: [{ thresholdValue: 500, thresholdColor: 'red' }],
yAxisUnit: 'ms',
});
expect(builder.getConfig().hooks?.draw).toHaveLength(1);
},
);
it('keeps a source-unit threshold from stretching the percent band', () => {
const builder = new UPlotConfigBuilder({ id: 'stack-thr' });
builder.setStackMode(StackMode.Percent);
const thresholds = {
scaleKey: 'y',
thresholds: [{ thresholdValue: 500, thresholdColor: 'red' }],
yAxisUnit: 'ms',
};
builder.addThresholds(thresholds);
builder.addScale({ scaleKey: 'y', thresholds });
// Without this the 500ms threshold would widen a percentage axis to 0500.
expect(scaleSoftLimits(builder, 'y')).toStrictEqual({ min: 0, max: 100 });
});
it('lets explicit bands win over the derived ones', () => {
const builder = builderFor(StackMode.Normal);
builder.setBands([{ series: [1, 3] }]);
expect(builder.getConfig().bands).toStrictEqual([{ series: [1, 3] }]);
});
});

View File

@@ -1,4 +1,5 @@
import { PrecisionOption } from 'components/Graph/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import uPlot, { Series } from 'uplot';
import { ThresholdsDrawHookOptions } from '../hooks/types';
@@ -32,13 +33,6 @@ export enum SelectionPreferencesSource {
/**
* Props for configuring the uPlot config builder
*/
/** `Percent` rescales each x-slice to its column total, so every column fills to 100. */
export enum StackMode {
None = 'none',
Normal = 'normal',
Percent = 'percent',
}
export interface ConfigBuilderProps {
id: string;
onDragSelect?: (startTime: number, endTime: number) => void;
@@ -52,50 +46,31 @@ export interface ConfigBuilderProps {
* Props for configuring an axis
*/
export interface AxisProps {
/** Scale this axis is drawn against — `'x'` / `'y'`, matching an `addScale` key. Also
* selects the default tick formatter and sizing (x: time, y: value + unit). */
scaleKey: string;
/** Axis title drawn alongside the ticks; omitted when there's nothing to name. */
label?: string;
/** Render the axis at all; false keeps the scale but draws no ticks or labels. */
show?: boolean;
/** Which edge of the plot the axis sits on: 0 | 1 | 2 | 3 — top, right, bottom, left. */
side?: 0 | 1 | 2 | 3;
/** Tick/label color. Defaults to black or white from `isDarkMode`. */
side?: 0 | 1 | 2 | 3; // top, right, bottom, left
stroke?: string;
/** Partial override of the grid lines; unset keys fall back to the theme defaults. */
grid?: {
stroke?: string;
width?: number;
show?: boolean;
};
/** Partial override of the tick marks; provided as-is to uPlot when set. */
ticks?: {
stroke?: string;
width?: number;
show?: boolean;
size?: number;
};
/** Explicit tick formatter, replacing the scale's default (time / unit-formatted). */
values?: uPlot.Axis.Values;
/** Pixels between the ticks and their labels; also feeds the y axis width calculation. */
gap?: number;
/** Explicit axis thickness. Left unset, the y axis sizes itself to its widest label. */
size?: uPlot.Axis.Size;
formatValue?: (v: number) => string;
space?: number; // Space for log scale axes
/** Picks the dark or light default for stroke and grid color. */
isDarkMode?: boolean;
/** Axis is on a log scale — thins the grid lines to keep dense decades readable. */
isLogScale?: boolean;
/** Unit the y axis ticks are formatted in (`spec.formatting.unit`). */
yAxisUnit?: string;
/**
* X axis carries timestamps, so its ticks format as dates/times. Declared by the caller
* rather than inferred from a panel type — a chart whose x axis is buckets or categories
* (histogram) leaves it off.
*/
isTimeAxis?: boolean;
/** Decimal places for y axis tick values; unset lets the unit formatter decide. */
panelType?: PANEL_TYPES;
decimalPrecision?: PrecisionOption;
}

View File

@@ -281,20 +281,3 @@ describe('dataUtils', () => {
});
});
});
describe('insertLargeGapNullsIntoAlignedData index alignment', () => {
// ChartWrapper gap-processes the pre-stack series to keep tooltip indices aligned;
// that only holds because insertions are decided from the x axis, never from y.
it('inserts at the same positions regardless of the y values', () => {
const x = [0, 100, 200];
const options = [{ spanGaps: 50 }];
const raw = [x, [1, 2, 3]] as uPlot.AlignedData;
const stacked = [x, [10, 20, 30]] as uPlot.AlignedData;
const fromRaw = insertLargeGapNullsIntoAlignedData(raw, options);
const fromStacked = insertLargeGapNullsIntoAlignedData(stacked, options);
expect(fromRaw[0]).toStrictEqual(fromStacked[0]);
expect(fromRaw[1]).toHaveLength((fromStacked[1] as unknown[]).length);
});
});

View File

@@ -13,6 +13,7 @@ import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.sche
import PromQLIcon from 'assets/Dashboard/PromQl';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import TextToolTip from 'components/TextToolTip';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ClickHouseQueryContainer from 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/ClickHouse';
import PromQLQueryContainer from 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/promQL';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
@@ -63,12 +64,8 @@ function PanelEditorQueryBuilder({
footer,
stickyHeader = true,
}: PanelEditorQueryBuilderProps): JSX.Element {
// The shared QueryBuilderV2 provider still speaks the legacy PANEL_TYPES; what the
// builder offers for this kind comes from the kind's own declaration.
// The shared QueryBuilderV2 / list-view checks still speak the legacy PANEL_TYPES.
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
// Raw rows: the builder drops its aggregation controls, and with them the trace
// operator that combines aggregated trace queries (V1 parity).
const isListViewPanel = panelKind === 'signoz/ListPanel';
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const isDarkMode = useIsDarkMode();
@@ -115,9 +112,9 @@ function PanelEditorQueryBuilder({
<QueryBuilderV2
panelType={panelType}
filterConfigs={filterConfigs}
showTraceOperator={!isListViewPanel}
showTraceOperator={panelType !== PANEL_TYPES.LIST}
version="v3"
isListViewPanel={isListViewPanel}
isListViewPanel={panelType === PANEL_TYPES.LIST}
queryComponents={{}}
signalSourceChangeEnabled
savePreviousQuery
@@ -151,7 +148,7 @@ function PanelEditorQueryBuilder({
),
children: queryTypeComponents[queryType].component,
}));
}, [panelKind, panelType, filterConfigs, isDarkMode, isListViewPanel]);
}, [panelKind, panelType, filterConfigs, isDarkMode]);
return (
<div

View File

@@ -60,7 +60,6 @@ function renderBuilder(
function lastQueryBuilderProps(): {
panelType: string;
isListViewPanel: boolean;
showTraceOperator: boolean;
filterConfigs: unknown;
} {
const calls = mockQueryBuilderV2.mock.calls;
@@ -116,9 +115,6 @@ describe('PanelEditorQueryBuilder field visibility (driven by the capabilities g
const props = lastQueryBuilderProps();
expect(props.panelType).toBe('graph');
expect(props.isListViewPanel).toBe(false);
// The trace operator combines aggregated trace queries, so it rides along with
// the aggregation controls.
expect(props.showTraceOperator).toBe(true);
expect(props.filterConfigs).toStrictEqual({});
});
@@ -128,7 +124,6 @@ describe('PanelEditorQueryBuilder field visibility (driven by the capabilities g
const props = lastQueryBuilderProps();
expect(props.panelType).toBe('list');
expect(props.isListViewPanel).toBe(true);
expect(props.showTraceOperator).toBe(false);
expect(props.filterConfigs).toStrictEqual({
stepInterval: { isHidden: true, isDisabled: true },
having: { isHidden: true, isDisabled: true },

View File

@@ -1,15 +1,12 @@
import { Spline } from '@signozhq/icons';
import { PANEL_TYPES } from 'constants/queryBuilder';
import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag';
import { EQueryType } from 'types/common/dashboard';
interface PlotTagProps {
/** Authoring mode of the panel's query; undefined when no query exists yet. */
queryType: EQueryType | undefined;
/**
* Panel shows raw rows rather than a plot, so naming the mode the rows were
* "plotted with" would be wrong.
*/
isListViewPanel: boolean;
panelType: PANEL_TYPES;
className?: string;
}
@@ -20,10 +17,10 @@ interface PlotTagProps {
*/
function PlotTag({
queryType,
isListViewPanel,
panelType,
className,
}: PlotTagProps): JSX.Element | null {
if (queryType === undefined || isListViewPanel) {
if (queryType === undefined || panelType === PANEL_TYPES.LIST) {
return null;
}

View File

@@ -7,6 +7,7 @@ import PanelBody from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSection
import PanelHeader from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
import type { AnyPanelInteractionProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/interactions';
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
import { getPanelQueryType } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getPanelQueryType';
import type {
@@ -71,6 +72,7 @@ function PreviewPane({
onClick,
enableDrillDown,
}: PreviewPaneProps): JSX.Element {
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
const queryType = getPanelQueryType(panel);
// Search term is ephemeral preview state, threaded to header + renderer but
@@ -84,7 +86,7 @@ function PreviewPane({
<div className={styles.header}>
<PlotTag
queryType={queryType}
isListViewPanel={panel.spec.plugin.kind === 'signoz/ListPanel'}
panelType={panelType}
className={styles.queryType}
/>
<div className={styles.dateTimeSelector}>

View File

@@ -1,22 +1,30 @@
import { render, screen } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { EQueryType } from 'types/common/dashboard';
import PlotTag from '../PlotTag';
describe('PlotTag', () => {
it('renders the resolved query mode', () => {
render(<PlotTag queryType={EQueryType.PROM} isListViewPanel={false} />);
render(
<PlotTag queryType={EQueryType.PROM} panelType={PANEL_TYPES.TIME_SERIES} />,
);
expect(screen.getByTestId('panel-editor-plot-tag')).toBeInTheDocument();
expect(screen.getByText('PromQL')).toBeInTheDocument();
});
it('renders nothing when there is no query yet', () => {
render(<PlotTag queryType={undefined} isListViewPanel={false} />);
render(<PlotTag queryType={undefined} panelType={PANEL_TYPES.TIME_SERIES} />);
expect(screen.queryByTestId('panel-editor-plot-tag')).not.toBeInTheDocument();
});
it('renders nothing for a list panel (query mode is irrelevant)', () => {
render(<PlotTag queryType={EQueryType.QUERY_BUILDER} isListViewPanel />);
it('renders nothing for list panels (query mode is irrelevant)', () => {
render(
<PlotTag
queryType={EQueryType.QUERY_BUILDER}
panelType={PANEL_TYPES.LIST}
/>,
);
expect(screen.queryByTestId('panel-editor-plot-tag')).not.toBeInTheDocument();
});
});

View File

@@ -4,10 +4,7 @@ import type {
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import {
getPanelDefinition,
isPanelKindSupported,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import {
PANEL_KIND_TO_PANEL_TYPE,
@@ -94,9 +91,8 @@ export function usePanelEditSession({
const query = usePanelQuery({
panel: draft,
panelId,
queryCapabilities: panelDefinition.queryCapabilities,
time,
enabled: isPanelKindSupported(panelKind),
enabled: !!panelDefinition,
});
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({

View File

@@ -6,7 +6,7 @@ import type {
DashboardtypesQueryDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
handleQueryChange,
type PartialPanelTypes,
@@ -146,7 +146,7 @@ export function usePanelTypeSwitch({
);
// Match a fresh list panel's default order so the builder's Order By isn't empty.
const nextQuery =
newKind === 'signoz/ListPanel'
newPanelType === PANEL_TYPES.LIST
? withDefaultListOrder(transformed)
: transformed;
const signal = getBuilderQueries(currentSpec.queries)[0]

View File

@@ -1,14 +1,7 @@
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { OPERATORS } from 'constants/queryBuilder';
import { EQueryType } from 'types/common/dashboard';
import { UNSUPPORTED_PANEL } from '../kinds/UnsupportedPanel/definition';
import { getPanelDefinition, isPanelKindSupported } from '../registry';
import type { PanelQueryCapabilities } from '../types/panelCapabilities';
import { NO_PANEL_ACTIONS } from '../types/panelDefinition';
import {
getHiddenQueryBuilderFields,
getSupportedQueryTypes,
@@ -22,7 +15,6 @@ import type { PanelKind } from '../types/panelKind';
const { QUERY_BUILDER, CLICKHOUSE, PROM } = EQueryType;
const { logs, traces, metrics } = TelemetrytypesSignalDTO;
const { time_series, scalar, raw } = Querybuildertypesv5RequestTypeDTO;
const EXPECTED_QUERY_TYPES: Record<PanelKind, EQueryType[]> = {
'signoz/TimeSeriesPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
@@ -45,117 +37,9 @@ const EXPECTED_SIGNALS: Record<PanelKind, TelemetrytypesSignalDTO[]> = {
'signoz/ListPanel': [logs, traces],
};
// Exhaustive over PanelKind, so a new kind can't ship without stating how its request is
// shaped — the check that used to be implicit in a legacy PANEL_TYPES switch.
const EXPECTED_QUERY_CAPABILITIES: Record<PanelKind, PanelQueryCapabilities> = {
'signoz/TimeSeriesPanel': {
requestType: time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
// Bar bins client-side, so it asks for a widened step interval over a raw series.
'signoz/BarChartPanel': {
requestType: time_series,
formatTableResultForUI: false,
bucketedStepInterval: true,
orderTiebreaker: false,
serverPaginated: false,
},
'signoz/HistogramPanel': {
requestType: time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
'signoz/NumberPanel': {
requestType: scalar,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
'signoz/PieChartPanel': {
requestType: scalar,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
// Only Table asks the server to transpose its scalar result into UI rows.
'signoz/TablePanel': {
requestType: scalar,
formatTableResultForUI: true,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
// Only List reads raw rows, pages them server-side, and needs an order tiebreaker.
'signoz/ListPanel': {
requestType: raw,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: true,
serverPaginated: true,
},
};
const ALL_KINDS = Object.keys(EXPECTED_QUERY_TYPES) as PanelKind[];
describe('panel capabilities guard', () => {
describe('query capabilities', () => {
it.each(ALL_KINDS)('declares how %s shapes its request', (kind) => {
expect(getPanelDefinition(kind).queryCapabilities).toStrictEqual(
EXPECTED_QUERY_CAPABILITIES[kind],
);
});
});
// A dashboard spec written by a newer SigNoz can name a kind this build has no
// definition for. The registry answers with UNSUPPORTED_PANEL rather than nothing, so
// every guard below reads it without first proving a definition exists.
describe('a kind this build cannot render', () => {
const unknownKind = 'signoz/SomeFutureKindPanel' as PanelKind;
it('is not reported as supported', () => {
expect(isPanelKindSupported(unknownKind)).toBe(false);
expect(isPanelKindSupported('signoz/TimeSeriesPanel')).toBe(true);
});
it('still resolves to a definition', () => {
expect(getPanelDefinition(unknownKind)).toBe(UNSUPPORTED_PANEL);
});
it('declares nothing, so it is never offered as authorable', () => {
expect(getSupportedSignals(unknownKind)).toStrictEqual([]);
expect(getSupportedQueryTypes(unknownKind)).toStrictEqual([]);
expect(isSignalSupported(unknownKind, logs)).toBe(false);
expect(
isPanelCombinationValid({ kind: unknownKind, queryType: QUERY_BUILDER }),
).toBe(false);
expect(getHiddenQueryBuilderFields(unknownKind, logs)).toStrictEqual({});
expect(getPanelDefinition(unknownKind).sections).toStrictEqual([]);
});
it('offers no actions', () => {
expect(getPanelDefinition(unknownKind).actions).toStrictEqual(
NO_PANEL_ACTIONS,
);
expect(NO_PANEL_ACTIONS.view).toBe(false);
expect(NO_PANEL_ACTIONS.edit).toBe(false);
expect(NO_PANEL_ACTIONS.drilldown).toBe(false);
});
it('carries an inert query shape, so a stray request can do no harm', () => {
const { queryCapabilities } = getPanelDefinition(unknownKind);
expect(queryCapabilities.requestType).toBe(time_series);
expect(queryCapabilities.serverPaginated).toBe(false);
expect(queryCapabilities.formatTableResultForUI).toBe(false);
});
});
describe('query type support', () => {
it.each(ALL_KINDS)('declares the expected query types for %s', (kind) => {
expect(getSupportedQueryTypes(kind)).toStrictEqual(

View File

@@ -20,12 +20,8 @@ interface NoDataProps {
isFetching?: boolean;
/** When provided, renders a Retry button that re-runs the query. */
onRetry?: () => void;
/**
* The panel this empty state stands in for. Every renderer has it, and it decides
* whether the global "Extend time range" action applies (a panel locked to a fixed
* time preference can't be widened by it) as well as what the action events report.
*/
panel: DashboardtypesPanelDTO;
/** Hides the global "Extend time range" action when this panel is locked to a fixed time preference. */
panel?: DashboardtypesPanelDTO;
'data-testid'?: string;
}
@@ -47,17 +43,19 @@ function NoData({
const globalExtend = useExtendTimeWindow();
// The View modal's local extender wins; the global one only applies to a panel that
// follows the ambient window (a fixed preference can't be widened by it).
const hasFixedTimePreference = panel
? panelHasFixedTimePreference(panel)
: false;
const activeExtend =
viewExtend ?? (panelHasFixedTimePreference(panel) ? undefined : globalExtend);
viewExtend ?? (hasFixedTimePreference ? undefined : globalExtend);
if (isFetching) {
return <PanelLoader />;
}
// `panelType` stays on the event so existing reports keep resolving; `panelKind` is the
// V2 identity, and the only one that can tell two kinds sharing a panel type apart.
const panelKind = panel.spec.plugin.kind;
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
const panelType = panel
? PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind]
: undefined;
const extendAction: PanelMessageAction | undefined =
activeExtend?.canExtend && activeExtend.actionLabel
@@ -67,7 +65,6 @@ function NoData({
void logEvent(DashboardDetailEvents.NoDataAction, {
action: 'extendTime',
panelType,
panelKind,
});
activeExtend.extend();
},
@@ -82,7 +79,6 @@ function NoData({
void logEvent(DashboardDetailEvents.NoDataAction, {
action: 'retry',
panelType,
panelKind,
});
onRetry();
},

View File

@@ -33,12 +33,7 @@ function panelWith(
timePreference?: DashboardtypesTimePreferenceDTO,
): DashboardtypesPanelDTO {
return {
spec: {
plugin: {
kind: 'signoz/TimeSeriesPanel',
spec: { visualization: { timePreference } },
},
},
spec: { plugin: { spec: { visualization: { timePreference } } } },
} as unknown as DashboardtypesPanelDTO;
}
@@ -49,7 +44,7 @@ describe('NoData', () => {
});
it('renders the empty-state title and hint', () => {
render(<NoData panel={panelWith()} />);
render(<NoData />);
expect(screen.getByTestId('panel-no-data')).toBeInTheDocument();
expect(screen.getByText('No data in this time range')).toBeInTheDocument();
@@ -60,7 +55,7 @@ describe('NoData', () => {
it('offers to extend the window as the primary action', () => {
mockUseExtendTimeWindow.mockReturnValue(extender());
render(<NoData panel={panelWith()} />);
render(<NoData />);
const action = screen.getByTestId('panel-no-data-action');
expect(action).toHaveTextContent('Extend time range');
@@ -73,7 +68,7 @@ describe('NoData', () => {
it('renders both Extend (primary) and Retry (secondary) when a retry handler is given', () => {
const onRetry = jest.fn();
mockUseExtendTimeWindow.mockReturnValue(extender());
render(<NoData onRetry={onRetry} panel={panelWith()} />);
render(<NoData onRetry={onRetry} />);
expect(screen.getByTestId('panel-no-data-action')).toHaveTextContent(
'Extend time range',
@@ -87,7 +82,7 @@ describe('NoData', () => {
it('falls back to Retry as the sole action when the window cannot be widened', () => {
const onRetry = jest.fn();
render(<NoData onRetry={onRetry} panel={panelWith()} />);
render(<NoData onRetry={onRetry} />);
const action = screen.getByTestId('panel-no-data-action');
expect(action).toHaveTextContent('Retry');
@@ -106,7 +101,7 @@ describe('NoData', () => {
useViewPanelStore.setState({
viewPanelExtendWindow: extender({ extend: storeExtend }),
});
render(<NoData panel={panelWith()} />);
render(<NoData />);
fireEvent.click(screen.getByTestId('panel-no-data-action'));
expect(storeExtend).toHaveBeenCalledTimes(1);
@@ -114,7 +109,7 @@ describe('NoData', () => {
});
it('renders no action when nothing can be widened and no retry handler', () => {
render(<NoData panel={panelWith()} />);
render(<NoData />);
expect(screen.queryByTestId('panel-no-data-action')).not.toBeInTheDocument();
expect(
@@ -124,7 +119,7 @@ describe('NoData', () => {
it('shows the panel loader (not the empty state) while refetching', () => {
mockUseExtendTimeWindow.mockReturnValue(extender());
render(<NoData isFetching panel={panelWith()} />);
render(<NoData isFetching />);
expect(screen.getByTestId('panel-loading')).toBeInTheDocument();
expect(screen.queryByTestId('panel-no-data')).not.toBeInTheDocument();
@@ -133,7 +128,7 @@ describe('NoData', () => {
it('honours the data-testid override for the number panel', () => {
mockUseExtendTimeWindow.mockReturnValue(extender());
render(<NoData data-testid="number-panel-no-data" panel={panelWith()} />);
render(<NoData data-testid="number-panel-no-data" />);
expect(screen.getByTestId('number-panel-no-data')).toBeInTheDocument();
});

View File

@@ -7,7 +7,6 @@ import { PanelMode } from 'container/DashboardContainer/visualization/panels/typ
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
import { StackMode } from 'lib/uPlotV2/config/types';
import {
flattenTimeSeries,
getExecStats,
@@ -220,9 +219,7 @@ function BarPanelRenderer({
height={containerDimensions.height}
syncMode={dashboardPreference?.syncMode}
syncFilterMode={dashboardPreference?.syncFilterMode}
stack={
spec.visualization?.stackedBarChart ? StackMode.Normal : StackMode.None
}
isStackedBarChart={spec.visualization?.stackedBarChart ?? false}
renderTooltipFooter={renderTooltipFooter}
onClick={enableDrillDown ? handleChartClick : undefined}
/>

View File

@@ -1,10 +1,7 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
@@ -23,15 +20,6 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
EQueryType.PROM,
],
queryBuilderFields: {},
// Bars are binned client-side from a raw time series, so the request asks for a
// step interval wide enough to keep the bar count readable (V1 parity).
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
formatTableResultForUI: false,
bucketedStepInterval: true,
orderTiebreaker: false,
serverPaginated: false,
},
actions: {
view: true,
edit: true,

View File

@@ -1,5 +1,7 @@
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
@@ -46,7 +48,7 @@ export function buildBarChartConfig({
}: BuildBarChartConfigArgs): UPlotConfigBuilder {
const builder = buildBaseConfig({
panelId,
isTimeAxis: true,
panelType: PANEL_TYPES.BAR,
isDarkMode,
timezone,
panelMode,
@@ -99,6 +101,12 @@ function addSeries({
}: AddSeriesArgs): void {
const colorMapping = spec.legend?.customColors ?? {};
if (spec.visualization?.stackedBarChart) {
// uPlot uses 1-based series indices (index 0 is the timestamp axis);
// `+1` keeps the band targets aligned with the series we're about to add.
builder.setBands(getInitialStackedBands(series.length + 1));
}
series.forEach((s) => {
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);

View File

@@ -1,10 +1,7 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
@@ -23,15 +20,6 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
EQueryType.PROM,
],
queryBuilderFields: {},
// Buckets are computed client-side from the raw series, so the request is a plain
// time series — the bucket count is a display concern, not a query one.
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
actions: {
view: true,
edit: true,

View File

@@ -1,5 +1,6 @@
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
@@ -43,7 +44,7 @@ export function buildHistogramConfig({
}: BuildHistogramConfigArgs): UPlotConfigBuilder {
const builder = buildBaseConfig({
panelId,
isTimeAxis: false,
panelType: PANEL_TYPES.HISTOGRAM,
isDarkMode,
timezone,
panelMode,

View File

@@ -1,10 +1,7 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { OPERATORS } from 'constants/queryBuilder';
import { EQueryType } from 'types/common/dashboard';
@@ -33,15 +30,6 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
},
},
sections,
// The only kind reading raw rows: they page server-side, and the sort needs a
// tiebreaker so a duplicated sort key can't repeat or skip a row across pages.
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.raw,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: true,
serverPaginated: true,
},
actions: {
view: true,
edit: true,

View File

@@ -1,10 +1,7 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
@@ -23,13 +20,6 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
EQueryType.PROM,
],
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
actions: {
view: true,
edit: true,

View File

@@ -1,10 +1,7 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
@@ -19,13 +16,6 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
],
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
actions: {
view: true,
edit: true,

View File

@@ -1,10 +1,7 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/TablePanel'> = {
@@ -19,14 +16,6 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
],
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
queryBuilderFields: {},
// The only kind that asks the server to transpose its scalar result into UI rows.
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
formatTableResultForUI: true,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
// Tables carry tabular data worth exporting (V1 parity: download is table-only).
actions: {
view: true,

View File

@@ -1,10 +1,7 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
@@ -23,13 +20,6 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
EQueryType.PROM,
],
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
actions: {
view: true,
edit: true,

View File

@@ -1,5 +1,6 @@
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import {
buildBaseConfig,
@@ -65,7 +66,7 @@ export function buildTimeSeriesConfig({
}: BuildTimeSeriesConfigArgs): UPlotConfigBuilder {
const builder = buildBaseConfig({
panelId,
isTimeAxis: true,
panelType: PANEL_TYPES.TIME_SERIES,
isDarkMode,
timezone,
panelMode,

View File

@@ -1,26 +0,0 @@
import { CircleHelp } from '@signozhq/icons';
import PanelMessage from '../../components/PanelMessage/PanelMessage';
import PanelStyles from '../../panel.module.scss';
/**
* Body for a panel whose kind this build has no renderer for — a spec written by a newer
* SigNoz names a visualization that didn't exist when this client shipped. Says so in
* place of the chart, so the panel keeps its slot in the layout instead of leaving a hole.
*/
function UnsupportedPanelRenderer(): JSX.Element {
return (
<div
data-testid="unsupported-panel-renderer"
className={PanelStyles.panelContainer}
>
<PanelMessage
icon={<CircleHelp size={18} />}
title="Unsupported panel type"
description="This panel was built with a newer version of SigNoz. Upgrade to view it."
/>
</div>
);
}
export default UnsupportedPanelRenderer;

View File

@@ -1,34 +0,0 @@
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
import {
NO_PANEL_ACTIONS,
type RenderablePanelDefinition,
} from '../../types/panelDefinition';
import Renderer from './Renderer';
/**
* Stand-in definition for a kind that isn't in the registry, so `getPanelDefinition`
* always resolves and no caller has to branch on a missing one. It declares nothing: no
* signals, no query types, no config sections and no actions — an unknown kind can't be
* queried, configured or acted on, only shown as unsupported.
*
* `kind` carries a sentinel that no API enum value can collide with; the cast is the one
* place this definition steps outside `PanelKind`.
*/
export const UNSUPPORTED_PANEL: RenderablePanelDefinition = {
kind: '<unsupported>' as RenderablePanelDefinition['kind'],
displayName: 'Unsupported panel',
Renderer,
sections: [],
supportedSignals: [],
supportedQueryTypes: [],
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
actions: NO_PANEL_ACTIONS,
};

View File

@@ -5,7 +5,6 @@ import { definition as PieChart } from './kinds/PieChartPanel/definition';
import { definition as TimeSeries } from './kinds/TimeSeriesPanel/definition';
import { definition as Table } from './kinds/TablePanel/definition';
import { definition as List } from './kinds/ListPanel/definition';
import { UNSUPPORTED_PANEL } from './kinds/UnsupportedPanel/definition';
import type {
PanelRegistry,
RenderablePanelDefinition,
@@ -23,24 +22,8 @@ export const PANELS: PanelRegistry = {
[List.kind]: List,
};
/**
* Whether this build can render the kind. `PanelKind` spans every kind the API declares,
* but a dashboard spec written by a newer SigNoz can name one this client has never heard
* of — so ask before doing work on a panel's behalf, such as fetching its data.
*/
export function isPanelKindSupported(kind: PanelKind): boolean {
return kind in PANELS;
}
/**
* The definition for a kind — always one. An unregistered kind resolves to
* {@link UNSUPPORTED_PANEL}, which declares no capabilities and renders as unsupported, so
* callers read a definition's fields without first proving it exists.
*/
export function getPanelDefinition(kind: PanelKind): RenderablePanelDefinition {
// Single intentional cast widening the per-kind Renderer to the kind-agnostic
// prop surface (a per-kind renderer can't be statically validated against the union).
return (
(PANELS[kind] as RenderablePanelDefinition | undefined) ?? UNSUPPORTED_PANEL
);
return PANELS[kind] as RenderablePanelDefinition;
}

View File

@@ -1,7 +1,4 @@
import {
Querybuildertypesv5RequestTypeDTO,
type TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
/**
@@ -21,30 +18,3 @@ export type FilterConfigsPartial = NonNullable<
export type QueryBuilderFieldRule = {
default?: FilterConfigsPartial;
} & Partial<Record<TelemetrytypesSignalDTO, FilterConfigsPartial>>;
/**
* How a kind's query-range request is shaped. Declared per-kind in
* `kinds/<Kind>/definition.ts` and read through the capabilities guard, so no V2 code
* has to translate a panel kind into the legacy `PANEL_TYPES` enum to answer these.
*/
export interface PanelQueryCapabilities {
/** V5 request type the panel's data comes back as. */
requestType: Querybuildertypesv5RequestTypeDTO;
/** Server transposes the scalar result into UI table rows (`formatOptions.formatTableResultForUI`). */
formatTableResultForUI: boolean;
/**
* Widen the step interval to cap how many buckets come back — kinds that bin
* client-side from a raw time series rather than plotting every point.
*/
bucketedStepInterval: boolean;
/**
* Append a deterministic tiebreaker to the query's `order` so offset paging over raw
* rows can't repeat or skip a row when the sort key has duplicates.
*/
orderTiebreaker: boolean;
/**
* Rows page server-side via `offset`/`limit`. AND-ed at the call site with "the query
* carries no explicit limit" — an explicit limit means the user asked for a fixed set.
*/
serverPaginated: boolean;
}

View File

@@ -5,10 +5,7 @@ import type { EQueryType } from 'types/common/dashboard';
import type { SectionConfig } from './sections';
import type { AnyPanelInteractionProps } from './interactions';
import type { PanelKind } from './panelKind';
import type {
PanelQueryCapabilities,
QueryBuilderFieldRule,
} from './panelCapabilities';
import type { QueryBuilderFieldRule } from './panelCapabilities';
import type { BaseRendererProps, PanelRendererProps } from './rendererProps';
/** Export formats offered under the single "Download" action. */
@@ -42,24 +39,6 @@ export interface PanelActionCapabilities {
drilldown: boolean;
}
/**
* No actions at all — for a kind this build can't render, where every action would act on
* a panel body that isn't there. See `UNSUPPORTED_PANEL`.
*/
export const NO_PANEL_ACTIONS: PanelActionCapabilities = {
view: false,
edit: false,
clone: false,
download: {
[DownloadFormat.CSV]: false,
[DownloadFormat.PNG]: false,
[DownloadFormat.SVG]: false,
},
createAlert: false,
search: false,
drilldown: false,
};
export interface PanelDefinition<K extends PanelKind = PanelKind> {
kind: K;
displayName: string;
@@ -71,8 +50,6 @@ export interface PanelDefinition<K extends PanelKind = PanelKind> {
supportedQueryTypes: EQueryType[];
/** Query-builder fields this kind hides/disables, optionally per signal (`{}` hides none). */
queryBuilderFields: QueryBuilderFieldRule;
/** How this kind's query-range request is shaped (request type, paging, result formatting). */
queryCapabilities: PanelQueryCapabilities;
actions: PanelActionCapabilities;
}

View File

@@ -1,7 +1,7 @@
import { buildDefaultQueries } from '../buildDefaultQueries';
describe('buildDefaultQueries', () => {
it('seeds a list panel with a runnable logs query ordered by timestamp desc', () => {
it('seeds a List panel with a runnable logs query ordered by timestamp desc', () => {
const queries = buildDefaultQueries('signoz/ListPanel');
expect(queries).toHaveLength(1);
@@ -13,7 +13,7 @@ describe('buildDefaultQueries', () => {
expect(serialized.toLowerCase()).toContain('logs');
});
it('seeds a list panel without a limit so it pages server-side by default', () => {
it('seeds a List panel without a limit so it pages server-side by default', () => {
const queries = buildDefaultQueries('signoz/ListPanel');
// A limit would make usePanelQuery treat the panel as a static, unpaged list.
@@ -21,7 +21,7 @@ describe('buildDefaultQueries', () => {
expect(spec.limit).toBeUndefined();
});
it('seeds no query for plotted kinds (they seed from the builder)', () => {
it('seeds no query for non-List kinds (they seed from the builder)', () => {
expect(buildDefaultQueries('signoz/TimeSeriesPanel')).toStrictEqual([]);
expect(buildDefaultQueries('signoz/NumberPanel')).toStrictEqual([]);
});

View File

@@ -3,6 +3,7 @@ import type {
DashboardtypesThresholdWithLabelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import onClickPlugin, {
OnClickPluginOpts,
@@ -25,11 +26,7 @@ import {
*/
export interface BuildBaseConfigArgs {
panelId: string;
/**
* X axis plots timestamps, so its ticks format as dates/times. Each kind states this
* for itself — a bucketed x axis (histogram) passes false.
*/
isTimeAxis: boolean;
panelType: PANEL_TYPES;
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
@@ -66,7 +63,7 @@ export interface BuildBaseConfigArgs {
*/
export function buildBaseConfig({
panelId,
isTimeAxis,
panelType,
isDarkMode,
timezone,
panelMode,
@@ -136,7 +133,7 @@ export function buildBaseConfig({
side: 2,
isDarkMode,
isLogScale,
isTimeAxis,
panelType,
});
builder.addAxis({
@@ -146,6 +143,7 @@ export function buildBaseConfig({
isDarkMode,
isLogScale,
yAxisUnit,
panelType,
});
return builder;

View File

@@ -1,15 +1,14 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { listViewInitialLogQuery } from 'constants/queryBuilder';
import { listViewInitialLogQuery, PANEL_TYPES } from 'constants/queryBuilder';
import { toPerses } from '../../queryV5/persesQueryAdapters';
import { PANEL_KIND_TO_PANEL_TYPE, type PanelKind } from '../types/panelKind';
/** Seed query for a new panel. Only a list panel needs one (logs, timestamp desc) so its
/** Seed query for a new panel. Only List needs one (logs, timestamp desc) so its
* preview runs on open; other kinds start empty and seed from the builder. */
export function buildDefaultQueries(kind: PanelKind): DashboardtypesQueryDTO[] {
if (kind !== 'signoz/ListPanel') {
return [];
if (PANEL_KIND_TO_PANEL_TYPE[kind] === PANEL_TYPES.LIST) {
return toPerses(listViewInitialLogQuery, PANEL_TYPES.LIST);
}
// `toPerses` pivots through the V1 `Query`, which is still keyed by panel type.
return toPerses(listViewInitialLogQuery, PANEL_KIND_TO_PANEL_TYPE[kind]);
return [];
}

View File

@@ -1,10 +1,7 @@
import { useState } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import ContextMenu from 'periscope/components/ContextMenu';
import {
getPanelDefinition,
isPanelKindSupported,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import {
getPanelTimePreference,
panelTimePreferenceLabel,
@@ -53,22 +50,15 @@ function Panel({
// Header search: only kinds that declare it render the box. The term is owned
// here and threaded to both the header (input) and renderer (filter).
const searchable = panelDefinition.actions.search;
const searchable = !!panelDefinition?.actions.search;
const [searchTerm, setSearchTerm] = useState('');
// Only an explicit false defers the fetch: `isVisible` is undefined wherever no
// observer reports visibility (the View modal, the editor preview), and those panels
// are on screen by construction.
const isOffScreen = isVisible === false;
const { data, isFetching, isPreviousData, error, refetch, pagination } =
usePanelQuery({
panel,
panelId,
queryCapabilities: panelDefinition.queryCapabilities,
// Lazy: fetch once on screen, and never for a kind this build can't render —
// the data would have nothing to render into.
enabled: isPanelKindSupported(panelKind) && !isOffScreen,
// Lazy: fetch only once on screen (undefined → visible) and a renderer exists.
enabled: !!panelDefinition && isVisible !== false,
});
const { onDragSelect, dashboardPreference } = usePanelInteractions();
@@ -95,23 +85,25 @@ function Panel({
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
/>
<PanelBody
panelDefinition={panelDefinition}
panel={panel}
panelId={panelId}
data={data}
isFetching={isFetching}
isVisible={isVisible}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
dashboardPreference={dashboardPreference}
searchTerm={searchable ? searchTerm : undefined}
pagination={pagination}
onClick={drilldown.onPanelClick}
enableDrillDown={drilldown.enableDrillDown}
/>
{panelDefinition && (
<PanelBody
panelDefinition={panelDefinition}
panel={panel}
panelId={panelId}
data={data}
isFetching={isFetching}
isVisible={isVisible}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
dashboardPreference={dashboardPreference}
searchTerm={searchable ? searchTerm : undefined}
pagination={pagination}
onClick={drilldown.onPanelClick}
enableDrillDown={drilldown.enableDrillDown}
/>
)}
<ContextMenu {...drilldown.contextMenuProps} />
</div>
);

View File

@@ -0,0 +1,64 @@
import { type KeyboardEvent, useCallback } from 'react';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import { PANEL_TYPES } from 'constants/queryBuilder';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import styles from './ViewPanelModal.module.scss';
interface ViewPanelQueryBuilderProps {
panelType: PANEL_TYPES;
/** Preview fetch in flight — drives the Run/Cancel button state. */
isLoadingQueries: boolean;
/** Run the current query (Run Query button / ⌘↵). */
onStageRunQuery: () => void;
/** Abort the in-flight preview fetch. */
onCancelQuery: () => void;
}
/**
* Drilldown query editor for the View modal. Mirrors V1's FullView: the query builder
* rows + a "Run Query" button, with NO query-type tabs (ClickHouse/PromQL) — drilldown
* is query-builder only, exactly as V1.
*/
function ViewPanelQueryBuilder({
panelType,
isLoadingQueries,
onStageRunQuery,
onCancelQuery,
}: ViewPanelQueryBuilderProps): JSX.Element {
const handleKeyDownCapture = useCallback(
(event: KeyboardEvent<HTMLDivElement>): void => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
onStageRunQuery();
}
},
[onStageRunQuery],
);
return (
<div
className={styles.queryBuilder}
data-testid="view-panel-query-builder"
onKeyDownCapture={handleKeyDownCapture}
role="presentation"
>
<QueryBuilderV2
panelType={panelType}
version="v3"
isListViewPanel={panelType === PANEL_TYPES.LIST}
signalSourceChangeEnabled
/>
<div className={styles.queryBuilderToolbar}>
<RightToolbarActions
handleCancelQuery={onCancelQuery}
onStageRunQuery={onStageRunQuery}
isLoadingQueries={isLoadingQueries}
/>
</div>
</div>
);
}
export default ViewPanelQueryBuilder;

View File

@@ -148,9 +148,7 @@ describe('useCreateAlertFromPanel', () => {
expect(mockBuildQueryRangeRequest).toHaveBeenCalledWith(
expect.objectContaining({
queries: panel.spec.queries,
queryCapabilities: expect.objectContaining({
requestType: 'time_series',
}),
panelType: PANEL_TYPES.TIME_SERIES,
variables: { service: { type: 'query', value: 'checkout' } },
}),
);

View File

@@ -81,7 +81,6 @@ export function useClonePanel({
void logEvent(DashboardDetailEvents.PanelAction, {
action: 'clone',
panelType: PANEL_KIND_TO_PANEL_TYPE[source.panel.spec.plugin.kind],
panelKind: source.panel.spec.plugin.kind,
panelId,
dashboardId,
});

View File

@@ -7,7 +7,6 @@ import { useReplaceVariables } from 'api/generated/services/querier';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { getPanelQueryType } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getPanelQueryType';
import { buildQueryRangeRequest } from 'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest';
@@ -45,15 +44,11 @@ export function useCreateAlertFromPanel(): (
return useCallback(
(panel: DashboardtypesPanelDTO, panelId: string): void => {
const panelKind = panel.spec.plugin.kind;
// Alerts are a V1 surface: the query pivots through the V1 `Query` shape and the
// URL carries a legacy panel type, so this flow keeps translating.
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
void logEvent('Dashboard Detail: Panel action', {
action: 'createAlerts',
panelType,
panelKind,
dashboardId,
widgetId: panelId,
queryType: getPanelQueryType(panel),
@@ -67,7 +62,7 @@ export function useCreateAlertFromPanel(): (
// Redux global time is nanoseconds; the request DTO takes epoch ms.
const request = buildQueryRangeRequest({
queries: panel.spec.queries,
queryCapabilities: getPanelDefinition(panelKind).queryCapabilities,
panelType,
startMs: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
endMs: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
variables,

View File

@@ -42,7 +42,6 @@ export function useDeletePanel({
}
const removed = section.items.find((i) => i.id === panelId);
const removedKind = removed?.panel?.spec.plugin.kind;
const nextItems = section.items.filter((i) => i.id !== panelId);
try {
await patchAsync([
@@ -51,15 +50,9 @@ export function useDeletePanel({
]);
void logEvent(DashboardDetailEvents.PanelAction, {
action: 'delete',
// An item ref can outlive its panel, so both fields go on together or
// not at all: `panelType` keeps existing reports resolving, `panelKind`
// is the V2 identity.
...(removedKind
? {
panelType: PANEL_KIND_TO_PANEL_TYPE[removedKind],
panelKind: removedKind,
}
: {}),
panelType: removed?.panel
? PANEL_KIND_TO_PANEL_TYPE[removed.panel.spec.plugin.kind]
: undefined,
panelId,
dashboardId,
});

View File

@@ -43,7 +43,6 @@ export function useDownloadPanelCsv({
void logEvent(DashboardDetailEvents.PanelExported, {
format: 'csv',
panelType: PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind],
panelKind: panel.spec.plugin.kind,
});
}, [canDownloadCsv, fileName, panel, data]);
}

View File

@@ -128,14 +128,11 @@ export function useDrilldown(
const onPanelClick = useCallback(
(payload: DrilldownClickPayload): void => {
void logEvent(DashboardDetailEvents.DrilldownOpened, {
panelType,
panelKind: kind,
});
void logEvent(DashboardDetailEvents.DrilldownOpened, { panelType });
setSubMenu(DrilldownSubMenu.Base);
onClick(payload.coordinates, payload.context);
},
[onClick, panelType, kind],
[onClick, panelType],
);
const handleClose = useCallback((): void => {
@@ -179,8 +176,7 @@ export function useDrilldown(
const { resolvedQuery, isResolving } = useResolvedDrilldownQuery({
queries,
panelKind: kind,
queryCapabilities: getPanelDefinition(kind).queryCapabilities,
panelType,
v1Query,
enabled: showAggregateMenu,
});

View File

@@ -53,7 +53,6 @@ export function useMovePanelToSection({
if (!moved) {
return;
}
const movedKind = moved.panel?.spec.plugin.kind;
const sourceItems = source.items.filter((i) => i.id !== panelId);
// Land at the section bottom, not backfilled into a gap — least disruptive
@@ -72,15 +71,9 @@ export function useMovePanelToSection({
);
void logEvent(DashboardDetailEvents.PanelAction, {
action: 'move',
// An item ref can outlive its panel, so both fields go on together or
// not at all: `panelType` keeps existing reports resolving, `panelKind`
// is the V2 identity.
...(movedKind
? {
panelType: PANEL_KIND_TO_PANEL_TYPE[movedKind],
panelKind: movedKind,
}
: {}),
panelType: moved.panel
? PANEL_KIND_TO_PANEL_TYPE[moved.panel.spec.plugin.kind]
: undefined,
panelId,
dashboardId,
});

View File

@@ -3,11 +3,7 @@ import { useEffect, useMemo } from 'react';
import { useSelector } from 'react-redux';
import { useReplaceVariables } from 'api/generated/services/querier';
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import type { PanelQueryCapabilities } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelCapabilities';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { buildQueryRangeRequest } from 'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest';
import { envelopesToQuery } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
import { selectResolvedVariables } from 'pages/DashboardPageV2/DashboardContainer/store/slices/variableSelectionSlice';
@@ -19,9 +15,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
interface UseResolvedDrilldownQueryArgs {
/** Panel's perses queries — the substitution source (carries the `$var` refs). */
queries: DashboardtypesQueryDTO[];
panelKind: PanelKind;
/** The panel kind's declared query capabilities — shapes the substitution request. */
queryCapabilities: PanelQueryCapabilities;
panelType: PANEL_TYPES;
/** The raw V5→V1 query; the fallback until substitution resolves / when no vars exist. */
v1Query: Query;
/** Resolve only while the aggregate menu is open (V1 parity: fires when it appears). */
@@ -44,8 +38,7 @@ interface UseResolvedDrilldownQueryResult {
*/
export function useResolvedDrilldownQuery({
queries,
panelKind,
queryCapabilities,
panelType,
v1Query,
enabled,
}: UseResolvedDrilldownQueryArgs): UseResolvedDrilldownQueryResult {
@@ -67,7 +60,7 @@ export function useResolvedDrilldownQuery({
substituteVars({
data: buildQueryRangeRequest({
queries,
queryCapabilities,
panelType,
startMs: Math.floor(minTime / 1e6),
endMs: Math.floor(maxTime / 1e6),
variables,
@@ -77,7 +70,7 @@ export function useResolvedDrilldownQuery({
enabled,
hasVariables,
queries,
queryCapabilities,
panelType,
minTime,
maxTime,
variables,
@@ -88,13 +81,8 @@ export function useResolvedDrilldownQuery({
if (!hasVariables || !data) {
return v1Query;
}
// View-in-X navigates to a V1 explorer, so the resolved query crosses back into the
// V1 `Query` shape — the one place this hook still needs a legacy panel type.
return envelopesToQuery(
data.data.compositeQuery?.queries ?? [],
PANEL_KIND_TO_PANEL_TYPE[panelKind],
);
}, [hasVariables, data, v1Query, panelKind]);
return envelopesToQuery(data.data.compositeQuery?.queries ?? [], panelType);
}, [hasVariables, data, v1Query, panelType]);
return { resolvedQuery, isResolving: enabled && hasVariables && isLoading };
}

View File

@@ -1,11 +1,7 @@
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { act, renderHook } from '@testing-library/react';
import {
type DashboardtypesPanelDTO,
Querybuildertypesv5RequestTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PanelQueryCapabilities } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelCapabilities';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
@@ -58,23 +54,6 @@ function panelWith(
} as unknown as DashboardtypesPanelDTO;
}
// The capability blocks TimeSeries and List declare. Passed in rather than resolved from
// the registry: the hook takes them as input, and importing the registry here would pull
// every panel renderer (and the app's API client) into this suite.
const TIME_SERIES_CAPABILITIES: PanelQueryCapabilities = {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
};
const LIST_PANEL_CAPABILITIES: PanelQueryCapabilities = {
...TIME_SERIES_CAPABILITIES,
requestType: Querybuildertypesv5RequestTypeDTO.raw,
orderTiebreaker: true,
serverPaginated: true,
};
function builderPanel(): DashboardtypesPanelDTO {
return panelWith('signoz/TimeSeriesPanel', {
name: 'A',
@@ -121,13 +100,7 @@ beforeEach(() => {
describe('usePanelQuery', () => {
it('builds the generated V5 request DTO directly from panel.spec.queries', () => {
renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
);
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
const [{ requestPayload }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(requestPayload.schemaVersion).toBe('v1');
expect(requestPayload.compositeQuery.queries).toStrictEqual([
@@ -139,30 +112,30 @@ describe('usePanelQuery', () => {
});
it('converts redux nanosecond time to epoch ms on the request', () => {
renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
);
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
const [{ requestPayload }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(requestPayload.start).toBe(1_000_000_000);
expect(requestPayload.end).toBe(2_000_000_000);
});
// Which requestType each kind declares is asserted in
// Panels/__tests__/capabilities.test.ts; here it only has to reach the request.
it('sends the requestType from the declared query capabilities', () => {
it.each([
['signoz/TimeSeriesPanel', 'time_series'],
['signoz/ListPanel', 'raw'],
// HISTOGRAM and BAR panels bin/derive from raw time-series data
// client-side, so the backend must receive `time_series` (V1 parity).
['signoz/HistogramPanel', 'time_series'],
['signoz/BarChartPanel', 'time_series'],
['signoz/NumberPanel', 'scalar'],
['signoz/PieChartPanel', 'scalar'],
])('%s panel sends requestType=%s', (panelKind, requestType) => {
renderHook(() =>
usePanelQuery({
panel: panelWith('signoz/ListPanel', { name: 'A', signal: 'logs' }),
panel: panelWith(panelKind, { name: 'A', signal: 'logs' }),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
);
const [{ requestPayload }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(requestPayload.requestType).toBe('raw');
expect(requestPayload.requestType).toBe(requestType);
});
it('exposes the raw V5 response, request payload, and legend map on data', () => {
@@ -175,11 +148,7 @@ describe('usePanelQuery', () => {
});
const { result } = renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.data.response).toBe(v5Response);
@@ -189,11 +158,7 @@ describe('usePanelQuery', () => {
it('exposes an undefined response before data arrives', () => {
const { result } = renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.data.response).toBeUndefined();
});
@@ -206,11 +171,7 @@ describe('usePanelQuery', () => {
error: new Error('boom'),
});
const { result } = renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.error?.message).toBe('boom');
});
@@ -225,11 +186,7 @@ describe('usePanelQuery', () => {
error: null,
});
const { result } = renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.isLoading).toBe(false);
expect(result.current.isFetching).toBe(true);
@@ -243,11 +200,7 @@ describe('usePanelQuery', () => {
error: null,
});
const { result } = renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.isLoading).toBe(true);
});
@@ -260,23 +213,14 @@ describe('usePanelQuery', () => {
error: undefined,
});
const { result } = renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.error).toBeNull();
});
it('passes enabled=false to the fetch hook when the caller disables it', () => {
renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
enabled: false,
}),
usePanelQuery({ panel: builderPanel(), panelId: 'p1', enabled: false }),
);
const [{ enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(enabled).toBe(false);
@@ -284,12 +228,7 @@ describe('usePanelQuery', () => {
it('auto-disables the fetch when the panel has no queries (even with enabled=true)', () => {
renderHook(() =>
usePanelQuery({
panel: emptyPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
enabled: true,
}),
usePanelQuery({ panel: emptyPanel(), panelId: 'p1', enabled: true }),
);
const [{ enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(enabled).toBe(false);
@@ -304,7 +243,6 @@ describe('usePanelQuery', () => {
aggregations: [{}],
}),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
);
const [{ enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
@@ -313,13 +251,7 @@ describe('usePanelQuery', () => {
it('composes a react-query cache key that includes panelId, time range, kind, and queries', () => {
const panel = builderPanel();
renderHook(() =>
usePanelQuery({
panel,
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
);
renderHook(() => usePanelQuery({ panel, panelId: 'p1' }));
const [{ queryKey }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(queryKey).toStrictEqual(
expect.arrayContaining([
@@ -338,7 +270,6 @@ describe('usePanelQuery', () => {
renderHook(() =>
usePanelQuery({
panel,
queryCapabilities: TIME_SERIES_CAPABILITIES,
panelId: 'p1',
time: { startMs: 1_700_000_000_000, endMs: 1_700_000_600_000 },
}),
@@ -365,7 +296,6 @@ describe('usePanelQuery', () => {
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
time: { startMs: 1_700_000_000_000.546, endMs: 1_700_000_600_000.999 },
}),
);
@@ -386,11 +316,7 @@ describe('usePanelQuery', () => {
it('exposes server paging at the default page size when the query has no limit', () => {
const { result } = renderHook(() =>
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
expect(result.current.pagination).toBeDefined();
expect(result.current.pagination?.pageSize).toBe(25);
@@ -401,34 +327,20 @@ describe('usePanelQuery', () => {
it('disables the server pager when the query has an explicit limit (V1 parity)', () => {
const { result } = renderHook(() =>
usePanelQuery({
panel: listPanel({ limit: 100 }),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
usePanelQuery({ panel: listPanel({ limit: 100 }), panelId: 'p1' }),
);
expect(result.current.pagination).toBeUndefined();
});
it('keeps previous data while paging so the table/pager stay mounted on page change', () => {
renderHook(() =>
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
);
renderHook(() => usePanelQuery({ panel: listPanel({}), panelId: 'p1' }));
const [{ keepPreviousData }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(keepPreviousData).toBe(true);
});
it('changes the page size (and re-requests with the new limit) via setPageSize', () => {
const { result } = renderHook(() =>
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
act(() => result.current.pagination?.setPageSize(50));
@@ -468,11 +380,7 @@ describe('usePanelQuery', () => {
it('starts on page 0 with no prev/next and does not throw before data arrives', () => {
const { result } = renderHook(() =>
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
expect(result.current.pagination?.pageIndex).toBe(0);
expect(result.current.pagination?.canPrev).toBe(false);
@@ -484,33 +392,21 @@ describe('usePanelQuery', () => {
// window/cursor path), so a full page is the has-more signal.
withResponse(rawResponse(25));
const fullPage = renderHook(() =>
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
expect(fullPage.result.current.pagination?.canNext).toBe(true);
// Partial page, no cursor → the last page.
withResponse(rawResponse(3));
const partialPage = renderHook(() =>
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
expect(partialPage.result.current.pagination?.canNext).toBe(false);
// Cursor present (even on a partial page) → more rows (timestamp window path).
withResponse(rawResponse(3, 'cursor-1'));
const withCursor = renderHook(() =>
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
expect(withCursor.result.current.pagination?.canNext).toBe(true);
});
@@ -520,13 +416,7 @@ describe('usePanelQuery', () => {
// Stable panel reference: a fresh one each render would change the
// `queries` identity and trip the offset-reset effect (real props are stable).
const panel = listPanel({});
const { result } = renderHook(() =>
usePanelQuery({
panel,
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
);
const { result } = renderHook(() => usePanelQuery({ panel, panelId: 'p1' }));
expect(result.current.pagination?.pageIndex).toBe(0);
act(() => result.current.pagination?.goNext());
@@ -538,11 +428,7 @@ describe('usePanelQuery', () => {
it('stays defined and zero-paged for a non-raw (scalar) response', () => {
withResponse({ data: { type: 'scalar', data: { results: [] } } });
const { result } = renderHook(() =>
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
expect(result.current.pagination).toBeDefined();
expect(result.current.pagination?.canNext).toBe(false);
@@ -551,11 +437,7 @@ describe('usePanelQuery', () => {
it('ignores a non-positive page size so paging never goes invalid', () => {
const { result } = renderHook(() =>
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
act(() => result.current.pagination?.setPageSize(0));
expect(result.current.pagination?.pageSize).toBe(25);
@@ -574,26 +456,14 @@ describe('usePanelQuery', () => {
it('caches for DASHBOARD_CACHE_TIME when auto-refresh is disabled', () => {
withAutoRefreshDisabled(true);
renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
);
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
const [{ cacheTime }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(cacheTime).toBe(DASHBOARD_CACHE_TIME);
});
it('drops cacheTime to 0 when auto-refresh is enabled', () => {
withAutoRefreshDisabled(false);
renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
);
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
const [{ cacheTime }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(cacheTime).toBe(DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED);
});

View File

@@ -3,6 +3,7 @@ import { useQueryClient } from 'react-query';
// eslint-disable-next-line no-restricted-imports -- TODO: migrate global time selector off redux
import { useSelector } from 'react-redux';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
@@ -23,7 +24,7 @@ import {
queryReferencesAnyVariable,
} from '../queryV5/getReferencedVariables';
import { getBuilderQueries } from '../Panels/utils/getBuilderQueries';
import type { PanelQueryCapabilities } from '../Panels/types/panelCapabilities';
import { PANEL_KIND_TO_PANEL_TYPE } from '../Panels/types/panelKind';
import { selectResolvedVariables } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
import { resolvePanelTimeWindow } from './resolvePanelTimeWindow';
@@ -37,8 +38,6 @@ const DEFAULT_LIST_PAGE_SIZE = 25;
export interface UsePanelQueryArgs {
panel: DashboardtypesPanelDTO;
panelId: string;
/** The panel kind's declared query capabilities — `panelDefinition.queryCapabilities` at the call site. */
queryCapabilities: PanelQueryCapabilities;
/**
* Gate the fetch (default true). PanelV2 sets false for unregistered kinds to skip a wasted
* call. The hook also auto-disables internally when the panel has no runnable queries.
@@ -86,20 +85,21 @@ export interface UsePanelQueryResult {
export function usePanelQuery({
panel,
panelId,
queryCapabilities,
enabled = true,
time,
}: UsePanelQueryArgs): UsePanelQueryResult {
const fullKind = panel.spec.plugin.kind;
const panelType =
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind]) ?? PANEL_TYPES.TIME_SERIES;
const queries = panel.spec.queries;
// V1 parity: a query with an explicit `limit` shows without a server pager; without
// one a paging kind fetches server-side at a user-selectable size.
// V1 parity: a list query with an explicit `limit` shows without a server pager; without
// one it pages server-side at a user-selectable size.
const hasExplicitLimit = useMemo(
() => !!getBuilderQueries(queries)[0]?.limit,
[queries],
);
const isPaginated = queryCapabilities.serverPaginated && !hasExplicitLimit;
const isPaginated = panelType === PANEL_TYPES.LIST && !hasExplicitLimit;
const [pageSize, setPageSize] = useState(DEFAULT_LIST_PAGE_SIZE);
const [offset, setOffset] = useState(0);
@@ -188,7 +188,7 @@ export function usePanelQuery({
() =>
buildQueryRangeRequest({
queries,
queryCapabilities,
panelType,
startMs,
endMs,
fillGaps,
@@ -197,7 +197,7 @@ export function usePanelQuery({
}),
[
queries,
queryCapabilities,
panelType,
startMs,
endMs,
fillGaps,

View File

@@ -1,13 +1,12 @@
import {
type DashboardtypesQueryDTO,
Querybuildertypesv5RequestTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
buildQueryRangeRequest,
extractLegendMap,
getBarStepIntervalSeconds,
hasRunnableQueries,
panelTypeToRequestType,
toQueryEnvelopes,
} from '../buildQueryRangeRequest';
@@ -41,46 +40,20 @@ function compositeQuery(
const HOUR_MS = 60 * 60 * 1000;
const START_MS = 1_700_000_000_000;
// Capability blocks matching what each kind declares, so these tests exercise the
// builder's response to the flags rather than the declarations themselves (those are
// asserted against the registry in Panels/__tests__/capabilities.test.ts).
const TIME_SERIES_CAPABILITIES = {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
};
const BAR_CAPABILITIES = {
...TIME_SERIES_CAPABILITIES,
bucketedStepInterval: true,
};
const TABLE_CAPABILITIES = {
...TIME_SERIES_CAPABILITIES,
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
formatTableResultForUI: true,
};
const LIST_PANEL_CAPABILITIES = {
...TIME_SERIES_CAPABILITIES,
requestType: Querybuildertypesv5RequestTypeDTO.raw,
orderTiebreaker: true,
serverPaginated: true,
};
describe('requestType', () => {
describe('panelTypeToRequestType', () => {
it.each([
Querybuildertypesv5RequestTypeDTO.time_series,
Querybuildertypesv5RequestTypeDTO.scalar,
Querybuildertypesv5RequestTypeDTO.raw,
Querybuildertypesv5RequestTypeDTO.trace,
])('passes %s through from the declared capabilities', (requestType) => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'metrics' }),
queryCapabilities: { ...TIME_SERIES_CAPABILITIES, requestType },
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
expect(request.requestType).toBe(requestType);
[PANEL_TYPES.TIME_SERIES, 'time_series'],
// HISTOGRAM and BAR bin client-side from time-series data; sending
// 'distribution' would return a shape the renderers can't bin.
[PANEL_TYPES.BAR, 'time_series'],
[PANEL_TYPES.HISTOGRAM, 'time_series'],
[PANEL_TYPES.TABLE, 'scalar'],
[PANEL_TYPES.PIE, 'scalar'],
[PANEL_TYPES.VALUE, 'scalar'],
[PANEL_TYPES.LIST, 'raw'],
[PANEL_TYPES.TRACE, 'trace'],
])('%s → %s', (panelType, requestType) => {
expect(panelTypeToRequestType(panelType)).toBe(requestType);
});
});
@@ -162,7 +135,7 @@ describe('buildQueryRangeRequest', () => {
it('assembles the full request DTO', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'metrics' }),
queryCapabilities: TIME_SERIES_CAPABILITIES,
panelType: PANEL_TYPES.TIME_SERIES,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -184,7 +157,7 @@ describe('buildQueryRangeRequest', () => {
it('sets formatTableResultForUI only for TABLE panels', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A' }),
queryCapabilities: TABLE_CAPABILITIES,
panelType: PANEL_TYPES.TABLE,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -194,7 +167,7 @@ describe('buildQueryRangeRequest', () => {
it('passes through fillGaps into formatOptions', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A' }),
queryCapabilities: TIME_SERIES_CAPABILITIES,
panelType: PANEL_TYPES.TIME_SERIES,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
fillGaps: true,
@@ -205,7 +178,7 @@ describe('buildQueryRangeRequest', () => {
it('stamps offset/limit onto builder queries when pagination is given', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'logs' }),
queryCapabilities: LIST_PANEL_CAPABILITIES,
panelType: PANEL_TYPES.LIST,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
pagination: { offset: 100, limit: 50 },
@@ -225,7 +198,7 @@ describe('buildQueryRangeRequest', () => {
it('defaults a logs list with no order to timestamp desc + id tiebreaker', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'logs' }),
queryCapabilities: LIST_PANEL_CAPABILITIES,
panelType: PANEL_TYPES.LIST,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -245,7 +218,7 @@ describe('buildQueryRangeRequest', () => {
signal: 'logs',
order: [{ key: { name: 'timestamp' }, direction: 'desc' }],
}),
queryCapabilities: LIST_PANEL_CAPABILITIES,
panelType: PANEL_TYPES.LIST,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -265,7 +238,7 @@ describe('buildQueryRangeRequest', () => {
];
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'logs', order }),
queryCapabilities: LIST_PANEL_CAPABILITIES,
panelType: PANEL_TYPES.LIST,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -279,7 +252,7 @@ describe('buildQueryRangeRequest', () => {
const order = [{ key: { name: 'timestamp' }, direction: 'desc' }];
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'traces', order }),
queryCapabilities: LIST_PANEL_CAPABILITIES,
panelType: PANEL_TYPES.LIST,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -292,7 +265,7 @@ describe('buildQueryRangeRequest', () => {
it('injects the range-derived stepInterval into BAR builder queries without one', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'metrics' }),
queryCapabilities: BAR_CAPABILITIES,
panelType: PANEL_TYPES.BAR,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -307,7 +280,7 @@ describe('buildQueryRangeRequest', () => {
it('preserves a user-set stepInterval on BAR builder queries', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', stepInterval: 300 }),
queryCapabilities: BAR_CAPABILITIES,
panelType: PANEL_TYPES.BAR,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -320,7 +293,7 @@ describe('buildQueryRangeRequest', () => {
it('does not touch stepInterval for non-BAR panels', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A' }),
queryCapabilities: TIME_SERIES_CAPABILITIES,
panelType: PANEL_TYPES.TIME_SERIES,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});

View File

@@ -7,12 +7,7 @@ import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import {
envelopesToQuery,
fromPerses,
panelTypeToRequestType,
toPerses,
} from '../persesQueryAdapters';
import { envelopesToQuery, fromPerses, toPerses } from '../persesQueryAdapters';
/** A bare perses query (single plugin, not wrapped in a CompositeQuery). */
function bareQuery(
@@ -26,23 +21,6 @@ function bareQuery(
}
describe('persesQueryAdapters', () => {
describe('panelTypeToRequestType', () => {
it.each([
[PANEL_TYPES.TIME_SERIES, 'time_series'],
// HISTOGRAM and BAR bin client-side from time-series data; sending
// 'distribution' would return a shape the renderers can't bin.
[PANEL_TYPES.BAR, 'time_series'],
[PANEL_TYPES.HISTOGRAM, 'time_series'],
[PANEL_TYPES.TABLE, 'scalar'],
[PANEL_TYPES.PIE, 'scalar'],
[PANEL_TYPES.VALUE, 'scalar'],
[PANEL_TYPES.LIST, 'raw'],
[PANEL_TYPES.TRACE, 'trace'],
])('%s → %s', (panelType, requestType) => {
expect(panelTypeToRequestType(panelType)).toBe(requestType);
});
});
describe('fromPerses', () => {
it('returns a fresh metrics builder query for an empty panel', () => {
const query = fromPerses([], PANEL_TYPES.TIME_SERIES);

View File

@@ -14,9 +14,9 @@ import {
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
Querybuildertypesv5QueryEnvelopePromQLDTOType,
Querybuildertypesv5RequestTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PanelQueryCapabilities } from '../Panels/types/panelCapabilities';
import { PANEL_TYPES } from 'constants/queryBuilder';
// Narrow view over the envelope spec variants. Orval erases envelope `spec` to `unknown`, so
// shared fields are read through this view with a localized cast at the envelope boundary.
@@ -29,6 +29,31 @@ interface QuerySpecView {
order?: Querybuildertypesv5OrderByDTO[];
}
/**
* Maps a V2 panel type to the V5 `requestType`. HISTOGRAM/BAR bin client-side from raw
* time-series, so their request type is `time_series` (V1 parity).
*/
export function panelTypeToRequestType(
panelType: PANEL_TYPES,
): Querybuildertypesv5RequestTypeDTO {
switch (panelType) {
case PANEL_TYPES.TIME_SERIES:
case PANEL_TYPES.BAR:
case PANEL_TYPES.HISTOGRAM:
return Querybuildertypesv5RequestTypeDTO.time_series;
case PANEL_TYPES.TABLE:
case PANEL_TYPES.PIE:
case PANEL_TYPES.VALUE:
return Querybuildertypesv5RequestTypeDTO.scalar;
case PANEL_TYPES.LIST:
return Querybuildertypesv5RequestTypeDTO.raw;
case PANEL_TYPES.TRACE:
return Querybuildertypesv5RequestTypeDTO.trace;
default:
return Querybuildertypesv5RequestTypeDTO.time_series;
}
}
/**
* Unwraps the perses query into the V5 `compositeQuery.queries` list: a CompositeQuery passes
* through verbatim, bare plugins wrap into one envelope. Top-level Formula/TraceOperator are
@@ -214,13 +239,7 @@ function withPagination(
export interface BuildQueryRangeRequestArgs {
queries: DashboardtypesQueryDTO[];
/**
* The panel kind's declared query capabilities (`PanelDefinition.queryCapabilities`): request type,
* result formatting, and the step-interval/order treatment. Passed in rather than looked up
* by kind so this stays a leaf of the query layer — the panel registry carries every
* renderer with it, which has no business in the data path.
*/
queryCapabilities: PanelQueryCapabilities;
panelType: PANEL_TYPES;
/** Epoch milliseconds. */
startMs: number;
/** Epoch milliseconds. */
@@ -239,12 +258,7 @@ export interface BuildQueryRangeRequestArgs {
*/
export function buildQueryRangeRequest({
queries,
queryCapabilities: {
requestType,
formatTableResultForUI,
bucketedStepInterval,
orderTiebreaker,
},
panelType,
startMs,
endMs,
fillGaps = false,
@@ -252,10 +266,10 @@ export function buildQueryRangeRequest({
variables = {},
}: BuildQueryRangeRequestArgs): Querybuildertypesv5QueryRangeRequestDTO {
let envelopes = toQueryEnvelopes(queries);
if (bucketedStepInterval) {
if (panelType === PANEL_TYPES.BAR) {
envelopes = withBarStepInterval(envelopes, startMs, endMs);
}
if (orderTiebreaker) {
if (panelType === PANEL_TYPES.LIST) {
envelopes = withListOrderTiebreaker(envelopes);
}
if (pagination) {
@@ -266,10 +280,10 @@ export function buildQueryRangeRequest({
schemaVersion: 'v1',
start: startMs,
end: endMs,
requestType,
requestType: panelTypeToRequestType(panelType),
compositeQuery: { queries: envelopes },
formatOptions: {
formatTableResultForUI,
formatTableResultForUI: panelType === PANEL_TYPES.TABLE,
fillGaps,
},
variables,

View File

@@ -10,7 +10,6 @@ import {
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
Querybuildertypesv5QueryEnvelopePromQLDTOType,
Querybuildertypesv5RequestTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
@@ -21,7 +20,10 @@ import type { QueryEnvelope } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { toQueryEnvelopes } from './buildQueryRangeRequest';
import {
panelTypeToRequestType,
toQueryEnvelopes,
} from './buildQueryRangeRequest';
/**
* Adapters between the V2 perses query shape and the V1 `Query` the shared query
@@ -88,33 +90,6 @@ export function deriveQueryType(
return EQueryType.QUERY_BUILDER;
}
/**
* Maps a legacy panel type to the V5 `requestType`. Lives on this side of the V1 boundary
* because only the V1 pivot still speaks `PANEL_TYPES` — V2 panels read `requestType` off
* their kind's declared query capabilities instead. BAR/HISTOGRAM bin client-side from a raw
* time series, so they request `time_series` (V1 parity).
*/
export function panelTypeToRequestType(
panelType: PANEL_TYPES,
): Querybuildertypesv5RequestTypeDTO {
switch (panelType) {
case PANEL_TYPES.TIME_SERIES:
case PANEL_TYPES.BAR:
case PANEL_TYPES.HISTOGRAM:
return Querybuildertypesv5RequestTypeDTO.time_series;
case PANEL_TYPES.TABLE:
case PANEL_TYPES.PIE:
case PANEL_TYPES.VALUE:
return Querybuildertypesv5RequestTypeDTO.scalar;
case PANEL_TYPES.LIST:
return Querybuildertypesv5RequestTypeDTO.raw;
case PANEL_TYPES.TRACE:
return Querybuildertypesv5RequestTypeDTO.trace;
default:
return Querybuildertypesv5RequestTypeDTO.time_series;
}
}
/**
* V5 query-envelope list → V1 `Query`, via `mapQueryDataFromApi`. An empty list opens
* on a fresh metrics builder query. Used by `fromPerses` and by the envelopes a

View File

@@ -40,7 +40,6 @@ function PublicPanel({
const { data, isFetching, isPreviousData, error, refetch } =
usePublicPanelQuery({
panel,
queryCapabilities: panelDefinition.queryCapabilities,
panelKey,
publicDashboardId,
startMs,

View File

@@ -1,9 +1,6 @@
import { renderHook, waitFor } from '@testing-library/react';
import { getPublicDashboardPanelQueryRangeV2 } from 'api/generated/services/dashboard';
import {
type DashboardtypesPanelDTO,
Querybuildertypesv5RequestTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
@@ -45,15 +42,6 @@ const panel = {
const args = {
panel,
// What TimeSeries declares; passed in rather than resolved from the registry, which
// would pull every panel renderer into this suite.
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
panelKey: 'panel-1',
publicDashboardId: 'pub-1',
startMs: 1000,

View File

@@ -3,9 +3,10 @@ import type {
DashboardtypesPanelDTO,
GetPublicDashboardPanelQueryRangeV2200,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { retryUnlessClientError } from 'pages/DashboardPageV2/DashboardContainer/hooks/useGetQueryRangeV5';
import type { PanelQueryCapabilities } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelCapabilities';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import {
buildQueryRangeRequest,
extractLegendMap,
@@ -20,8 +21,6 @@ import { useQuery, useQueryClient } from 'react-query';
export interface UsePublicPanelQueryArgs {
panel: DashboardtypesPanelDTO;
/** The panel kind's declared query capabilities — `panelDefinition.queryCapabilities`. */
queryCapabilities: PanelQueryCapabilities;
/** Panel key in `spec.panels` — addresses the panel on the public endpoint. */
panelKey: string;
publicDashboardId: string;
@@ -53,13 +52,15 @@ export interface UsePublicPanelQueryResult {
*/
export function usePublicPanelQuery({
panel,
queryCapabilities,
panelKey,
publicDashboardId,
startMs,
endMs,
enabled = true,
}: UsePublicPanelQueryArgs): UsePublicPanelQueryResult {
const fullKind = panel.spec.plugin.kind;
const panelType =
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind]) ?? PANEL_TYPES.TIME_SERIES;
const { queries } = panel.spec;
const pluginSpec = panel.spec.plugin.spec;
@@ -76,13 +77,13 @@ export function usePublicPanelQuery({
() =>
buildQueryRangeRequest({
queries,
queryCapabilities,
panelType,
startMs,
endMs,
fillGaps,
variables: {},
}),
[queries, queryCapabilities, startMs, endMs, fillGaps],
[queries, panelType, startMs, endMs, fillGaps],
);
const legendMap = useMemo(() => extractLegendMap(queries), [queries]);

View File

@@ -428,20 +428,24 @@ func (b *StatementBuilder) buildTemporalAggDeltaFastPath(
sb.SelectMore(fmt.Sprintf("`%s`", GroupByColumnAlias(i, g.Name)))
}
aggCol, err := metricstelemetryschema.AggregationColumnForSamplesTable(
samplesTable, query.Aggregations[0].Temporality, query.Aggregations[0].TimeAggregation,
)
if err != nil {
return "", nil, err
}
if query.Aggregations[0].TimeAggregation == metrictypes.TimeAggregationRate {
// TODO(srikanthccv): should it be step interval or use [start_time_unix_nano](https://github.com/open-telemetry/opentelemetry-proto/blob/d3fb76d70deb0874692bd0ebe03148580d85f3bb/opentelemetry/proto/metrics/v1/metrics.proto#L400C11-L400C31)?
aggCol = fmt.Sprintf("%s/%d", aggCol, stepSec)
}
var aggCol string
if query.Aggregations[0].SpaceAggregation.IsPercentile() &&
query.Aggregations[0].Type == metrictypes.ExpHistogramType {
// merging sketches already spans every series in the step, so neither a
// samples-table value column nor the rate divisor applies
aggCol = fmt.Sprintf("quantilesDDMerge(0.01, %f)(sketch)[1]", query.Aggregations[0].SpaceAggregation.Percentile())
} else {
col, err := metricstelemetryschema.AggregationColumnForSamplesTable(
samplesTable, query.Aggregations[0].Temporality, query.Aggregations[0].TimeAggregation,
)
if err != nil {
return "", nil, err
}
aggCol = col
if query.Aggregations[0].TimeAggregation == metrictypes.TimeAggregationRate {
// TODO(srikanthccv): should it be step interval or use [start_time_unix_nano](https://github.com/open-telemetry/opentelemetry-proto/blob/d3fb76d70deb0874692bd0ebe03148580d85f3bb/opentelemetry/proto/metrics/v1/metrics.proto#L400C11-L400C31)?
aggCol = fmt.Sprintf("%s/%d", aggCol, stepSec)
}
}
sb.SelectMore(fmt.Sprintf("%s AS value", aggCol))

View File

@@ -126,6 +126,64 @@ func TestStatementBuilder(t *testing.T) {
},
expectedErr: nil,
},
{
name: "test_exp_histogram_percentile_delta",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Signal: telemetrytypes.SignalMetrics,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "signoz_latency",
Type: metrictypes.ExpHistogramType,
Temporality: metrictypes.Delta,
SpaceAggregation: metrictypes.SpaceAggregationPercentile95,
},
},
GroupBy: []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "service.name",
},
},
},
},
expected: qbtypes.Statement{
Query: "WITH __spatial_aggregation_cte AS (SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_service.name`, quantilesDDMerge(0.01, 0.950000)(sketch)[1] AS value FROM signoz_metrics.distributed_exp_hist AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_service.name`, ts",
Args: []any{"signoz_latency", uint64(1747936800000), uint64(1747983420000), "delta", "signoz_latency", uint64(1747947390000), uint64(1747983420000)},
},
expectedErr: nil,
},
{
// the sketch merge spans the whole step, so `rate` must not add a /step divisor
name: "test_exp_histogram_percentile_delta_rate_time_aggregation",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Signal: telemetrytypes.SignalMetrics,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "signoz_latency",
Type: metrictypes.ExpHistogramType,
Temporality: metrictypes.Delta,
TimeAggregation: metrictypes.TimeAggregationRate,
SpaceAggregation: metrictypes.SpaceAggregationPercentile95,
},
},
GroupBy: []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "service.name",
},
},
},
},
expected: qbtypes.Statement{
Query: "WITH __spatial_aggregation_cte AS (SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_service.name`, quantilesDDMerge(0.01, 0.950000)(sketch)[1] AS value FROM signoz_metrics.distributed_exp_hist AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_service.name`, ts",
Args: []any{"signoz_latency", uint64(1747936800000), uint64(1747983420000), "delta", "signoz_latency", uint64(1747947390000), uint64(1747983420000)},
},
expectedErr: nil,
},
{
name: "test_histogram_percentile1",
requestType: qbtypes.RequestTypeTimeSeries,

View File

@@ -132,7 +132,12 @@ class MetricsSample(ABC):
class MetricsExpHist(ABC):
"""Represents a row in the exp_hist table for exponential histograms."""
"""Represents a row in the exp_hist table for exponential histograms.
Carries the raw observations rather than a serialized sketch: the `sketch`
column is an AggregateFunction state that only ClickHouse can build, so
`observations` is what gets folded into one on insert. Must be non-empty.
"""
env: str
temporality: str
@@ -143,7 +148,7 @@ class MetricsExpHist(ABC):
sum: np.float64
min: np.float64
max: np.float64
sketch: bytes
observations: list[int]
flags: np.uint32
def __init__(
@@ -151,11 +156,7 @@ class MetricsExpHist(ABC):
metric_name: str,
fingerprint: np.uint64,
timestamp: datetime.datetime,
count: int,
sum_value: float,
min_value: float,
max_value: float,
sketch: bytes = b"",
observations: list[int],
temporality: str = "Unspecified",
env: str = "default",
flags: int = 0,
@@ -165,28 +166,13 @@ class MetricsExpHist(ABC):
self.metric_name = metric_name
self.fingerprint = fingerprint
self.unix_milli = np.int64(int(timestamp.timestamp() * 1e3))
self.count = np.uint64(count)
self.sum = np.float64(sum_value)
self.min = np.float64(min_value)
self.max = np.float64(max_value)
self.sketch = sketch
self.observations = observations
self.count = np.uint64(len(observations))
self.sum = np.float64(sum(observations))
self.min = np.float64(min(observations))
self.max = np.float64(max(observations))
self.flags = np.uint32(flags)
def to_row(self) -> list:
return [
self.env,
self.temporality,
self.metric_name,
self.fingerprint,
self.unix_milli,
self.count,
self.sum,
self.min,
self.max,
self.sketch,
self.flags,
]
class MetricsMetadata(ABC):
"""Represents a row in the metadata table for metric metadata."""
@@ -429,6 +415,73 @@ class Metrics(ABC):
return metrics
class ExpHistogramMetrics(ABC):
"""High-level exponential histogram representation. Produces both time series
and exp_hist entries."""
metric_name: str
labels: dict[str, str]
temporality: str
timestamp: datetime.datetime
observations: list[int]
@property
def time_series(self) -> MetricsTimeSeries:
return self._time_series
@property
def exp_hist(self) -> MetricsExpHist:
return self._exp_hist
def __init__(
self,
metric_name: str,
observations: list[int],
labels: dict[str, str] = {},
timestamp: datetime.datetime | None = None,
temporality: str = "Delta",
flags: int = 0,
description: str = "",
unit: str = "",
env: str = "default",
resource_attributes: dict[str, str] = {},
scope_attributes: dict[str, str] = {},
) -> None:
if timestamp is None:
timestamp = datetime.datetime.now()
self.metric_name = metric_name
self.labels = labels
self.temporality = temporality
self.timestamp = timestamp
self.observations = observations
self._time_series = MetricsTimeSeries(
metric_name=metric_name,
labels=labels,
timestamp=timestamp,
temporality=temporality,
description=description,
unit=unit,
# the querier resolves the metric type from this column, and only an
# ExponentialHistogram here routes the query to the sketch read
type_="ExponentialHistogram",
is_monotonic=False,
env=env,
resource_attrs=resource_attributes,
scope_attrs=scope_attributes,
)
self._exp_hist = MetricsExpHist(
metric_name=metric_name,
fingerprint=self._time_series.fingerprint,
timestamp=timestamp,
observations=observations,
temporality=temporality,
env=env,
flags=flags,
)
class MetricsReducedTimeSeries(ABC):
"""Represents a row in the time_series_v4_reduced table i.e what
the time_series_v4_reduced_mv materializes for a metric under a
@@ -853,6 +906,86 @@ def insert_metrics(
)
def insert_exp_histogram_metrics_to_clickhouse(conn, metrics: list[ExpHistogramMetrics]) -> None:
"""
Insert exponential histograms into ClickHouse tables.
Handles insertion into:
- distributed_time_series_v4 (time series metadata)
- distributed_exp_hist (per-point sketches)
"""
time_series_map: dict[tuple[int, int], MetricsTimeSeries] = {}
for metric in metrics:
fp = int(metric.time_series.fingerprint)
hour_bucket = int(metric.time_series.unix_milli) // 3_600_000
if (fp, hour_bucket) not in time_series_map:
metric.time_series.unix_milli = np.int64(hour_bucket * 3_600_000)
time_series_map[(fp, hour_bucket)] = metric.time_series
if len(time_series_map) > 0:
conn.insert(
database="signoz_metrics",
table="distributed_time_series_v4",
column_names=[
"env",
"temporality",
"metric_name",
"description",
"unit",
"type",
"is_monotonic",
"fingerprint",
"unix_milli",
"labels",
"attrs",
"scope_attrs",
"resource_attrs",
],
data=[ts.to_row() for ts in time_series_map.values()],
)
# `sketch` is AggregateFunction(quantilesDD(...), UInt64) — the state has to be
# folded server-side, it cannot be sent as a literal. The quantilesDDState
# parameters must match the column's exactly or the INSERT is rejected.
for metric in metrics:
hist = metric.exp_hist
conn.command(
"INSERT INTO signoz_metrics.distributed_exp_hist "
"(env, temporality, metric_name, fingerprint, unix_milli, count, sum, min, max, sketch, flags) "
"SELECT %(env)s, %(temporality)s, %(metric_name)s, %(fingerprint)s, %(unix_milli)s, "
"%(count)s, %(sum)s, %(min)s, %(max)s, "
"quantilesDDState(0.01, 0.5, 0.75, 0.9, 0.95, 0.99)(toUInt64(observation)), %(flags)s "
"FROM (SELECT arrayJoin(%(observations)s) AS observation)",
parameters={
"env": hist.env,
"temporality": hist.temporality,
"metric_name": hist.metric_name,
"fingerprint": int(hist.fingerprint),
"unix_milli": int(hist.unix_milli),
"count": int(hist.count),
"sum": float(hist.sum),
"min": float(hist.min),
"max": float(hist.max),
"observations": hist.observations,
"flags": int(hist.flags),
},
)
@pytest.fixture(name="insert_exp_histogram_metrics", scope="function")
def insert_exp_histogram_metrics(
clickhouse: types.TestContainerClickhouse,
) -> Generator[Callable[[list[ExpHistogramMetrics]], None], Any]:
def _insert_exp_histogram_metrics(metrics: list[ExpHistogramMetrics]) -> None:
insert_exp_histogram_metrics_to_clickhouse(clickhouse.conn, metrics)
yield _insert_exp_histogram_metrics
truncate_metrics_tables(
clickhouse.conn,
clickhouse.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER"],
)
def insert_reduced_metrics_to_clickhouse(
conn,
time_series: list[MetricsReducedTimeSeries],

View File

@@ -0,0 +1,129 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.metrics import ExpHistogramMetrics
from fixtures.querier import (
build_builder_query,
get_all_series,
get_series_values,
make_query_request,
)
# quantilesDD carries 0.01 relative accuracy and the log-spaced observations put
# neighbouring ranks ~1.25% apart, so a percentile can land a few percent off
PERCENTILE_TOLERANCE = 0.05
@pytest.mark.parametrize(
"space_aggregation, frontend_first, frontend_last, backend_first, backend_last",
[
("p50", 118, 153, 711, 921),
("p95", 1108, 1435, 6651, 8613),
("p99", 1352, 1751, 8113, 10507),
],
)
@pytest.mark.parametrize("time_aggregation", ["", "rate"])
def test_exp_histogram_percentile_delta_grouped(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_exp_histogram_metrics: Callable[[list[ExpHistogramMetrics]], None],
time_aggregation: str,
space_aggregation: str,
frontend_first: float,
frontend_last: float,
backend_first: float,
backend_last: float,
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
start_ms = int((now - timedelta(minutes=65)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
metric_name = "test_exp_histogram_latency"
insert_exp_histogram_metrics(
[
ExpHistogramMetrics(
metric_name=metric_name,
# log-spaced latencies with a long tail, drifting ~30% higher across
# the hour so each point carries a distinct distribution
observations=[round(base * 1.0125**rank * (1 + minute / 200)) for rank in range(400)],
labels={"service.name": service},
timestamp=now - timedelta(minutes=60 - minute),
temporality="Delta",
)
for service, base in (("frontend", 10), ("backend", 60))
for minute in range(60)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = build_builder_query(
"A",
metric_name,
time_aggregation,
space_aggregation,
temporality="delta",
group_by=["service.name"],
)
response = make_query_request(signoz, token, start_ms, end_ms, [query])
assert response.status_code == HTTPStatus.OK, response.text
all_series = get_all_series(response.json(), "A")
values_by_service = {series["labels"][0]["value"]: [point["value"] for point in sorted(series["values"], key=lambda point: point["timestamp"])] for series in all_series}
assert set(values_by_service.keys()) == {"frontend", "backend"}, f"got series {set(values_by_service.keys())}"
for service, first, last in (
("frontend", frontend_first, frontend_last),
("backend", backend_first, backend_last),
):
values = values_by_service[service]
assert len(values) >= 55, f"{service}: expected a point per minute, got {len(values)}"
assert values[0] == pytest.approx(first, rel=PERCENTILE_TOLERANCE), f"{service} {space_aggregation} at the oldest point: got {values[0]}, want ~{first}"
assert values[-1] == pytest.approx(last, rel=PERCENTILE_TOLERANCE), f"{service} {space_aggregation} at the newest point: got {values[-1]}, want ~{last}"
# every observation drifts up minute over minute, so the sketch must too
assert values == sorted(values), f"{service} {space_aggregation} is not non-decreasing: {values}"
def test_exp_histogram_percentile_delta_merges_across_series(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_exp_histogram_metrics: Callable[[list[ExpHistogramMetrics]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
start_ms = int((now - timedelta(minutes=65)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
metric_name = "test_exp_histogram_latency_merged"
insert_exp_histogram_metrics(
[
ExpHistogramMetrics(
metric_name=metric_name,
observations=[round(base * 1.0125**rank * (1 + minute / 200)) for rank in range(400)],
labels={"service.name": service},
timestamp=now - timedelta(minutes=60 - minute),
temporality="Delta",
)
for service, base in (("frontend", 10), ("backend", 60))
for minute in range(60)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = build_builder_query("A", metric_name, "", "p95", temporality="delta")
response = make_query_request(signoz, token, start_ms, end_ms, [query])
assert response.status_code == HTTPStatus.OK, response.text
# both services' sketches merge into one, so p95 sits well above the frontend's
# own p95 (~1108) and below the backend's (~6651)
values = [point["value"] for point in sorted(get_series_values(response.json(), "A"), key=lambda point: point["timestamp"])]
assert len(values) >= 55, f"expected a point per minute, got {len(values)}"
assert values[0] == pytest.approx(5188, rel=PERCENTILE_TOLERANCE), f"oldest point: got {values[0]}, want ~5188"
assert values[-1] == pytest.approx(6718, rel=PERCENTILE_TOLERANCE), f"newest point: got {values[-1]}, want ~6718"