mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-19 19:30:40 +01:00
Compare commits
4 Commits
tvats-cons
...
feat/chart
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ba466ac77 | ||
|
|
0a86eef3aa | ||
|
|
4057dc5fdf | ||
|
|
e679805b43 |
@@ -5,6 +5,7 @@ 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,
|
||||
@@ -131,9 +132,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}
|
||||
|
||||
@@ -58,26 +58,17 @@ describe('prepareBillingBarConfig', () => {
|
||||
expect(config.series?.[4]?.stroke).toBe(Color.BG_AMBER_500);
|
||||
});
|
||||
|
||||
it('sets stacking bands, padding, and focus alpha for behavioral parity', () => {
|
||||
it('sets padding and focus alpha for behavioral parity', () => {
|
||||
const builder = prepareBillingBarConfig({
|
||||
...baseProps,
|
||||
apiResponse: makeApiResponse(['Logs', 'Traces', 'Metrics']),
|
||||
});
|
||||
const config = builder.getConfig();
|
||||
expect(config.bands).toStrictEqual([{ series: [1, 2] }, { series: [2, 3] }]);
|
||||
// Stacking bands come from the chart now — see useChartStacking.
|
||||
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: {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
@@ -63,7 +62,6 @@ export function prepareBillingBarConfig({
|
||||
});
|
||||
});
|
||||
|
||||
builder.setBands(getInitialStackedBands(results.length));
|
||||
builder.setPadding([32, 32, 16, 16]);
|
||||
builder.setFocus({ alpha: 0.3 });
|
||||
|
||||
|
||||
@@ -6,25 +6,24 @@ import {
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
|
||||
import { useBarChartStacking } from '../../hooks/useBarChartStacking';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { BarChartProps } from '../types';
|
||||
|
||||
export default function BarChart(props: BarChartProps): JSX.Element {
|
||||
const {
|
||||
children,
|
||||
isStackedBarChart,
|
||||
customTooltip,
|
||||
config,
|
||||
data,
|
||||
stack = StackMode.None,
|
||||
pinnedTooltipElement,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const chartData = useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart,
|
||||
config,
|
||||
});
|
||||
// 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.setStack(stack);
|
||||
|
||||
const renderTooltip = useCallback(
|
||||
(props: TooltipRenderArgs): React.ReactNode => {
|
||||
@@ -37,7 +36,6 @@ 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,
|
||||
};
|
||||
@@ -48,7 +46,6 @@ export default function BarChart(props: BarChartProps): JSX.Element {
|
||||
rest.timezone,
|
||||
rest.yAxisUnit,
|
||||
rest.decimalPrecision,
|
||||
isStackedBarChart,
|
||||
rest.canPinTooltip,
|
||||
rest.renderTooltipFooter,
|
||||
],
|
||||
@@ -58,7 +55,7 @@ export default function BarChart(props: BarChartProps): JSX.Element {
|
||||
<ChartWrapper
|
||||
{...rest}
|
||||
config={config}
|
||||
data={chartData}
|
||||
data={data}
|
||||
customTooltip={renderTooltip}
|
||||
pinnedTooltipElement={pinnedTooltipElement}
|
||||
>
|
||||
|
||||
@@ -6,12 +6,15 @@ 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 { ChartProps } from '../types';
|
||||
import { ChartWrapperProps } from '../types';
|
||||
import { useChartStacking } from './useChartStacking';
|
||||
|
||||
const TOOLTIP_WIDTH_PADDING = 120;
|
||||
const TOOLTIP_MIN_WIDTH = 300;
|
||||
@@ -39,9 +42,20 @@ export default function ChartWrapper({
|
||||
pinnedTooltipElement,
|
||||
tooltipPortalRoot,
|
||||
'data-testid': testId,
|
||||
}: ChartProps): JSX.Element {
|
||||
}: ChartWrapperProps): 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) {
|
||||
@@ -61,11 +75,11 @@ export default function ChartWrapper({
|
||||
const renderTooltipCallback = useCallback(
|
||||
(args: TooltipRenderArgs): React.ReactNode => {
|
||||
if (customTooltip) {
|
||||
return customTooltip(args);
|
||||
return customTooltip({ ...args, unstackedData });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[customTooltip],
|
||||
[customTooltip, unstackedData],
|
||||
);
|
||||
|
||||
const syncMetadata = useMemo(
|
||||
@@ -91,7 +105,7 @@ export default function ChartWrapper({
|
||||
{({ chartWidth, chartHeight, averageLegendWidth }): JSX.Element => (
|
||||
<UPlotChart
|
||||
config={config}
|
||||
data={data}
|
||||
data={chartData}
|
||||
width={chartWidth}
|
||||
height={chartHeight}
|
||||
plotRef={(plot): void => {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -6,10 +6,11 @@ import {
|
||||
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 '../charts/utils/stackSeriesUtils';
|
||||
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 {
|
||||
@@ -31,12 +32,12 @@ function canApplyStacking(
|
||||
|
||||
function setupStackingHooks(
|
||||
config: UPlotConfigBuilder,
|
||||
applyStackingToChart: (plot: uPlot) => void,
|
||||
restack: (plot: uPlot) => void,
|
||||
isUpdatingRef: MutableRefObject<boolean>,
|
||||
): () => void {
|
||||
const onDataChange = (plot: uPlot): void => {
|
||||
if (!isUpdatingRef.current) {
|
||||
applyStackingToChart(plot);
|
||||
restack(plot);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -45,8 +46,9 @@ function setupStackingHooks(
|
||||
_seriesIdx: number | null,
|
||||
opts: uPlot.Series,
|
||||
): void => {
|
||||
// uPlot fires setSeries for hover focus too; only visibility changes restack.
|
||||
if (!has(opts, 'focus')) {
|
||||
applyStackingToChart(plot);
|
||||
restack(plot);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -62,64 +64,69 @@ function setupStackingHooks(
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseBarChartStackingParams {
|
||||
export interface UseChartStackingParams {
|
||||
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).
|
||||
* 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 useBarChartStacking({
|
||||
export function useChartStacking({
|
||||
data,
|
||||
isStackedBarChart = false,
|
||||
config,
|
||||
}: UseBarChartStackingParams): uPlot.AlignedData {
|
||||
// Store unstacked source data so uPlot hooks can access it (hooks run outside React's render cycle)
|
||||
}: UseChartStackingParams): uPlot.AlignedData {
|
||||
const stack = config?.getStackMode() ?? StackMode.None;
|
||||
const unstackedDataRef = useRef<uPlot.AlignedData | null>(null);
|
||||
unstackedDataRef.current = isStackedBarChart ? data : null;
|
||||
unstackedDataRef.current = stack === 'none' ? null : data;
|
||||
|
||||
// Prevents re-entrant calls when we update chart data (avoids infinite loop in setData hook)
|
||||
// Guards the re-entrant setData below, which would otherwise re-trigger our own hook.
|
||||
const isUpdatingChartRef = useRef(false);
|
||||
|
||||
const chartData = useMemo((): uPlot.AlignedData => {
|
||||
if (!isStackedBarChart || !data || data.length < 2) {
|
||||
if (stack === StackMode.None || !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]);
|
||||
return stackSeries(data, noSeriesHidden, stack).data;
|
||||
}, [data, stack]);
|
||||
|
||||
const applyStackingToChart = useCallback((plot: uPlot): void => {
|
||||
const unstacked = unstackedDataRef.current;
|
||||
if (
|
||||
!unstacked ||
|
||||
!canApplyStacking(unstacked, plot, isUpdatingChartRef.current)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const restack = 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);
|
||||
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));
|
||||
plot.delBand(null);
|
||||
bands.forEach((band: uPlot.Band) => plot.addBand(band));
|
||||
|
||||
isUpdatingChartRef.current = true;
|
||||
plot.setData(stacked);
|
||||
isUpdatingChartRef.current = false;
|
||||
}, []);
|
||||
isUpdatingChartRef.current = true;
|
||||
plot.setData(stacked);
|
||||
isUpdatingChartRef.current = false;
|
||||
},
|
||||
[stack],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isStackedBarChart || !config) {
|
||||
if (stack === StackMode.None || !config) {
|
||||
return undefined;
|
||||
}
|
||||
return setupStackingHooks(config, applyStackingToChart, isUpdatingChartRef);
|
||||
}, [isStackedBarChart, config, applyStackingToChart]);
|
||||
return setupStackingHooks(config, restack, isUpdatingChartRef);
|
||||
}, [stack, config, restack]);
|
||||
|
||||
return chartData;
|
||||
}
|
||||
@@ -6,10 +6,16 @@ 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, ...rest } = props;
|
||||
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.setStack(stack);
|
||||
|
||||
const renderTooltip = useCallback(
|
||||
(props: TooltipRenderArgs): React.ReactNode => {
|
||||
|
||||
@@ -14,6 +14,7 @@ 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;
|
||||
@@ -52,27 +53,26 @@ interface UPlotChartDataProps {
|
||||
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
|
||||
}
|
||||
|
||||
export interface TimeSeriesChartProps
|
||||
extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps {
|
||||
/** Everything the shared uPlot shell consumes; each chart's props narrow it. */
|
||||
export interface ChartWrapperProps
|
||||
extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps {}
|
||||
|
||||
export interface TimeSeriesChartProps extends ChartWrapperProps {
|
||||
timezone?: Timezone;
|
||||
/** How series compose. Defaults to `none`, which draws them independently. */
|
||||
stack?: StackMode;
|
||||
}
|
||||
|
||||
export interface HistogramChartProps
|
||||
extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps {
|
||||
export interface BarChartProps extends ChartWrapperProps {
|
||||
timezone?: Timezone;
|
||||
/** How series compose. Defaults to `none`, which draws them independently. */
|
||||
stack?: StackMode;
|
||||
}
|
||||
|
||||
export interface HistogramChartProps extends ChartWrapperProps {
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,20 @@
|
||||
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 is excluded from stacking.
|
||||
* When `omit(seriesIndex)` returns true, that series keeps its raw values and
|
||||
* contributes nothing to the total. `None` is a no-op.
|
||||
*/
|
||||
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
|
||||
@@ -17,6 +24,7 @@ export function stackSeries(
|
||||
valueSeriesCount,
|
||||
pointCount,
|
||||
omit,
|
||||
mode,
|
||||
});
|
||||
const bands = buildFillBands(valueSeriesCount + 1, omit); // +1 for 1-based series indices
|
||||
|
||||
@@ -31,6 +39,34 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,9 +78,15 @@ function buildStackedSeries({
|
||||
valueSeriesCount,
|
||||
pointCount,
|
||||
omit,
|
||||
mode,
|
||||
}: BuildStackedSeriesParams): (number | null)[][] {
|
||||
const stackedSeries: (number | null)[][] = Array(valueSeriesCount);
|
||||
const cumulativeSums = Array(pointCount).fill(0) as number[];
|
||||
// Known up front: totals span series the accumulation below has not reached yet.
|
||||
const totals =
|
||||
mode === StackMode.Percent
|
||||
? columnTotals({ data, valueSeriesCount, pointCount, omit })
|
||||
: undefined;
|
||||
|
||||
for (let seriesIndex = valueSeriesCount; seriesIndex >= 1; seriesIndex--) {
|
||||
const rawValues = data[seriesIndex] as (number | null)[];
|
||||
@@ -54,7 +96,10 @@ function buildStackedSeries({
|
||||
} else {
|
||||
stackedSeries[seriesIndex - 1] = rawValues.map((rawValue, pointIndex) => {
|
||||
const numericValue = rawValue == null ? 0 : Number(rawValue);
|
||||
return (cumulativeSums[pointIndex] += numericValue);
|
||||
const contribution = totals
|
||||
? toPercent(numericValue, totals[pointIndex])
|
||||
: numericValue;
|
||||
return (cumulativeSums[pointIndex] += contribution);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -101,16 +146,3 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ 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 {
|
||||
@@ -147,6 +148,7 @@ 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,
|
||||
@@ -159,7 +161,6 @@ 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}
|
||||
|
||||
@@ -35,20 +35,10 @@ 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'] = [],
|
||||
@@ -247,36 +237,5 @@ 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
@@ -69,11 +68,6 @@ 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,
|
||||
|
||||
@@ -9,6 +9,7 @@ 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';
|
||||
@@ -137,6 +138,7 @@ function TimeSeries({
|
||||
key={`${WIDGET_ID}-${index}`}
|
||||
>
|
||||
<BarChart
|
||||
stack={StackMode.Normal}
|
||||
config={chart.config}
|
||||
legendConfig={{
|
||||
position: LegendPosition.BOTTOM,
|
||||
@@ -144,7 +146,6 @@ function TimeSeries({
|
||||
data={chart.chartData as uPlot.AlignedData}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
isStackedBarChart
|
||||
yAxisUnit={yAxisUnit || 'short'}
|
||||
timezone={timezone}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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 {
|
||||
@@ -89,9 +88,6 @@ export function buildMeterChartConfig({
|
||||
return builder;
|
||||
}
|
||||
|
||||
const seriesCount = (apiResponse.data.result.length ?? 0) + 1;
|
||||
builder.setBands(getInitialStackedBands(seriesCount));
|
||||
|
||||
apiResponse.data.result.forEach((series) => {
|
||||
const baseLabelName = getLabelName(
|
||||
series.metric,
|
||||
|
||||
@@ -9,6 +9,7 @@ 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,
|
||||
@@ -21,6 +22,7 @@ export default function BarChartTooltip(props: BarTooltipProps): JSX.Element {
|
||||
}),
|
||||
[
|
||||
props.uPlotInstance,
|
||||
props.unstackedData,
|
||||
props.seriesIndex,
|
||||
props.dataIndexes,
|
||||
props.yAxisUnit,
|
||||
|
||||
@@ -11,6 +11,7 @@ export default function TimeSeriesTooltip(
|
||||
(): TooltipContentItem[] =>
|
||||
buildTooltipContent({
|
||||
data: props.uPlotInstance.data,
|
||||
unstackedData: props.unstackedData,
|
||||
series: props.uPlotInstance.series,
|
||||
dataIndexes: props.dataIndexes,
|
||||
activeSeriesIndex: props.seriesIndex,
|
||||
@@ -22,6 +23,7 @@ export default function TimeSeriesTooltip(
|
||||
}),
|
||||
[
|
||||
props.uPlotInstance,
|
||||
props.unstackedData,
|
||||
props.seriesIndex,
|
||||
props.dataIndexes,
|
||||
props.yAxisUnit,
|
||||
|
||||
@@ -72,6 +72,35 @@ 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],
|
||||
|
||||
@@ -23,17 +23,25 @@ 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,
|
||||
@@ -56,6 +64,7 @@ export function getTooltipBaseValue({
|
||||
|
||||
export function buildTooltipContent({
|
||||
data,
|
||||
unstackedData,
|
||||
series,
|
||||
dataIndexes,
|
||||
activeSeriesIndex,
|
||||
@@ -67,6 +76,7 @@ export function buildTooltipContent({
|
||||
syncFilterMode,
|
||||
}: {
|
||||
data: AlignedData;
|
||||
unstackedData?: AlignedData;
|
||||
series: Series[];
|
||||
dataIndexes: Array<number | null>;
|
||||
activeSeriesIndex: number | null;
|
||||
@@ -115,6 +125,7 @@ export function buildTooltipContent({
|
||||
|
||||
const baseValue = getTooltipBaseValue({
|
||||
data,
|
||||
unstackedData,
|
||||
index: seriesIndex,
|
||||
dataIndex,
|
||||
isStackedBarChart,
|
||||
|
||||
@@ -69,6 +69,11 @@ 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 {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
ConfigBuilderProps,
|
||||
LegendItem,
|
||||
SelectionPreferencesSource,
|
||||
StackMode,
|
||||
} from './types';
|
||||
import { AxisProps, UPlotAxisBuilder } from './UPlotAxisBuilder';
|
||||
import { ScaleProps, UPlotScaleBuilder } from './UPlotScaleBuilder';
|
||||
@@ -28,6 +29,11 @@ import { SeriesProps, UPlotSeriesBuilder } from './UPlotSeriesBuilder';
|
||||
/**
|
||||
* Type definitions for uPlot option objects
|
||||
*/
|
||||
/** Renders a 0–100 number as `50%`, unlike the 0–1 `percentunit`. */
|
||||
const PERCENT_AXIS_UNIT = 'percent';
|
||||
|
||||
const PERCENT_AXIS_MAX = 100;
|
||||
|
||||
type LegendConfig = {
|
||||
show?: boolean;
|
||||
live?: boolean;
|
||||
@@ -57,6 +63,8 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
|
||||
private bands: uPlot.Band[] = [];
|
||||
|
||||
private stack: StackMode = StackMode.None;
|
||||
|
||||
private cursor: Cursor | undefined;
|
||||
|
||||
private hooks: Hooks.Arrays = {};
|
||||
@@ -143,6 +151,15 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
this.axes[scaleKey] = new UPlotAxisBuilder(props);
|
||||
}
|
||||
|
||||
/** Drives the fill bands, the percent axis unit and the percent range below. */
|
||||
setStack(stack: StackMode): void {
|
||||
this.stack = stack;
|
||||
}
|
||||
|
||||
getStackMode(): StackMode {
|
||||
return this.stack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or merge a scale configuration
|
||||
*/
|
||||
@@ -211,6 +228,41 @@ 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 0–100 stay visible.
|
||||
*/
|
||||
private resolveScale(scale: UPlotScaleBuilder): UPlotScaleBuilder {
|
||||
if (this.stack !== 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 0–500.
|
||||
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.stack === 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
|
||||
*/
|
||||
@@ -444,9 +496,19 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
};
|
||||
}),
|
||||
];
|
||||
config.axes = Object.values(this.axes).map((a) => a.getConfig());
|
||||
config.axes = Object.entries(this.axes).map(([scaleKey, axis]) => {
|
||||
if (scaleKey !== 'y' || this.stack !== 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.scales = this.scales.reduce(
|
||||
(acc, s) => ({ ...acc, ...s.getConfig() }),
|
||||
(acc, s) => ({ ...acc, ...this.resolveScale(s).getConfig() }),
|
||||
{} as Record<string, uPlot.Scale>,
|
||||
);
|
||||
|
||||
@@ -456,7 +518,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.bands.length > 0 ? this.bands : undefined;
|
||||
config.bands = this.resolveBands();
|
||||
|
||||
if (Array.isArray(this.padding)) {
|
||||
config.padding = this.padding;
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
STEP_INTERVAL_MULTIPLIER,
|
||||
} from '../../constants';
|
||||
import type { SeriesProps } from '../types';
|
||||
import { DrawStyle, SelectionPreferencesSource } from '../types';
|
||||
import { DrawStyle, SelectionPreferencesSource, StackMode } from '../types';
|
||||
import { UPlotConfigBuilder } from '../UPlotConfigBuilder';
|
||||
|
||||
// Mock only the real boundary that hits localStorage
|
||||
@@ -496,3 +496,161 @@ 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.setStack(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 0–100 band under a percent stack, dropping panel limits', () => {
|
||||
const builder = new UPlotConfigBuilder({ id: 'stack-scale' });
|
||||
builder.setStack(StackMode.Percent);
|
||||
builder.addScale({ scaleKey: 'y', softMin: 5, softMax: 500 });
|
||||
|
||||
// Soft, not hard: mixed-sign shares fall outside 0–100 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.setStack(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.setStack(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.setStack(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 0–500.
|
||||
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] }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,13 @@ 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;
|
||||
|
||||
@@ -281,3 +281,20 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ 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,
|
||||
@@ -219,7 +220,9 @@ function BarPanelRenderer({
|
||||
height={containerDimensions.height}
|
||||
syncMode={dashboardPreference?.syncMode}
|
||||
syncFilterMode={dashboardPreference?.syncFilterMode}
|
||||
isStackedBarChart={spec.visualization?.stackedBarChart ?? false}
|
||||
stack={
|
||||
spec.visualization?.stackedBarChart ? StackMode.Normal : StackMode.None
|
||||
}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
onClick={enableDrillDown ? handleChartClick : undefined}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
@@ -101,12 +100,6 @@ 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);
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
package querier
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/chcol"
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/bytedance/sonic"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -30,6 +30,8 @@ var (
|
||||
// written clickhouse query. The column alias indcate which value is
|
||||
// to be considered as final result (or target).
|
||||
legacyReservedColumnTargetAliases = []string{"__result", "__value", "result", "res", "value"}
|
||||
|
||||
CodeFailUnmarshalJSONColumn = errors.MustNewCode("fail_unmarshal_json_column")
|
||||
)
|
||||
|
||||
// stripKeyAlias removes the __SELECT_KEY_<n>_ / __GROUP_BY_KEY_<n>_ prefix from a result
|
||||
@@ -38,32 +40,6 @@ func stripKeyAlias(name string) string {
|
||||
return keyAliasRe.ReplaceAllString(name, "")
|
||||
}
|
||||
|
||||
// unwrapVariant returns the concrete value inside the chcol.Variant envelope the driver scans a
|
||||
// Dynamic column — a JSON path such as body_v2.level — into.
|
||||
func unwrapVariant(val any) any {
|
||||
if v, ok := val.(chcol.Variant); ok {
|
||||
return v.Any()
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
// labelValue renders a group-by value the payload cannot carry as a scalar — a JSON column, or a
|
||||
// Dynamic one — as a stable string, so that rows differing only in that value land in different
|
||||
// series. JSON goes through encoding/json for its sorted map keys: ClickHouse groups documents by
|
||||
// structure, so two rows it considers equal have to produce the same label.
|
||||
func labelValue(val any) string {
|
||||
val = unwrapVariant(val)
|
||||
if val == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := val.(telemetrystoretypes.JSONValue); ok {
|
||||
if raw, err := json.Marshal(v); err == nil {
|
||||
return string(raw)
|
||||
}
|
||||
}
|
||||
return fmt.Sprint(val)
|
||||
}
|
||||
|
||||
// consume reads every row and shapes it into the payload expected for the
|
||||
// given request type.
|
||||
//
|
||||
@@ -90,148 +66,10 @@ func consume(rows driver.Rows, kind qbtypes.RequestType, queryWindow *qbtypes.Ti
|
||||
return payload, err
|
||||
}
|
||||
|
||||
// labelPair is a label held in per-row scratch, so that rows landing in an existing series do not
|
||||
// allocate label objects only to drop them. It keeps the value unboxed — an any field would put
|
||||
// every label of every row on the heap — and boxes once, for the row that creates the series.
|
||||
type labelPair struct {
|
||||
name string
|
||||
display string // the form the series key is built from
|
||||
num float64
|
||||
class tsColumnClass
|
||||
}
|
||||
|
||||
func materialiseLabels(pairs []labelPair) []*qbtypes.Label {
|
||||
labels := make([]*qbtypes.Label, len(pairs))
|
||||
for i, pair := range pairs {
|
||||
var value any = pair.display
|
||||
switch pair.class {
|
||||
case tsColumnNumeric:
|
||||
value = pair.num
|
||||
case tsColumnBool:
|
||||
value = pair.num != 0
|
||||
}
|
||||
labels[i] = &qbtypes.Label{
|
||||
Key: telemetrytypes.TelemetryFieldKey{Name: pair.name},
|
||||
Value: value,
|
||||
}
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
// The *FromSlot helpers skip the reflection in derefValue for the types that actually turn up;
|
||||
// anything else still goes through it, so a missing width is slower, never dropped.
|
||||
func numericFromSlot(ptr any) (float64, bool) {
|
||||
switch v := ptr.(type) {
|
||||
case *float64:
|
||||
return *v, true
|
||||
case *uint64:
|
||||
return float64(*v), true
|
||||
case *int64:
|
||||
return float64(*v), true
|
||||
case *uint32:
|
||||
return float64(*v), true
|
||||
case *int32:
|
||||
return float64(*v), true
|
||||
case *float32:
|
||||
return float64(*v), true
|
||||
case *uint8:
|
||||
return float64(*v), true
|
||||
case *int8:
|
||||
return float64(*v), true
|
||||
}
|
||||
val := derefValue(ptr)
|
||||
if val == nil {
|
||||
return 0, false
|
||||
}
|
||||
return numericAsFloat(val), true
|
||||
}
|
||||
|
||||
func boolFromSlot(ptr any) bool {
|
||||
if flag, ok := ptr.(*bool); ok {
|
||||
return *flag
|
||||
}
|
||||
flag, _ := derefValue(ptr).(bool)
|
||||
return flag
|
||||
}
|
||||
|
||||
func stringFromSlot(ptr any) string {
|
||||
switch v := ptr.(type) {
|
||||
case *string:
|
||||
return *v
|
||||
case **string:
|
||||
if *v == nil {
|
||||
return ""
|
||||
}
|
||||
return **v
|
||||
}
|
||||
str, _ := derefValue(ptr).(string)
|
||||
return str
|
||||
}
|
||||
|
||||
// maxAggregationIndex bounds the __result_<n> indices honored as aggregations; anything past it
|
||||
// is read as a plain numeric column.
|
||||
const maxAggregationIndex = 1000
|
||||
|
||||
type tsColumnClass uint8
|
||||
|
||||
const (
|
||||
tsColumnSkip tsColumnClass = iota
|
||||
tsColumnTimestamp
|
||||
tsColumnNumeric
|
||||
tsColumnBool
|
||||
tsColumnString
|
||||
tsColumnDocument // a JSON or Dynamic column, rendered as a label
|
||||
)
|
||||
|
||||
// tsColumn is what a result column contributes to every row of a time series. The class and the
|
||||
// role are the same for all of them, so they are worked out once instead of per cell.
|
||||
type tsColumn struct {
|
||||
name string
|
||||
class tsColumnClass
|
||||
aggIdx int // -1 unless the column is aliased as an aggregation
|
||||
isTargetAlias bool
|
||||
}
|
||||
|
||||
func planColumns(colNames []string, colTypes []driver.ColumnType) []tsColumn {
|
||||
plan := make([]tsColumn, len(colTypes))
|
||||
for i, colType := range colTypes {
|
||||
name := stripKeyAlias(colNames[i])
|
||||
col := tsColumn{
|
||||
name: name,
|
||||
aggIdx: -1,
|
||||
isTargetAlias: slices.Contains(legacyReservedColumnTargetAliases, name),
|
||||
}
|
||||
// A raw ClickHouse query writes its own aliases, and aggValues and the result buckets are
|
||||
// sized from this index — an unchecked __result_<n> is an allocation of the user's choosing.
|
||||
if m := aggRe.FindStringSubmatch(name); m != nil {
|
||||
if idx, err := strconv.Atoi(m[1]); err == nil && idx < maxAggregationIndex {
|
||||
col.aggIdx = idx
|
||||
}
|
||||
}
|
||||
|
||||
typ := baseType(colType.ScanType())
|
||||
switch {
|
||||
case typ == timeType:
|
||||
col.class = tsColumnTimestamp
|
||||
case typ == jsonValueType, typ == variantType:
|
||||
col.class = tsColumnDocument
|
||||
case isNumericKind(typ):
|
||||
col.class = tsColumnNumeric
|
||||
case typ.Kind() == reflect.Bool:
|
||||
col.class = tsColumnBool
|
||||
case typ.Kind() == reflect.String:
|
||||
col.class = tsColumnString
|
||||
}
|
||||
plan[i] = col
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
func readAsTimeSeries(rows driver.Rows, queryWindow *qbtypes.TimeRange, step qbtypes.Step, queryName string) (*qbtypes.TimeSeriesData, error) {
|
||||
colTypes := rows.ColumnTypes()
|
||||
colNames := rows.Columns()
|
||||
|
||||
plan := planColumns(colNames, colTypes)
|
||||
slots := make([]any, len(colTypes))
|
||||
numericColsCount := 0
|
||||
for i, ct := range colTypes {
|
||||
@@ -284,122 +122,109 @@ func readAsTimeSeries(rows driver.Rows, queryWindow *qbtypes.TimeRange, step qbt
|
||||
lblValsCapacity = 0
|
||||
}
|
||||
|
||||
// Every row writes into the same scratch: the label objects are materialised only for the row
|
||||
// that creates a series, and aggregation slots are indexed by the alias instead of hashed.
|
||||
aggCount := 1
|
||||
for _, col := range plan {
|
||||
if col.aggIdx >= aggCount {
|
||||
aggCount = col.aggIdx + 1
|
||||
}
|
||||
}
|
||||
var (
|
||||
aggValues = make([]float64, aggCount)
|
||||
aggSeen = make([]bool, aggCount)
|
||||
lblVals = make([]string, 0, lblValsCapacity)
|
||||
lblPairs = make([]labelPair, 0, lblValsCapacity)
|
||||
)
|
||||
|
||||
for rows.Next() {
|
||||
if err := rows.Scan(slots...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
clear(aggSeen)
|
||||
lblVals = lblVals[:0]
|
||||
lblPairs = lblPairs[:0]
|
||||
|
||||
var (
|
||||
ts int64
|
||||
anyAgg bool
|
||||
fallbackValue float64 // value when NO __result_N columns exist
|
||||
lblVals = make([]string, 0, lblValsCapacity)
|
||||
lblObjs = make([]*qbtypes.Label, 0, lblValsCapacity)
|
||||
aggValues = map[int]float64{} // all __result_N in this row
|
||||
fallbackValue float64 // value when NO __result_N columns exist
|
||||
fallbackSeen bool
|
||||
)
|
||||
|
||||
for idx, ptr := range slots {
|
||||
col := plan[idx]
|
||||
name := stripKeyAlias(colNames[idx])
|
||||
|
||||
switch col.class {
|
||||
case tsColumnTimestamp:
|
||||
if t, ok := ptr.(*time.Time); ok {
|
||||
ts = t.UnixMilli()
|
||||
} else if t, ok := derefValue(ptr).(time.Time); ok {
|
||||
ts = t.UnixMilli()
|
||||
}
|
||||
switch v := ptr.(type) {
|
||||
case *time.Time:
|
||||
ts = v.UnixMilli()
|
||||
|
||||
case tsColumnNumeric:
|
||||
num, ok := numericFromSlot(ptr)
|
||||
if !ok { // a NULL number is neither a value nor a label
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case col.aggIdx >= 0:
|
||||
aggValues[col.aggIdx] = num
|
||||
aggSeen[col.aggIdx] = true
|
||||
anyAgg = true
|
||||
case numericColsCount == 1, col.isTargetAlias: // classic single-value query
|
||||
fallbackValue = num
|
||||
case *float64, *float32, *int64, *int32, *uint64, *uint32:
|
||||
val := numericAsFloat(reflect.ValueOf(ptr).Elem().Interface())
|
||||
if m := aggRe.FindStringSubmatch(name); m != nil {
|
||||
id, _ := strconv.Atoi(m[1])
|
||||
aggValues[id] = val
|
||||
} else if numericColsCount == 1 { // classic single-value query
|
||||
fallbackValue = val
|
||||
fallbackSeen = true
|
||||
default:
|
||||
display := strconv.FormatFloat(num, 'g', -1, 64)
|
||||
lblVals = append(lblVals, display)
|
||||
lblPairs = append(lblPairs, labelPair{
|
||||
name: col.name,
|
||||
display: display,
|
||||
num: num,
|
||||
class: tsColumnNumeric,
|
||||
} else if slices.Contains(legacyReservedColumnTargetAliases, name) {
|
||||
fallbackValue = val
|
||||
fallbackSeen = true
|
||||
} else {
|
||||
// numeric label
|
||||
lblVals = append(lblVals, fmt.Sprint(val))
|
||||
lblObjs = append(lblObjs, &qbtypes.Label{
|
||||
Key: telemetrytypes.TelemetryFieldKey{Name: name},
|
||||
Value: val,
|
||||
})
|
||||
}
|
||||
|
||||
case tsColumnBool:
|
||||
flag := boolFromSlot(ptr)
|
||||
switch {
|
||||
case col.aggIdx >= 0:
|
||||
aggValues[col.aggIdx] = boolAsFloat(flag)
|
||||
aggSeen[col.aggIdx] = true
|
||||
anyAgg = true
|
||||
case col.isTargetAlias:
|
||||
fallbackValue = boolAsFloat(flag)
|
||||
fallbackSeen = true
|
||||
default:
|
||||
display := strconv.FormatBool(flag)
|
||||
lblVals = append(lblVals, display)
|
||||
lblPairs = append(lblPairs, labelPair{
|
||||
name: col.name,
|
||||
display: display,
|
||||
num: boolAsFloat(flag),
|
||||
class: tsColumnBool,
|
||||
})
|
||||
case **float64, **float32, **int64, **int32, **uint64, **uint32:
|
||||
tempVal := reflect.ValueOf(ptr)
|
||||
if tempVal.IsValid() && !tempVal.IsNil() && !tempVal.Elem().IsNil() {
|
||||
val := numericAsFloat(tempVal.Elem().Elem().Interface())
|
||||
if m := aggRe.FindStringSubmatch(name); m != nil {
|
||||
id, _ := strconv.Atoi(m[1])
|
||||
aggValues[id] = val
|
||||
} else if numericColsCount == 1 { // classic single-value query
|
||||
fallbackValue = val
|
||||
fallbackSeen = true
|
||||
} else if slices.Contains(legacyReservedColumnTargetAliases, name) {
|
||||
fallbackValue = val
|
||||
fallbackSeen = true
|
||||
} else {
|
||||
// numeric label
|
||||
lblVals = append(lblVals, fmt.Sprint(val))
|
||||
lblObjs = append(lblObjs, &qbtypes.Label{
|
||||
Key: telemetrytypes.TelemetryFieldKey{Name: name},
|
||||
Value: val,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
case tsColumnString:
|
||||
label := stringFromSlot(ptr) // a NULL string labels the series with the empty string
|
||||
lblVals = append(lblVals, label)
|
||||
lblPairs = append(lblPairs, labelPair{name: col.name, display: label, class: tsColumnString})
|
||||
case *string:
|
||||
lblVals = append(lblVals, *v)
|
||||
lblObjs = append(lblObjs, &qbtypes.Label{
|
||||
Key: telemetrytypes.TelemetryFieldKey{Name: name},
|
||||
Value: *v,
|
||||
})
|
||||
|
||||
case tsColumnDocument:
|
||||
label := labelValue(derefValue(ptr))
|
||||
lblVals = append(lblVals, label)
|
||||
lblPairs = append(lblPairs, labelPair{name: col.name, display: label, class: tsColumnDocument})
|
||||
case **string:
|
||||
val := *v
|
||||
if val == nil {
|
||||
var empty string
|
||||
val = &empty
|
||||
}
|
||||
lblVals = append(lblVals, *val)
|
||||
lblObjs = append(lblObjs, &qbtypes.Label{
|
||||
Key: telemetrytypes.TelemetryFieldKey{Name: name},
|
||||
Value: *val,
|
||||
})
|
||||
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Edge-case: no __result_N columns, but a single numeric column present
|
||||
if !anyAgg && fallbackSeen {
|
||||
if len(aggValues) == 0 && fallbackSeen {
|
||||
aggValues[0] = fallbackValue
|
||||
aggSeen[0] = true
|
||||
anyAgg = true
|
||||
}
|
||||
|
||||
if ts == 0 || !anyAgg {
|
||||
if ts == 0 || len(aggValues) == 0 {
|
||||
continue // nothing useful
|
||||
}
|
||||
|
||||
slices.Sort(lblVals)
|
||||
sort.Strings(lblVals)
|
||||
labelsKey := strings.Join(lblVals, ",")
|
||||
|
||||
// one point per aggregation in this row
|
||||
for aggIdx, val := range aggValues {
|
||||
if !aggSeen[aggIdx] || math.IsNaN(val) || math.IsInf(val, 0) {
|
||||
if math.IsNaN(val) || math.IsInf(val, 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -407,7 +232,7 @@ func readAsTimeSeries(rows driver.Rows, queryWindow *qbtypes.TimeRange, step qbt
|
||||
|
||||
series, ok := seriesMap[key]
|
||||
if !ok {
|
||||
series = &qbtypes.TimeSeries{Labels: materialiseLabels(lblPairs)}
|
||||
series = &qbtypes.TimeSeries{Labels: lblObjs}
|
||||
seriesMap[key] = series
|
||||
}
|
||||
series.Values = append(series.Values, &qbtypes.TimeSeriesValue{
|
||||
@@ -457,20 +282,6 @@ func readAsTimeSeries(rows driver.Rows, queryWindow *qbtypes.TimeRange, step qbt
|
||||
}, nil
|
||||
}
|
||||
|
||||
var (
|
||||
timeType = reflect.TypeFor[time.Time]()
|
||||
jsonValueType = reflect.TypeFor[telemetrystoretypes.JSONValue]()
|
||||
variantType = reflect.TypeFor[chcol.Variant]()
|
||||
)
|
||||
|
||||
// baseType unwraps pointer levels, so that a Nullable column is classified like the column it wraps.
|
||||
func baseType(t reflect.Type) reflect.Type {
|
||||
for t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func isNumericKind(t reflect.Type) bool {
|
||||
if t == nil {
|
||||
return false
|
||||
@@ -534,7 +345,7 @@ func readAsScalar(rows driver.Rows, queryName string) (*qbtypes.ScalarData, erro
|
||||
// 2. deref each slot into the output row
|
||||
row := make([]any, len(scan))
|
||||
for i, cell := range scan {
|
||||
row[i] = unwrapVariant(derefValue(cell))
|
||||
row[i] = derefValue(cell)
|
||||
}
|
||||
data = append(data, row)
|
||||
}
|
||||
@@ -571,13 +382,31 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
|
||||
colTypes := rows.ColumnTypes()
|
||||
colCnt := len(colNames)
|
||||
|
||||
// Helper that decides scan target per column based on DB type
|
||||
makeScanTarget := func(i int) any {
|
||||
dbt := strings.ToUpper(colTypes[i].DatabaseTypeName())
|
||||
if strings.HasPrefix(dbt, "JSON") {
|
||||
// Since the driver fails to decode JSON/Dynamic into native Go values, we read it as raw bytes
|
||||
// TODO: check in future if fixed in the driver
|
||||
var v []byte
|
||||
return &v
|
||||
}
|
||||
return reflect.New(colTypes[i].ScanType()).Interface()
|
||||
}
|
||||
|
||||
// Build a template slice of correctly-typed pointers once
|
||||
scanTpl := make([]any, colCnt)
|
||||
for i := range colTypes {
|
||||
scanTpl[i] = makeScanTarget(i)
|
||||
}
|
||||
|
||||
var outRows []*qbtypes.RawRow
|
||||
|
||||
for rows.Next() {
|
||||
// fresh copy of the scan slice (otherwise the driver reuses pointers)
|
||||
scan := make([]any, colCnt)
|
||||
for i := range colTypes {
|
||||
scan[i] = reflect.New(colTypes[i].ScanType()).Interface()
|
||||
for i := range scanTpl {
|
||||
scan[i] = makeScanTarget(i)
|
||||
}
|
||||
|
||||
if err := rows.Scan(scan...); err != nil {
|
||||
@@ -592,7 +421,21 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
|
||||
name := stripKeyAlias(colNames[i])
|
||||
|
||||
// de-reference the typed pointer to any
|
||||
val := unwrapVariant(reflect.ValueOf(cellPtr).Elem().Interface())
|
||||
val := reflect.ValueOf(cellPtr).Elem().Interface()
|
||||
// Post-process JSON columns: unmarshal bytes into map[string]any
|
||||
if strings.HasPrefix(strings.ToUpper(colTypes[i].DatabaseTypeName()), "JSON") {
|
||||
switch x := val.(type) {
|
||||
case []byte:
|
||||
var m map[string]any
|
||||
err := sonic.Unmarshal(x, &m)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, CodeFailUnmarshalJSONColumn, "failed to unmarshal JSON column %s", name)
|
||||
}
|
||||
val = m
|
||||
default:
|
||||
// already a structured type (map[string]any, []any, etc.)
|
||||
}
|
||||
}
|
||||
|
||||
// special-case: timestamp column
|
||||
if name == "timestamp" || name == "timestamp_datetime" {
|
||||
@@ -669,13 +512,6 @@ func mergeSpanAttributeColumns(data map[string]any) {
|
||||
}
|
||||
}
|
||||
|
||||
func boolAsFloat(v bool) float64 {
|
||||
if v {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// numericAsFloat converts numeric types to float64 efficiently.
|
||||
func numericAsFloat(v any) float64 {
|
||||
switch x := v.(type) {
|
||||
|
||||
@@ -3,17 +3,8 @@ package querier
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/chcol"
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
||||
cmock "github.com/SigNoz/clickhouse-go-mock"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMergeSpanAttributeColumns_ParsesEventsAndLinks(t *testing.T) {
|
||||
@@ -84,103 +75,6 @@ func TestMergeSpanAttributeColumns_ParsesEventsAndLinks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A ClickHouse query can put a JSON column in the result of any request type — e.g.
|
||||
// `select * from signoz_logs.logs_v2` on a body_v2 stack, where `*` covers body_v2.
|
||||
func TestConsume_JSONColumn(t *testing.T) {
|
||||
ts := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC)
|
||||
body := `{"level":"error","attrs":{"code":500}}`
|
||||
wantBody := telemetrystoretypes.JSONValue{
|
||||
"level": "error",
|
||||
"attrs": map[string]any{"code": float64(500)},
|
||||
}
|
||||
|
||||
// the scalar reader reuses its scan slots across rows, so each row must still carry its own body
|
||||
t.Run("scalar", func(t *testing.T) {
|
||||
rows := telemetrystore.WrapRows(cmock.NewRows([]cmock.ColumnType{
|
||||
{Name: "body_v2", Type: "JSON"},
|
||||
{Name: "__result_0", Type: "UInt64"},
|
||||
}, [][]any{{body, uint64(3)}, {`{"level":"warn"}`, uint64(1)}}))
|
||||
|
||||
payload, err := consume(rows, qbtypes.RequestTypeScalar, nil, qbtypes.Step{}, "A")
|
||||
require.NoError(t, err)
|
||||
|
||||
data := payload.(*qbtypes.ScalarData)
|
||||
require.Len(t, data.Data, 2)
|
||||
assert.Equal(t, wantBody, data.Data[0][0])
|
||||
assert.Equal(t, uint64(3), data.Data[0][1])
|
||||
assert.Equal(t, telemetrystoretypes.JSONValue{"level": "warn"}, data.Data[1][0])
|
||||
assert.Equal(t, uint64(1), data.Data[1][1])
|
||||
})
|
||||
|
||||
t.Run("time series", func(t *testing.T) {
|
||||
rows := telemetrystore.WrapRows(cmock.NewRows([]cmock.ColumnType{
|
||||
{Name: "ts", Type: "DateTime"},
|
||||
{Name: "body_v2", Type: "JSON"},
|
||||
{Name: "__result_0", Type: "UInt64"},
|
||||
}, [][]any{{ts, body, uint64(3)}}))
|
||||
|
||||
payload, err := consume(rows, qbtypes.RequestTypeTimeSeries, nil, qbtypes.Step{}, "A")
|
||||
require.NoError(t, err)
|
||||
|
||||
data := payload.(*qbtypes.TimeSeriesData)
|
||||
require.Len(t, data.Aggregations, 1)
|
||||
require.Len(t, data.Aggregations[0].Series, 1)
|
||||
require.Len(t, data.Aggregations[0].Series[0].Values, 1)
|
||||
assert.Equal(t, float64(3), data.Aggregations[0].Series[0].Values[0].Value)
|
||||
})
|
||||
|
||||
// grouping by a JSON column is legal in ClickHouse, so each document has to label its own
|
||||
// series rather than being dropped, which would merge every group into one
|
||||
t.Run("time series grouped by the JSON column", func(t *testing.T) {
|
||||
rows := telemetrystore.WrapRows(cmock.NewRows([]cmock.ColumnType{
|
||||
{Name: "ts", Type: "DateTime"},
|
||||
{Name: "body_v2", Type: "JSON"},
|
||||
{Name: "__result_0", Type: "UInt64"},
|
||||
}, [][]any{
|
||||
{ts, `{"level":"error"}`, uint64(7)},
|
||||
{ts, `{"level":"warn"}`, uint64(2)},
|
||||
}))
|
||||
|
||||
payload, err := consume(rows, qbtypes.RequestTypeTimeSeries, nil, qbtypes.Step{}, "A")
|
||||
require.NoError(t, err)
|
||||
|
||||
data := payload.(*qbtypes.TimeSeriesData)
|
||||
require.Len(t, data.Aggregations, 1)
|
||||
require.Len(t, data.Aggregations[0].Series, 2)
|
||||
|
||||
got := map[string]float64{}
|
||||
for _, series := range data.Aggregations[0].Series {
|
||||
require.Len(t, series.Labels, 1)
|
||||
require.Len(t, series.Values, 1)
|
||||
got[series.Labels[0].Value.(string)] = series.Values[0].Value
|
||||
}
|
||||
assert.Equal(t, map[string]float64{`{"level":"error"}`: 7, `{"level":"warn"}`: 2}, got)
|
||||
})
|
||||
|
||||
t.Run("raw", func(t *testing.T) {
|
||||
rows := telemetrystore.WrapRows(cmock.NewRows([]cmock.ColumnType{
|
||||
{Name: "timestamp", Type: "DateTime"},
|
||||
{Name: "body_v2", Type: "JSON"},
|
||||
}, [][]any{{ts, body}}))
|
||||
|
||||
payload, err := consume(rows, qbtypes.RequestTypeRaw, nil, qbtypes.Step{}, "A")
|
||||
require.NoError(t, err)
|
||||
|
||||
data := payload.(*qbtypes.RawData)
|
||||
require.Len(t, data.Rows, 1)
|
||||
assert.Equal(t, ts, data.Rows[0].Timestamp.UTC())
|
||||
assert.Equal(t, wantBody, data.Rows[0].Data["body_v2"])
|
||||
})
|
||||
}
|
||||
|
||||
// A JSON path (e.g. `body_v2.level`) comes back as a Dynamic column, which the driver scans
|
||||
// into a chcol.Variant envelope rather than the value itself.
|
||||
func TestUnwrapVariant(t *testing.T) {
|
||||
assert.Equal(t, "error", unwrapVariant(chcol.NewDynamicWithType("error", "String")))
|
||||
assert.Nil(t, unwrapVariant(chcol.Dynamic{}))
|
||||
assert.Equal(t, uint64(3), unwrapVariant(uint64(3)))
|
||||
}
|
||||
|
||||
func TestMergeSpanAttributeColumns_EmptyEventsAndLinks(t *testing.T) {
|
||||
data := map[string]any{
|
||||
"events": []string{},
|
||||
@@ -196,114 +90,3 @@ func TestMergeSpanAttributeColumns_EmptyEventsAndLinks(t *testing.T) {
|
||||
t.Fatalf("expected empty []spantypes.Link, got %#v", data["links"])
|
||||
}
|
||||
}
|
||||
|
||||
// boolRows reports a bool scan type for one column, which cmock cannot do on its own.
|
||||
type boolRows struct {
|
||||
driver.Rows
|
||||
col string
|
||||
}
|
||||
|
||||
func (r boolRows) ColumnTypes() []driver.ColumnType {
|
||||
out := r.Rows.ColumnTypes()
|
||||
for i, colType := range out {
|
||||
if colType.Name() == r.col {
|
||||
out[i] = cmock.NewColumnType(colType.Name(), "Bool", false, reflect.TypeOf(true))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// A time-series result can carry numeric widths narrower than 32 bits — max(severity_number) is
|
||||
// UInt8, kind is Int8, has_error is Bool. Dropping them empties the chart when the column holds the
|
||||
// aggregation, and merges every group into one series when it is a group-by key.
|
||||
func TestConsume_NarrowNumericWidths(t *testing.T) {
|
||||
ts := time.Date(2026, 8, 19, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
seriesOf := func(t *testing.T, rows driver.Rows) []*qbtypes.TimeSeries {
|
||||
t.Helper()
|
||||
payload, err := consume(rows, qbtypes.RequestTypeTimeSeries, nil, qbtypes.Step{}, "A")
|
||||
require.NoError(t, err)
|
||||
data := payload.(*qbtypes.TimeSeriesData)
|
||||
require.Len(t, data.Aggregations, 1)
|
||||
return data.Aggregations[0].Series
|
||||
}
|
||||
|
||||
t.Run("UInt8 aggregation", func(t *testing.T) {
|
||||
series := seriesOf(t, cmock.NewRows([]cmock.ColumnType{
|
||||
{Name: "ts", Type: "DateTime"},
|
||||
{Name: "__result_0", Type: "UInt8"},
|
||||
}, [][]any{{ts, uint8(17)}}))
|
||||
|
||||
require.Len(t, series, 1)
|
||||
require.Len(t, series[0].Values, 1)
|
||||
assert.Equal(t, float64(17), series[0].Values[0].Value)
|
||||
})
|
||||
|
||||
t.Run("Int8 group-by", func(t *testing.T) {
|
||||
series := seriesOf(t, cmock.NewRows([]cmock.ColumnType{
|
||||
{Name: "ts", Type: "DateTime"},
|
||||
{Name: "kind", Type: "Int8"},
|
||||
{Name: "__result_0", Type: "UInt64"},
|
||||
}, [][]any{{ts, int8(2), uint64(7)}, {ts, int8(3), uint64(2)}}))
|
||||
|
||||
got := map[float64]float64{}
|
||||
for _, s := range series {
|
||||
require.Len(t, s.Labels, 1)
|
||||
require.Len(t, s.Values, 1)
|
||||
got[s.Labels[0].Value.(float64)] = s.Values[0].Value
|
||||
}
|
||||
assert.Equal(t, map[float64]float64{2: 7, 3: 2}, got)
|
||||
})
|
||||
|
||||
t.Run("Bool aggregation", func(t *testing.T) {
|
||||
series := seriesOf(t, boolRows{col: "__result_0", Rows: cmock.NewRows([]cmock.ColumnType{
|
||||
{Name: "ts", Type: "DateTime"},
|
||||
{Name: "__result_0", Type: "UInt8"},
|
||||
}, [][]any{{ts, uint8(1)}})})
|
||||
|
||||
require.Len(t, series, 1)
|
||||
require.Len(t, series[0].Values, 1)
|
||||
assert.Equal(t, float64(1), series[0].Values[0].Value)
|
||||
})
|
||||
|
||||
t.Run("Bool group-by", func(t *testing.T) {
|
||||
series := seriesOf(t, boolRows{col: "has_error", Rows: cmock.NewRows([]cmock.ColumnType{
|
||||
{Name: "ts", Type: "DateTime"},
|
||||
{Name: "has_error", Type: "UInt8"},
|
||||
{Name: "__result_0", Type: "UInt64"},
|
||||
}, [][]any{{ts, uint8(1), uint64(7)}, {ts, uint8(0), uint64(2)}})})
|
||||
|
||||
got := map[bool]float64{}
|
||||
for _, s := range series {
|
||||
require.Len(t, s.Labels, 1)
|
||||
require.Len(t, s.Values, 1)
|
||||
got[s.Labels[0].Value.(bool)] = s.Values[0].Value
|
||||
}
|
||||
assert.Equal(t, map[bool]float64{true: 7, false: 2}, got)
|
||||
})
|
||||
}
|
||||
|
||||
// A raw ClickHouse query picks its own aliases, so __result_<n> can carry any index — including
|
||||
// one that overflows int or would size a slice in the terabytes.
|
||||
func TestConsume_HugeAggregationAlias(t *testing.T) {
|
||||
ts := time.Date(2026, 8, 19, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
for _, alias := range []string{"__result_99999999999999999999", "__result_4000000000"} {
|
||||
t.Run(alias, func(t *testing.T) {
|
||||
rows := cmock.NewRows([]cmock.ColumnType{
|
||||
{Name: "ts", Type: "DateTime"},
|
||||
{Name: alias, Type: "UInt64"},
|
||||
}, [][]any{{ts, uint64(7)}})
|
||||
|
||||
payload, err := consume(rows, qbtypes.RequestTypeTimeSeries, nil, qbtypes.Step{}, "A")
|
||||
require.NoError(t, err)
|
||||
|
||||
// the alias is not honored as an aggregation; the single numeric column carries the value
|
||||
data := payload.(*qbtypes.TimeSeriesData)
|
||||
require.Len(t, data.Aggregations, 1)
|
||||
require.Len(t, data.Aggregations[0].Series, 1)
|
||||
require.Len(t, data.Aggregations[0].Series[0].Values, 1)
|
||||
assert.Equal(t, float64(7), data.Aggregations[0].Series[0].Values[0].Value)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
@@ -1066,7 +1065,7 @@ func (q *querier) postProcessLogBody(ctx context.Context, orgID valuer.UUID, res
|
||||
return result
|
||||
}
|
||||
for _, row := range rawData.Rows {
|
||||
bodyMap, ok := row.Data["body"].(telemetrystoretypes.JSONValue)
|
||||
bodyMap, ok := row.Data["body"].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ func (p *provider) Query(ctx context.Context, query string, args ...interface{})
|
||||
}
|
||||
|
||||
return &rowsWithHooks{
|
||||
Rows: telemetrystore.WrapRows(rows),
|
||||
Rows: rows,
|
||||
ctx: ctx,
|
||||
event: event,
|
||||
onClose: func() { telemetrystore.WrapAfterQuery(p.hooks, ctx, event) },
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
package telemetrystore
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
|
||||
)
|
||||
|
||||
// WrapRows reports JSONValue as the scan type of every JSON column. Nested JSON — Array(JSON),
|
||||
// Map(String, JSON) — is not covered.
|
||||
func WrapRows(rows driver.Rows) driver.Rows {
|
||||
return &rowsWithJSONScanType{Rows: rows}
|
||||
}
|
||||
|
||||
type rowsWithJSONScanType struct {
|
||||
driver.Rows
|
||||
}
|
||||
|
||||
func (r *rowsWithJSONScanType) ColumnTypes() []driver.ColumnType {
|
||||
colTypes := r.Rows.ColumnTypes()
|
||||
wrapped := make([]driver.ColumnType, len(colTypes))
|
||||
for i, colType := range colTypes {
|
||||
wrapped[i] = colType
|
||||
if strings.HasPrefix(strings.ToUpper(colType.DatabaseTypeName()), "JSON") {
|
||||
wrapped[i] = jsonColumnType{ColumnType: colType}
|
||||
}
|
||||
}
|
||||
return wrapped
|
||||
}
|
||||
|
||||
type jsonColumnType struct {
|
||||
driver.ColumnType
|
||||
}
|
||||
|
||||
func (jsonColumnType) ScanType() reflect.Type {
|
||||
return reflect.TypeFor[telemetrystoretypes.JSONValue]()
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2"
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
cmock "github.com/SigNoz/clickhouse-go-mock"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
@@ -33,21 +32,7 @@ func New(_ telemetrystore.Config, matcher sqlmock.QueryMatcher) *Provider {
|
||||
|
||||
// ClickhouseDB returns the mock Clickhouse connection.
|
||||
func (p *Provider) ClickhouseDB() clickhouse.Conn {
|
||||
return conn{Conn: p.clickhouseDB.(clickhouse.Conn)}
|
||||
}
|
||||
|
||||
// conn wraps rows the way the clickhouse provider does, so mocked JSON columns report the scan
|
||||
// type they do in production.
|
||||
type conn struct {
|
||||
clickhouse.Conn
|
||||
}
|
||||
|
||||
func (c conn) Query(ctx context.Context, query string, args ...any) (driver.Rows, error) {
|
||||
rows, err := c.Conn.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return telemetrystore.WrapRows(rows), nil
|
||||
return p.clickhouseDB.(clickhouse.Conn)
|
||||
}
|
||||
|
||||
// Cluster returns the cluster name.
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
package telemetrystoretypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/bytedance/sonic"
|
||||
)
|
||||
|
||||
var ErrCodeUnmarshalJSONColumn = errors.MustNewCode("fail_unmarshal_json_column")
|
||||
|
||||
// JSONValue is the scan target for a ClickHouse JSON column: the connection sets
|
||||
// output_format_native_write_json_as_string, so the column arrives as a raw document rather than
|
||||
// the chcol.JSON the driver reports as its scan type.
|
||||
type JSONValue map[string]any
|
||||
|
||||
// Scan decodes into a fresh map every time: a scan target is reused across rows, and unmarshalling
|
||||
// into the map already there would both keep its keys and hand every row the same map.
|
||||
func (v *JSONValue) Scan(src any) error {
|
||||
var raw []byte
|
||||
switch value := src.(type) {
|
||||
case nil:
|
||||
*v = nil
|
||||
return nil
|
||||
case string:
|
||||
raw = []byte(value)
|
||||
case []byte:
|
||||
raw = value
|
||||
default:
|
||||
return errors.NewInternalf(ErrCodeUnmarshalJSONColumn, "cannot decode %T as a JSON column", src)
|
||||
}
|
||||
|
||||
decoded := JSONValue{}
|
||||
if err := sonic.Unmarshal(raw, &decoded); err != nil {
|
||||
return errors.WrapInternalf(err, ErrCodeUnmarshalJSONColumn, "failed to unmarshal JSON column")
|
||||
}
|
||||
*v = decoded
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user