Compare commits

...

14 Commits

Author SHA1 Message Date
Abhi Kumar
5ed8dc78ca fix(dashboards-v2): prevent pie donut and labels from clipping
The donut used a flat radius = 0.35 * min(w,h) with leader labels anchored
at 1.3 * radius, leaving only ~0.045 * size between the label anchor and
the SVG edge — so on small panels the top/bottom labels (and a stray
container padding under border-box) clipped.

- getDonutGeometry now solves the radius back from the box half-extent
  minus a fixed label allowance, so the label anchor always lands a
  constant 22px inside the edge regardless of panel size — extracted as a
  pure, tested helper with a shared label-ratio constant (no drift with
  getArcGeometry) and a DonutGeometry type.
- Align the pie legend wrapper padding with ChartLayout's.
- Tests for getDonutGeometry (anchor stays in-box, derived radii, smaller
  axis wins, no negative radius).

echo "--- commit 2 done ---" && git log --oneline -3
echo "--- remaining ---" && git status --short
2026-06-15 16:40:41 +05:30
Abhi Kumar
4ccef6190d fix(dashboards-v2): cap bottom legend height at 30% of the panel
The bottom-legend reservation was capped only at min(2 rows, 80px) with
no relation to the container height, despite the helper's doc promising a
%-of-container cap. On short grid panels the legend kept its full slice
and the chart — the pie donut especially — collapsed to a sliver.

- calculateChartDimensions now also caps the bottom legend at 30% of the
  container height (mirroring the RIGHT-legend width cap).
- Drop the legend grid row-gap so the capped height isn't overrun.
- Add the previously-missing tests for calculateChartDimensions,
  including the short-container cap.

echo "--- commit 1 done ---" && git log --oneline -1
echo "--- remaining ---" && git status --short
2026-06-15 16:40:41 +05:30
Abhi Kumar
cd77cb7cb0 feat(dashboards-v2): number panel kind + registry entry 2026-06-15 16:40:41 +05:30
Abhi Kumar
e30f9725f6 feat(dashboards): support = and != threshold operators 2026-06-15 16:40:41 +05:30
Abhi Kumar
e20c71ef4d chore(dashboards-v2): dashboards-list-v2 query-param sort/order adjustments 2026-06-15 16:40:41 +05:30
Abhi Kumar
493c85a94e feat(dashboards-v2): panel chrome - header, body, interactions & grid wiring 2026-06-15 16:40:41 +05:30
Abhi Kumar
2d2e13ec3b feat(dashboards-v2): panel status popover (error/warning surfacing) 2026-06-15 16:40:41 +05:30
Abhi Kumar
e9b0089c8c feat(dashboards-v2): panel plugin registry 2026-06-15 16:40:41 +05:30
Abhi Kumar
afbbd644a7 feat(dashboards-v2): PieChartPanel renderer 2026-06-15 16:40:41 +05:30
Abhi Kumar
15740bd928 feat(dashboards-v2): HistogramPanel renderer 2026-06-15 16:40:41 +05:30
Abhi Kumar
98453ea4b4 feat(dashboards-v2): BarChartPanel renderer 2026-06-15 16:40:41 +05:30
Abhi Kumar
6ac2a398d8 feat(dashboards-v2): TimeSeriesPanel renderer 2026-06-15 16:40:41 +05:30
Abhi Kumar
90160054f6 feat(dashboards-v2): usePanelQuery data-fetching hook 2026-06-15 16:40:41 +05:30
Abhi Kumar
2064c08137 feat(dashboards-v2): panel type system & shared chart utilities 2026-06-15 16:40:41 +05:30
88 changed files with 6153 additions and 62 deletions

View File

@@ -63,5 +63,6 @@
flex: 0 0 auto;
min-height: 0;
min-width: 0;
padding: 8px;
padding-left: 12px;
padding-bottom: 12px;
}

View File

@@ -16,7 +16,7 @@ import PieArc from './PieArc';
import PieCenterLabel from './PieCenterLabel';
import styles from './Pie.module.scss';
import { PieTooltipData } from './types';
import { getFillColor } from './utils';
import { getDonutGeometry, getFillColor } from './utils';
/**
* Donut chart rendered with @visx. Splits its area into chart + legend with the
@@ -78,16 +78,12 @@ export default function Pie({
[containerWidth, containerHeight, position, data],
);
// Donut geometry derived from the allocated chart box.
const { size, radius, innerRadius } = useMemo(() => {
const nextSize = Math.min(width, height);
const nextRadius = nextSize * 0.35;
return {
size: nextSize,
radius: nextRadius,
innerRadius: nextRadius * 0.6,
};
}, [width, height]);
// Donut geometry derived from the allocated chart box, sized to leave room
// for the external leader labels (see getDonutGeometry).
const { size, radius, innerRadius } = useMemo(
() => getDonutGeometry(width, height),
[width, height],
);
const totalValue = useMemo(
() => visibleData.reduce((sum, slice) => sum + slice.value, 0),

View File

@@ -1,11 +1,40 @@
import {
getArcGeometry,
getDonutGeometry,
getFillColor,
getScaledFontSize,
lightenColor,
} from '../utils';
describe('Pie utils', () => {
describe('getDonutGeometry', () => {
it('keeps the label anchor inside the box (reserves room for leader labels)', () => {
const { radius } = getDonutGeometry(400, 300);
const half = Math.min(400, 300) / 2; // 150
// The label anchor sits at radius * 1.3 and must stay within the box
// half-extent so labels are not clipped.
expect(radius * 1.3).toBeLessThanOrEqual(half);
// And it should use the available room (anchor = half - 22 allowance).
expect(radius * 1.3).toBeCloseTo(half - 22);
});
it('derives size and inner radius from the outer radius', () => {
const { size, radius, innerRadius } = getDonutGeometry(300, 300);
expect(size).toBeCloseTo(radius * 2);
expect(innerRadius).toBeCloseTo(radius * 0.6);
});
it('sizes off the smaller dimension so it fits both axes', () => {
expect(getDonutGeometry(1000, 200)).toStrictEqual(
getDonutGeometry(200, 1000),
);
});
it('never returns a negative radius for a box too small for labels', () => {
expect(getDonutGeometry(20, 20).radius).toBe(0);
});
});
describe('getScaledFontSize', () => {
it('returns the base size for empty text', () => {
expect(getScaledFontSize({ text: '', baseSize: 30, innerRadius: 100 })).toBe(

View File

@@ -10,6 +10,16 @@ export interface ScaledFontSizeArgs {
innerRadius: number;
}
/** Donut sizing for a given chart box: the outer/inner radii and the square it spans. */
export interface DonutGeometry {
/** Outer diameter — feeds the visx Pie width/height and the render guard. */
size: number;
/** Outer radius of the donut ring. */
radius: number;
/** Inner radius (the hole) — also bounds the centre-total font. */
innerRadius: number;
}
export interface ArcGeometry {
/** Outer point where the leader label sits. */
labelX: number;

View File

@@ -3,7 +3,37 @@
* so the renderer stays declarative (per the one-component-per-file rule).
*/
import { ArcGeometry, ParsedRgb, ScaledFontSizeArgs } from './types';
import {
ArcGeometry,
DonutGeometry,
ParsedRgb,
ScaledFontSizeArgs,
} from './types';
// Leader-line + two-line label/value drawn outside the donut. `getArcGeometry`
// anchors the label at `radius * LABEL_RADIUS_RATIO`; `LABEL_TEXT_ALLOWANCE` is
// the px reserved beyond that anchor for the (10px, two-line) text so it never
// clips against the SVG edge.
const LABEL_RADIUS_RATIO = 1.3;
const LABEL_TEXT_ALLOWANCE = 22;
const INNER_RADIUS_RATIO = 0.6;
/**
* Sizes the donut to fit inside a `width × height` box *with room for the
* external leader labels*. The label anchor sits at `radius * 1.3`, so we solve
* the outer radius back from the box's half-extent minus the text allowance —
* guaranteeing the labels stay inside the SVG instead of being clipped (V1 used
* a flat `0.35 * min(w,h)`, which left too little margin on small panels).
*/
export function getDonutGeometry(width: number, height: number): DonutGeometry {
const half = Math.min(width, height) / 2;
const radius = Math.max(0, (half - LABEL_TEXT_ALLOWANCE) / LABEL_RADIUS_RATIO);
return {
size: radius * 2,
radius,
innerRadius: radius * INNER_RADIUS_RATIO,
};
}
/**
* Shrinks the centre-total font as the text gets longer so it never overflows
@@ -37,7 +67,7 @@ export function getArcGeometry(
radius: number,
): ArcGeometry {
const angle = (startAngle + endAngle) / 2;
const labelRadius = radius * 1.3;
const labelRadius = radius * LABEL_RADIUS_RATIO;
const lineEndRadius = radius * 1.1;
return {
labelX: Math.sin(angle) * labelRadius,

View File

@@ -0,0 +1,79 @@
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { calculateChartDimensions } from '../utils';
const labels = (count: number, length = 20): string[] =>
Array.from({ length: count }, (_, i) =>
`label-${i}`.padEnd(length, 'x').slice(0, length),
);
describe('calculateChartDimensions', () => {
it('returns all zeros when the container has no space', () => {
expect(
calculateChartDimensions({
containerWidth: 0,
containerHeight: 300,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(3),
}),
).toStrictEqual({
width: 0,
height: 0,
legendWidth: 0,
legendHeight: 0,
averageLegendWidth: 0,
});
});
it('RIGHT: reserves a side column capped at 30% of the width and keeps full height', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 400,
legendConfig: { position: LegendPosition.RIGHT },
seriesLabels: labels(10, 40),
});
// 40-char labels approximate to 336px, capped at min(240, 30% of 1000).
expect(dims.legendWidth).toBe(240);
expect(dims.width).toBe(760);
expect(dims.height).toBe(400);
expect(dims.legendHeight).toBe(400);
});
it('BOTTOM: a single row of items reserves one legend row', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 500,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(3),
});
// One row = line height (28) + padding (12).
expect(dims.legendHeight).toBe(40);
expect(dims.height).toBe(460);
expect(dims.legendWidth).toBe(1000);
});
it('BOTTOM: many items cap at two rows on a tall container', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 500,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(40),
});
// Two rows = 2 * 40 - 12 (no trailing padding) = 68, under the 80px cap.
expect(dims.legendHeight).toBe(68);
expect(dims.height).toBe(432);
});
it('BOTTOM: on a short container the legend never takes more than 30% of the height', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 160,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(40),
});
// Without the height-relative cap the legend would take 68px of a 160px
// panel and the chart (pie especially) collapses to a sliver.
expect(dims.legendHeight).toBe(48); // 30% of 160
expect(dims.height).toBe(112);
});
});

View File

@@ -116,7 +116,15 @@ export function calculateChartDimensions({
? legendRowCount * legendRowHeight - LEGEND_PADDING
: legendRowHeight;
const maxAllowedLegendHeight = Math.min(2 * legendRowHeight, 80);
// Cap at two rows / 80px, and never more than 30% of the container height
// (the doc above always promised the %-cap; without it, short grid panels
// hand most of their area to the legend and the chart — the pie donut
// especially — collapses to a sliver). 30% mirrors the RIGHT-legend width cap.
const maxAllowedLegendHeight = Math.min(
2 * legendRowHeight,
80,
Math.floor(containerHeight * 0.3),
);
const bottomLegendHeight = Math.min(
idealBottomLegendHeight,

View File

@@ -2,6 +2,7 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
createColumnsAndDataSource,
evaluateThresholdWithConvertedValue,
getQueryLegend,
sortFunction,
} from '../utils';
@@ -225,3 +226,30 @@ describe('Table Panel utils with QB v5 aggregations', () => {
).toBe(0);
});
});
// No units passed, so `convertUnit` is a no-op and the comparison runs against
// the raw value — exercising `evaluateCondition`'s operator switch directly.
describe('evaluateThresholdWithConvertedValue operators', () => {
it('handles ordering operators', () => {
expect(evaluateThresholdWithConvertedValue(5, 3, '>')).toBe(true);
expect(evaluateThresholdWithConvertedValue(2, 3, '>')).toBe(false);
expect(evaluateThresholdWithConvertedValue(2, 3, '<')).toBe(true);
expect(evaluateThresholdWithConvertedValue(3, 3, '>=')).toBe(true);
expect(evaluateThresholdWithConvertedValue(3, 3, '<=')).toBe(true);
});
it('treats = and == as equality', () => {
expect(evaluateThresholdWithConvertedValue(3, 3, '=')).toBe(true);
expect(evaluateThresholdWithConvertedValue(3, 3, '==')).toBe(true);
expect(evaluateThresholdWithConvertedValue(4, 3, '=')).toBe(false);
});
it('handles != as inequality', () => {
expect(evaluateThresholdWithConvertedValue(4, 3, '!=')).toBe(true);
expect(evaluateThresholdWithConvertedValue(3, 3, '!=')).toBe(false);
});
it('returns false for an unknown operator', () => {
expect(evaluateThresholdWithConvertedValue(3, 3, '~')).toBe(false);
});
});

View File

@@ -29,8 +29,11 @@ function evaluateCondition(
return value >= thresholdValue;
case '<=':
return value <= thresholdValue;
case '=':
case '==':
return value === thresholdValue;
case '!=':
return value !== thresholdValue;
default:
return false;
}

View File

@@ -2,7 +2,7 @@ import { Dispatch, ReactNode, SetStateAction } from 'react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { ColumnUnit } from 'types/api/dashboard/getAll';
export type ThresholdOperators = '>' | '<' | '>=' | '<=' | '=';
export type ThresholdOperators = '>' | '<' | '>=' | '<=' | '=' | '!=';
export type ThresholdProps = {
index: string;

View File

@@ -6,6 +6,8 @@ export const operatorOptions: DefaultOptionType[] = [
{ value: '>=', label: '>=' },
{ value: '<', label: '<' },
{ value: '<=', label: '<=' },
{ value: '=', label: '=' },
{ value: '!=', label: '≠' },
];
export const showAsOptions: DefaultOptionType[] = [

View File

@@ -44,7 +44,6 @@
auto-fill,
minmax(var(--legend-average-width, 240px), 1fr)
);
row-gap: 4px;
column-gap: 12px;
}

View File

@@ -0,0 +1,30 @@
import { useMemo } from 'react';
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type { BuilderQuery } from 'types/api/v5/queryRange';
/**
* Builds a record keyed by builder-query name to that query's groupBy keys
* in the V1 `BaseAutocompleteData` shape — the shape `TimeSeries` and the
* tooltip plugin consume. Conversion from v5 `GroupByKey` lives at this one
* call site that needs the V1 shape; the rest of V2 panel code stays on
* v5 types.
*/
export function useGroupByPerQuery(
builderQueries: BuilderQuery[],
): Record<string, BaseAutocompleteData[]> {
return useMemo(() => {
const result: Record<string, BaseAutocompleteData[]> = {};
builderQueries.forEach((q) => {
if (!q.name) {
return;
}
result[q.name] = (q.groupBy ?? []).map((g) => ({
key: g.name,
dataType: g.fieldDataType as BaseAutocompleteData['dataType'],
type: (g.fieldContext as BaseAutocompleteData['type']) ?? '',
id: '',
}));
});
return result;
}, [builderQueries]);
}

View File

@@ -0,0 +1,48 @@
import { RefObject, useEffect, useRef, useState } from 'react';
const MIN_FONT_PX = 16;
const MAX_FONT_PX = 60;
// The value font is sized to a fraction of the container's smaller dimension so
// it scales with the panel without overflowing.
const FONT_SCALE_DIVISOR = 5;
/**
* Sizes a single large value to its container, recomputing on resize via a
* ResizeObserver. Returns the ref to attach to the container and the current
* font size (px) to apply to the value text.
*/
export function useResponsiveFontSize(): {
containerRef: RefObject<HTMLDivElement>;
fontSize: string;
} {
const containerRef = useRef<HTMLDivElement>(null);
const [fontSize, setFontSize] = useState('2.5vw');
useEffect(() => {
const updateFontSize = (): void => {
if (!containerRef.current) {
return;
}
const { width, height } = containerRef.current.getBoundingClientRect();
const minDimension = Math.min(width, height);
const newSize = Math.max(
Math.min(minDimension / FONT_SCALE_DIVISOR, MAX_FONT_PX),
MIN_FONT_PX,
);
setFontSize(`${newSize}px`);
};
updateFontSize();
const resizeObserver = new ResizeObserver(updateFontSize);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return (): void => {
resizeObserver.disconnect();
};
}, []);
return { containerRef, fontSize };
}

View File

@@ -0,0 +1,36 @@
import { definition as barChart } from './kinds/BarChartPanel/definition';
import { definition as histogram } from './kinds/HistogramPanel/definition';
import { definition as number } from './kinds/NumberPanel/definition';
import { definition as pieChart } from './kinds/PieChartPanel/definition';
import { definition as timeSeries } from './kinds/TimeSeriesPanel/definition';
import type {
PanelRegistry,
RenderablePanelDefinition,
} from './types/panelDefinition';
import type { PanelKind } from './types/panelKind';
// Pure assembly: each kind owns its own PanelDefinition (see
// `kinds/<Kind>/definition.ts`). Registering a new panel = add its folder and a
// single entry below — no other central file needs editing.
export const PANELS: PanelRegistry = {
[timeSeries.kind]: timeSeries,
[barChart.kind]: barChart,
[histogram.kind]: histogram,
[number.kind]: number,
[pieChart.kind]: pieChart,
};
export function getPanelDefinition(
kind: string | undefined,
): RenderablePanelDefinition | undefined {
if (!kind) {
return undefined;
}
// The registry is correlated by kind, so a string lookup yields a union over
// every kind's exactly-typed definition. The renderer cannot be validated
// against that union at the JSX boundary, so widen to the kind-agnostic
// surface here — the single, intentional cast for the whole panel system.
return PANELS[kind as PanelKind] as unknown as
| RenderablePanelDefinition
| undefined;
}

View File

@@ -0,0 +1,175 @@
import { useCallback, useMemo, useRef } from 'react';
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
import {
flattenTimeSeries,
getExecStats,
getTimeSeriesResults,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
import { prepareAlignedData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
import { useTimezone } from 'providers/Timezone';
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import { getTimeRangeFromQueryRangeRequest } from 'utils/getTimeRange';
import { useGroupByPerQuery } from '../../hooks/useGroupByPerQuery';
import PanelStyles from '../../panel.module.scss';
import { PanelRendererProps } from '../../types/rendererProps';
import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../../utils/chartAppearanceMappings';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { buildBarChartConfig } from './buildConfig';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
function BarPanelRenderer({
panelId,
panel,
data,
onClick,
onDragSelect,
dashboardPreference,
panelMode,
}: PanelRendererProps<'signoz/BarChartPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/BarChartPanel'`, so the cast is a
// documented boundary narrowing. Memoized so the `?? {}` fallback doesn't
// produce a fresh object on each render.
const spec = useMemo<DashboardtypesBarChartPanelSpecDTO>(
() => (panel?.spec?.plugin?.spec ?? {}) as DashboardtypesBarChartPanelSpecDTO,
[panel?.spec?.plugin?.spec],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel?.spec?.queries),
[panel?.spec?.queries],
);
// X-scale clamps come from the request that produced the data (falls back
// to the global picker inside the helper). The generated request DTO is
// structurally the hand-written V5 request; the cast is the boundary.
const { minTimeScale, maxTimeScale } = useMemo(() => {
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(
data?.requestPayload as unknown as QueryRangeRequestV5 | undefined,
);
return { minTimeScale: startTime, maxTimeScale: endTime };
}, [data?.requestPayload]);
const groupByPerQuery = useGroupByPerQuery(builderQueries);
const flatSeries = useMemo(
() =>
flattenTimeSeries(
getTimeSeriesResults(data?.response),
data?.legendMap ?? {},
),
[data?.response, data?.legendMap],
);
const config = useMemo(
() =>
buildBarChartConfig({
panelId,
spec,
builderQueries,
series: flatSeries,
stepIntervals: getExecStats(data?.response)?.stepIntervals,
isDarkMode,
timezone,
panelMode,
minTimeScale,
maxTimeScale,
onDragSelect,
}),
[
panelId,
spec,
builderQueries,
flatSeries,
data?.response,
isDarkMode,
timezone,
panelMode,
minTimeScale,
maxTimeScale,
onDragSelect,
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
// Rebuild it on syncMode changes so the new chart instance starts from a
// clean config — otherwise switching to "No Sync" would inherit stale sync
// settings from the previous mode.
dashboardPreference?.syncMode,
],
);
const chartData = useMemo(() => prepareAlignedData(flatSeries), [flatSeries]);
const decimalPrecision = useMemo(
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
[spec.formatting?.decimalPrecision],
);
const legendPosition = useMemo(() => {
return resolveLegendPosition(spec.legend?.position);
}, [spec.legend?.position]);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
<TooltipFooter id={panelId} isPinned={isPinned} dismiss={dismiss} />
),
[panelId],
);
// The uPlot key prop is the only way to force a full teardown and re-mount
// of the chart. Including syncMode/syncFilterMode in the key ensures changes
// to these preferences trigger a fresh chart instance, preventing stale
// sync wiring from being inherited.
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
const handleChartClick = useCallback(
(args: ChartClickData) => {
onClick?.(args);
},
[onClick],
);
return (
<div
ref={graphRef}
data-testid="bar-panel-renderer"
className={PanelStyles.panelContainer}
>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<BarChart
key={key}
config={config}
data={chartData}
legendConfig={{ position: legendPosition }}
groupByPerQuery={groupByPerQuery}
canPinTooltip
timezone={timezone}
yAxisUnit={spec.formatting?.unit}
decimalPrecision={decimalPrecision}
width={containerDimensions.width}
height={containerDimensions.height}
syncMode={dashboardPreference?.syncMode}
syncFilterMode={dashboardPreference?.syncFilterMode}
isStackedBarChart={spec.visualization?.stackedBarChart ?? false}
renderTooltipFooter={renderTooltipFooter}
onClick={handleChartClick}
/>
)}
</div>
);
}
export default BarPanelRenderer;

View File

@@ -0,0 +1,138 @@
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';
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { toClickPluginPayload } from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import type { BuilderQuery } from 'types/api/v5/queryRange';
export interface BuildBarChartConfigArgs {
panelId: string;
spec: DashboardtypesBarChartPanelSpecDTO;
/**
* Flat list of builder queries on this panel (see `getBuilderQueries`).
* Powers per-query legend resolution; empty for non-builder panels.
*/
builderQueries: BuilderQuery[];
/** Flattened V5 series (see `flattenTimeSeries`). */
series: PanelSeries[];
/** Per-query step intervals from the response exec stats. */
stepIntervals?: Record<string, number>;
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
onDragSelect?: (start: number, end: number) => void;
onClick?: OnClickPluginOpts['onClick'];
minTimeScale?: number;
maxTimeScale?: number;
}
/**
* Builds a fully-wired `UPlotConfigBuilder` for a Bar chart panel.
*
* Delegates the panel-agnostic scaffolding (scales, thresholds, axes,
* drag-to-zoom, click plugin) to the shared `buildBaseConfig`, then layers
* in the Bar-specific concerns: optional stacking via uPlot bands, plus
* one bar series per result row.
*/
export function buildBarChartConfig({
panelId,
spec,
builderQueries,
series,
stepIntervals,
isDarkMode,
timezone,
panelMode,
onDragSelect,
onClick,
minTimeScale,
maxTimeScale,
}: BuildBarChartConfigArgs): UPlotConfigBuilder {
const builder = buildBaseConfig({
panelId,
panelType: PANEL_TYPES.BAR,
isDarkMode,
timezone,
panelMode,
isLogScale: spec.axes?.isLogScale,
softMin: spec.axes?.softMin ?? undefined,
softMax: spec.axes?.softMax ?? undefined,
formatting: spec.formatting,
thresholds: spec.thresholds,
stepIntervals,
clickPayload: toClickPluginPayload(series),
minTimeScale,
maxTimeScale,
onDragSelect,
onClick,
});
addSeries({
builder,
spec,
builderQueries,
series,
stepIntervals,
isDarkMode,
});
return builder;
}
interface AddSeriesArgs {
builder: UPlotConfigBuilder;
spec: DashboardtypesBarChartPanelSpecDTO;
builderQueries: BuilderQuery[];
series: PanelSeries[];
stepIntervals?: Record<string, number>;
isDarkMode: boolean;
}
/**
* Adds one bar series per flattened V5 series, plus uPlot bands for stacking
* when `spec.visualization.stackedBarChart` is set. Each series receives its
* own per-query step interval so bar widths line up with the actual
* sampling cadence reported by the backend.
*
* Order must match `prepareAlignedData` — both iterate the same flat list.
*/
function addSeries({
builder,
spec,
builderQueries,
series,
stepIntervals,
isDarkMode,
}: 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);
const stepInterval = s.queryName ? stepIntervals?.[s.queryName] : undefined;
builder.addSeries({
scaleKey: 'y',
drawStyle: DrawStyle.Bar,
label,
colorMapping,
isDarkMode,
stepInterval,
metric: s.labels,
});
});
}

View File

@@ -0,0 +1,13 @@
import { DataSource } from 'types/common/queryBuilder';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
kind: 'signoz/BarChartPanel',
displayName: 'Bar Chart',
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
};

View File

@@ -0,0 +1,9 @@
import type { SectionConfig } from '../../types/sections';
export const sections: SectionConfig[] = [
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'axes', controls: { minMax: true, unit: true, logScale: true } },
{ kind: 'legend', controls: { position: true, mode: true } },
{ kind: 'thresholds', controls: { list: true } },
{ kind: 'chartAppearance', controls: { stacked: true } },
];

View File

@@ -0,0 +1,139 @@
import { useCallback, useMemo, useRef } from 'react';
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import Histogram from 'container/DashboardContainer/visualization/charts/Histogram/Histogram';
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
import {
flattenTimeSeries,
getTimeSeriesResults,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
import { useTimezone } from 'providers/Timezone';
import type uPlot from 'uplot';
import PanelStyles from '../../panel.module.scss';
import { PanelRendererProps } from '../../types/rendererProps';
import { resolveLegendPosition } from '../../utils/chartAppearanceMappings';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { buildHistogramConfig } from './buildConfig';
import { prepareHistogramData } from './prepareData';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
function HistogramPanelRenderer({
panelId,
panel,
data,
panelMode,
onClick,
}: PanelRendererProps<'signoz/HistogramPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/HistogramPanel'`, so the cast is a
// documented boundary narrowing.
const spec = useMemo<DashboardtypesHistogramPanelSpecDTO>(
() =>
(panel?.spec?.plugin?.spec ?? {}) as DashboardtypesHistogramPanelSpecDTO,
[panel?.spec?.plugin?.spec],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel?.spec?.queries),
[panel?.spec?.queries],
);
const flatSeries = useMemo(
() =>
flattenTimeSeries(
getTimeSeriesResults(data?.response),
data?.legendMap ?? {},
),
[data?.response, data?.legendMap],
);
const config = useMemo(
() =>
buildHistogramConfig({
panelId,
spec,
builderQueries,
series: flatSeries,
isDarkMode,
timezone,
panelMode,
}),
[panelId, spec, builderQueries, flatSeries, isDarkMode, timezone, panelMode],
);
const chartData = useMemo(
() =>
prepareHistogramData({
series: flatSeries,
bucketWidth: spec.histogramBuckets?.bucketWidth ?? undefined,
bucketCount: spec.histogramBuckets?.bucketCount ?? undefined,
mergeAllActiveQueries: spec.histogramBuckets?.mergeAllActiveQueries,
}),
[
flatSeries,
spec.histogramBuckets?.bucketWidth,
spec.histogramBuckets?.bucketCount,
spec.histogramBuckets?.mergeAllActiveQueries,
],
);
const legendPosition = useMemo(
() => resolveLegendPosition(spec.legend?.position),
[spec.legend?.position],
);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
<TooltipFooter
id={panelId}
isPinned={isPinned}
dismiss={dismiss}
canDrilldown={false}
/>
),
[panelId],
);
const isQueriesMerged = spec.histogramBuckets?.mergeAllActiveQueries ?? false;
const handleChartClick = useCallback(
(args: ChartClickData) => {
onClick?.(args);
},
[onClick],
);
return (
<div
ref={graphRef}
data-testid="histogram-panel-renderer"
className={PanelStyles.panelContainer}
>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<Histogram
key={panelId}
config={config}
data={chartData as uPlot.AlignedData}
legendConfig={{ position: legendPosition }}
canPinTooltip
isQueriesMerged={isQueriesMerged}
width={containerDimensions.width}
height={containerDimensions.height}
renderTooltipFooter={renderTooltipFooter}
onClick={handleChartClick}
/>
)}
</div>
);
}
export default HistogramPanelRenderer;

View File

@@ -0,0 +1,129 @@
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import getLabelName from 'lib/getLabelName';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import type { BuilderQuery } from 'types/api/v5/queryRange';
const POINT_SIZE = 5;
const BAR_WIDTH_FACTOR = 1;
// Merged-series colors mirror the V1 default — single histogram bin gets a
// fixed blue-ish pair so the merged view looks the same as before.
const MERGED_SERIES_LINE_COLOR = '#3f5ecc';
const MERGED_SERIES_FILL_COLOR = '#4E74F8';
export interface BuildHistogramConfigArgs {
panelId: string;
spec: DashboardtypesHistogramPanelSpecDTO;
/** Builder queries on this panel — used to resolve per-series labels. */
builderQueries: BuilderQuery[];
/** Flattened V5 series (see `flattenTimeSeries`). */
series: PanelSeries[];
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
}
/**
* Builds a fully-wired `UPlotConfigBuilder` for a Histogram panel.
*
* Unlike time-axis panels, histograms have no time scale and no drag-to-zoom.
* We still reuse `buildBaseConfig` for the consistent scaffolding (thresholds,
* axes, click plugin) but then override the X/Y scales to be auto-linear
* (`time: false, auto: true`) and install a histogram-specific cursor that
* disables drag-pan and tightens focus proximity.
*/
export function buildHistogramConfig({
panelId,
spec,
builderQueries,
series,
isDarkMode,
timezone,
panelMode,
}: BuildHistogramConfigArgs): UPlotConfigBuilder {
// Histograms have no time axis — no stepIntervals, and no click plugin
// (the renderer passes no onClick), so the base config needs no response.
const builder = buildBaseConfig({
panelId,
panelType: PANEL_TYPES.HISTOGRAM,
isDarkMode,
timezone,
panelMode,
});
builder.setCursor({
drag: { x: false, y: false, setScale: true },
focus: { prox: 1e3 },
});
// Override the time-axis scales from `buildBaseConfig` — histograms are
// distribution plots, not time series.
builder.addScale({ scaleKey: 'x', time: false, auto: true });
builder.addScale({ scaleKey: 'y', time: false, auto: true, min: 0 });
addSeries({ builder, spec, builderQueries, series, isDarkMode });
return builder;
}
interface AddSeriesArgs {
builder: UPlotConfigBuilder;
spec: DashboardtypesHistogramPanelSpecDTO;
builderQueries: BuilderQuery[];
series: PanelSeries[];
isDarkMode: boolean;
}
/**
* Adds histogram bar series to the builder. When `mergeAllActiveQueries` is
* set, `prepareHistogramData` produces a single Y column, so we add exactly
* one series with the fixed merged-mode colors. Otherwise one series per
* result row, with labels resolved via the standard legend matrix.
*/
function addSeries({
builder,
spec,
builderQueries,
series,
isDarkMode,
}: AddSeriesArgs): void {
const colorMapping = spec.legend?.customColors ?? {};
const mergeAllActiveQueries =
spec.histogramBuckets?.mergeAllActiveQueries ?? false;
if (mergeAllActiveQueries) {
builder.addSeries({
scaleKey: 'y',
label: '',
drawStyle: DrawStyle.Histogram,
colorMapping,
barWidthFactor: BAR_WIDTH_FACTOR,
pointSize: POINT_SIZE,
lineColor: MERGED_SERIES_LINE_COLOR,
fillColor: MERGED_SERIES_FILL_COLOR,
isDarkMode,
});
return;
}
series.forEach((s) => {
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);
builder.addSeries({
scaleKey: 'y',
label,
drawStyle: DrawStyle.Histogram,
colorMapping,
barWidthFactor: BAR_WIDTH_FACTOR,
pointSize: POINT_SIZE,
isDarkMode,
});
});
}

View File

@@ -0,0 +1,13 @@
import { DataSource } from 'types/common/queryBuilder';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
kind: 'signoz/HistogramPanel',
displayName: 'Histogram',
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
};

View File

@@ -0,0 +1,148 @@
import { histogramBucketSizes } from '@grafana/data';
import { DEFAULT_BUCKET_COUNT } from 'container/PanelWrapper/constants';
import {
buildHistogramBuckets,
mergeAlignedDataTables,
prependNullBinToFirstHistogramSeries,
replaceUndefinedWithNullInAlignedData,
} from 'container/DashboardContainer/visualization/panels/utils/histogram';
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { AlignedData } from 'uplot';
import { incrRoundDn, roundDecimals } from 'utils/round';
export interface PrepareHistogramDataArgs {
/** Flattened V5 series (see `flattenTimeSeries`). */
series: PanelSeries[];
bucketWidth?: number;
bucketCount?: number;
mergeAllActiveQueries?: boolean;
}
const BUCKET_OFFSET = 0;
const sortAscending = (a: number, b: number): number => a - b;
/**
* Bins raw series values into a uPlot-aligned histogram. Picks a bucket size
* either from `bucketWidth` (explicit override) or the smallest predefined
* Grafana bucket that fits the data's `range / bucketCount` target while
* staying ≥ the data's smallest non-zero delta (so we never sub-divide below
* the resolution of the input).
*
* Empty input → `[[]]` (a valid empty AlignedData uPlot accepts).
*/
export function prepareHistogramData({
series,
bucketWidth,
bucketCount = DEFAULT_BUCKET_COUNT,
mergeAllActiveQueries = false,
}: PrepareHistogramDataArgs): AlignedData {
const values = extractNumericValues(series);
if (values.length === 0) {
return [[]];
}
const sorted = [...values].sort(sortAscending);
const range = sorted[sorted.length - 1] - sorted[0];
const smallestDelta = computeSmallestDelta(sorted);
let bucketSize = selectBucketSize({
range,
bucketCount,
smallestDelta,
bucketWidthOverride: bucketWidth,
});
if (bucketSize <= 0) {
bucketSize = range > 0 ? range / bucketCount : 1;
}
const getBucket = (v: number): number =>
roundDecimals(incrRoundDn(v - BUCKET_OFFSET, bucketSize) + BUCKET_OFFSET, 9);
const frames = buildFrames(series, mergeAllActiveQueries);
// Merged mode folds every query into frame 0 and leaves trailing empty
// frames — drop those. Per-query mode must keep one column per result row
// (even empty queries), or the data column count drifts below the series
// count `buildHistogramConfig` adds per row → uPlot renders nothing.
const histograms: AlignedData[] = frames
.filter((frame) => !mergeAllActiveQueries || frame.length > 0)
.map((frame) => buildHistogramBuckets(frame, getBucket, sortAscending));
if (histograms.length === 0) {
return [[]];
}
const merged = mergeAlignedDataTables(histograms);
replaceUndefinedWithNullInAlignedData(merged);
prependNullBinToFirstHistogramSeries(merged, bucketSize);
return merged;
}
// Non-finite samples degrade to 0 (legacy `parseFloat(...) || 0` parity).
function toBinnableValue(value: number): number {
return Number.isFinite(value) ? value : 0;
}
function extractNumericValues(series: PanelSeries[]): number[] {
const values: number[] = [];
for (const s of series) {
for (const point of s.values) {
values.push(toBinnableValue(point.value));
}
}
return values;
}
function computeSmallestDelta(sortedValues: number[]): number {
if (sortedValues.length <= 1) {
return 0;
}
let smallest = Infinity;
for (let i = 1; i < sortedValues.length; i++) {
const delta = sortedValues[i] - sortedValues[i - 1];
if (delta > 0) {
smallest = Math.min(smallest, delta);
}
}
return smallest === Infinity ? 0 : smallest;
}
function selectBucketSize({
range,
bucketCount,
smallestDelta,
bucketWidthOverride,
}: {
range: number;
bucketCount: number;
smallestDelta: number;
bucketWidthOverride?: number;
}): number {
if (bucketWidthOverride != null && bucketWidthOverride > 0) {
return bucketWidthOverride;
}
const targetSize = range / bucketCount;
for (const candidate of histogramBucketSizes) {
if (targetSize < candidate && candidate >= smallestDelta) {
return candidate;
}
}
return 0;
}
// When merging is on, fold all frames into the first; the trailing empty
// frames stay in the array so downstream `.filter(length > 0)` drops them.
function buildFrames(
series: PanelSeries[],
mergeAllActiveQueries: boolean,
): number[][] {
const frames: number[][] = series.map((s) =>
s.values.map((point) => toBinnableValue(point.value)),
);
if (mergeAllActiveQueries && frames.length > 1) {
const first = frames[0];
for (let i = 1; i < frames.length; i++) {
first.push(...frames[i]);
frames[i] = [];
}
}
return frames;
}

View File

@@ -0,0 +1,6 @@
import type { SectionConfig } from '../../types/sections';
export const sections: SectionConfig[] = [
{ kind: 'legend', controls: { position: true, mode: true } },
{ kind: 'buckets', controls: { count: true } },
];

View File

@@ -0,0 +1,81 @@
import { useMemo } from 'react';
import { Typography } from '@signozhq/ui/typography';
import type { DashboardtypesNumberPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
import { getScalarResults } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
import PanelStyles from '../../panel.module.scss';
import { PanelRendererProps } from '../../types/rendererProps';
import { formatPanelValue } from '../../utils/formatPanelValue';
import { resolveDecimalPrecision } from '../../utils/chartAppearanceMappings';
import { prepareNumberData } from './prepareData';
import { mapNumberThresholds } from './utils';
import ValueDisplay from './components/ValueDisplay/ValueDisplay';
function NumberPanelRenderer({
panel,
data,
}: PanelRendererProps<'signoz/NumberPanel'>): JSX.Element {
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/NumberPanel'`, so the cast is a
// documented boundary narrowing. Memoized so the `?? {}` fallback doesn't
// produce a fresh object on each render.
const spec = useMemo<DashboardtypesNumberPanelSpecDTO>(
() => (panel?.spec?.plugin?.spec ?? {}) as DashboardtypesNumberPanelSpecDTO,
[panel?.spec?.plugin?.spec],
);
const value = useMemo(
() =>
prepareNumberData(
prepareScalarTables({
results: getScalarResults(data?.response),
legendMap: data?.legendMap ?? {},
requestPayload: data?.requestPayload,
}),
),
[data?.response, data?.legendMap, data?.requestPayload],
);
const thresholds = useMemo(
() => mapNumberThresholds(spec.thresholds),
[spec.thresholds],
);
const decimalPrecision = useMemo(
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
[spec.formatting?.decimalPrecision],
);
const unit = spec.formatting?.unit;
// Precision is applied regardless of whether a unit is set (see
// `formatPanelValue`), so decimal-precision changes always take effect.
const formattedValue = useMemo(
() => (value === null ? '' : formatPanelValue(value, unit, decimalPrecision)),
[value, unit, decimalPrecision],
);
return (
<div
data-testid="number-panel-renderer"
className={PanelStyles.panelContainer}
>
{value === null ? (
<Typography.Text data-testid="number-panel-no-data">
No Data
</Typography.Text>
) : (
<ValueDisplay
value={formattedValue}
rawValue={value}
thresholds={thresholds}
unit={unit}
/>
)}
</div>
);
}
export default NumberPanelRenderer;

View File

@@ -0,0 +1,155 @@
import {
DashboardtypesComparisonOperatorDTO,
type DashboardtypesNumberPanelSpecDTO,
type DashboardtypesPanelDTO,
DashboardtypesThresholdFormatDTO,
type QueryRangeV5200,
} from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { render } from 'tests/test-utils';
import { BaseRendererProps } from '../../../types/rendererProps';
import BaseNumberPanelRenderer from '../Renderer';
// The kind's interaction map is `Record<string, never>`, which makes the strict
// `PanelRendererProps<'signoz/NumberPanel'>` intersection impossible to satisfy
// with a literal. NumberPanel reads no interaction props, so render it against
// the base prop surface.
const NumberPanelRenderer =
BaseNumberPanelRenderer as React.FC<BaseRendererProps>;
// ValueDisplay observes its container to size the font.
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
function panelWith(
spec: DashboardtypesNumberPanelSpecDTO,
): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: { plugin: { kind: 'signoz/NumberPanel', spec } },
} as unknown as DashboardtypesPanelDTO;
}
// V5 scalar response: one table per query, value in the aggregation column.
function dataWith(value: string | number): PanelQueryData {
return {
response: {
status: 'success',
data: {
type: 'scalar',
data: {
results: [
{
queryName: 'A',
columns: [
{
name: '__result',
queryName: 'A',
columnType: 'aggregation',
aggregationIndex: 0,
},
],
data: [[value]],
},
],
},
},
} as unknown as QueryRangeV5200,
requestPayload: undefined,
legendMap: {},
};
}
const emptyData: PanelQueryData = {
response: {
status: 'success',
data: { type: 'scalar', data: { results: [] } },
} as unknown as QueryRangeV5200,
requestPayload: undefined,
legendMap: {},
};
// NumberPanel adds no interaction props (its interaction map is
// `Record<string, never>`), so the base renderer props fully describe it.
function renderPanel(
props: Partial<BaseRendererProps>,
): ReturnType<typeof render> {
const baseProps: BaseRendererProps = {
panelId: 'panel-1',
panel: panelWith({}),
data: undefined,
isLoading: false,
error: null,
panelMode: PanelMode.DASHBOARD_VIEW,
...props,
};
return render(<NumberPanelRenderer {...baseProps} />);
}
describe('NumberPanelRenderer', () => {
it('renders the value with its y-axis unit', () => {
const { getByText } = renderPanel({
panel: panelWith({ formatting: { unit: 'ms' } }),
data: dataWith('295.4299833508185'),
});
expect(getByText('295.43')).toBeInTheDocument();
expect(getByText('ms')).toBeInTheDocument();
});
// Regression: with no unit configured, decimal precision must still apply.
// Previously the renderer fell back to `value.toString()` whenever the unit
// was empty, so precision changes had no effect on unitless panels.
it('applies decimal precision even when no unit is set', () => {
const { getByText, queryByText } = renderPanel({
panel: panelWith({}),
data: dataWith('3.14159'),
});
expect(getByText('3.14')).toBeInTheDocument();
expect(queryByText('3.14159')).not.toBeInTheDocument();
});
it('renders No Data when the response has no scalar results', () => {
const { getByTestId } = renderPanel({ data: emptyData });
expect(getByTestId('number-panel-no-data')).toBeInTheDocument();
});
it('renders No Data when the response is absent', () => {
const { getByTestId } = renderPanel({ data: undefined });
expect(getByTestId('number-panel-no-data')).toBeInTheDocument();
});
it('surfaces the conflicting-thresholds indicator when a value matches multiple thresholds', () => {
const { getByTestId } = renderPanel({
panel: panelWith({
thresholds: [
{
color: '#f00',
operator: DashboardtypesComparisonOperatorDTO.above,
value: 0,
format: DashboardtypesThresholdFormatDTO.background,
},
{
color: '#0f0',
operator: DashboardtypesComparisonOperatorDTO.above,
value: 100,
format: DashboardtypesThresholdFormatDTO.background,
},
],
}),
data: dataWith('295.4299833508185'),
});
expect(getByTestId('conflicting-thresholds')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,62 @@
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { prepareNumberData } from '../prepareData';
function tableWith(
columns: PanelTable['columns'],
rows: PanelTable['rows'],
): PanelTable {
return { queryName: 'A', legend: '', columns, rows };
}
describe('prepareNumberData', () => {
it('returns null for no tables', () => {
expect(prepareNumberData([])).toBeNull();
});
it('reads the first row of the value column', () => {
const table = tableWith(
[
{ name: 'group', queryName: 'A', isValueColumn: false, id: 'group' },
{ name: 'value', queryName: 'A', isValueColumn: true, id: 'val' },
],
[
{ data: { group: 'prod', val: '295.4299833508185' } },
{ data: { group: 'dev', val: '7' } },
],
);
expect(prepareNumberData([table])).toBeCloseTo(295.43, 2);
});
it('falls back to the row first value when no column is tagged isValueColumn', () => {
const table = tableWith(
[{ name: 'value', queryName: 'A', isValueColumn: false, id: 'value' }],
[{ data: { value: '7' } }],
);
expect(prepareNumberData([table])).toBe(7);
});
it('skips empty tables and reads the first one with rows', () => {
const empty = tableWith(
[{ name: 'value', queryName: 'A', isValueColumn: true, id: 'A' }],
[],
);
const filled = tableWith(
[{ name: 'value', queryName: 'B', isValueColumn: true, id: 'B' }],
[{ data: { B: 42 } }],
);
expect(prepareNumberData([empty, filled])).toBe(42);
});
it('returns null when the value is non-numeric', () => {
const table = tableWith(
[{ name: 'value', queryName: 'A', isValueColumn: true, id: 'A' }],
[{ data: { A: 'n/a' } }],
);
expect(prepareNumberData([table])).toBeNull();
});
});

View File

@@ -0,0 +1,99 @@
import {
DashboardtypesComparisonOperatorDTO,
DashboardtypesComparisonThresholdDTO,
DashboardtypesThresholdFormatDTO,
} from 'api/generated/services/sigNoz.schemas';
import { mapNumberThresholds } from '../utils';
describe('mapNumberThresholds', () => {
it('returns [] for null / undefined / empty', () => {
expect(mapNumberThresholds(null)).toStrictEqual([]);
expect(mapNumberThresholds(undefined)).toStrictEqual([]);
expect(mapNumberThresholds([])).toStrictEqual([]);
});
it('maps comparison operators to symbol operators', () => {
const thresholds: DashboardtypesComparisonThresholdDTO[] = [
{
color: '#f00',
operator: DashboardtypesComparisonOperatorDTO.above,
value: 1,
},
{
color: '#0f0',
operator: DashboardtypesComparisonOperatorDTO.below,
value: 2,
},
{
color: '#00f',
operator: DashboardtypesComparisonOperatorDTO.above_or_equal,
value: 3,
},
{
color: '#ff0',
operator: DashboardtypesComparisonOperatorDTO.below_or_equal,
value: 4,
},
{
color: '#0ff',
operator: DashboardtypesComparisonOperatorDTO.equal,
value: 5,
},
];
const mapped = mapNumberThresholds(thresholds);
expect(mapped.map((t) => t.operator)).toStrictEqual([
'>',
'<',
'>=',
'<=',
'=',
]);
});
it('maps not_equal to !=', () => {
const mapped = mapNumberThresholds([
{
color: '#f00',
operator: DashboardtypesComparisonOperatorDTO.not_equal,
value: 1,
},
]);
expect(mapped[0].operator).toBe('!=');
});
it('maps format and carries value/unit/color', () => {
const mapped = mapNumberThresholds([
{
color: '#abcdef',
operator: DashboardtypesComparisonOperatorDTO.above,
value: 100,
unit: 'ms',
format: DashboardtypesThresholdFormatDTO.background,
},
]);
expect(mapped[0]).toStrictEqual({
color: '#abcdef',
operator: '>',
value: 100,
unit: 'ms',
format: 'background',
});
});
it('maps text format to text', () => {
const mapped = mapNumberThresholds([
{
color: '#000',
value: 1,
format: DashboardtypesThresholdFormatDTO.text,
},
]);
expect(mapped[0].format).toBe('text');
});
});

View File

@@ -0,0 +1,36 @@
.container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
position: relative;
}
.textContainer {
display: flex;
flex-direction: row;
align-items: baseline;
justify-content: center;
flex-wrap: nowrap;
}
.valueText {
text-align: center;
font-weight: 400;
}
.conflictBackground {
position: absolute;
right: 10px;
bottom: 10px;
}
.conflictText {
margin-left: 10px;
margin-top: 20px;
}
.conflictIcon {
color: var(--warning-background);
}

View File

@@ -0,0 +1,102 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Tooltip } from 'antd';
import { CircleAlert } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import type { PanelThreshold } from '../../../../types/threshold';
import { resolveActiveThreshold } from '../../../../utils/evaluateThresholds';
import { parseFormattedValue } from '../../../../utils/parseFormattedValue';
import styles from './ValueDisplay.module.scss';
import { useResponsiveFontSize } from '../../../../hooks/useResponsiveFontSize';
import ValueUnit from '../ValueUnit/ValueUnit';
interface ValueDisplayProps {
/** The pre-formatted value string (may include a unit label). */
value: string;
/** The raw numeric value, used for threshold evaluation. */
rawValue: number;
thresholds: PanelThreshold[];
/** The panel's unit, used to convert threshold units before comparison. */
unit?: string;
}
/**
* Renders a single large scalar with optional prefix/suffix units and threshold
* recoloring (text or background). A V2-native replacement for the V1
* `ValueGraph` — depends only on V2 threshold utilities and the shared icon/
* typography primitives.
*/
function ValueDisplay({
value,
rawValue,
thresholds,
unit,
}: ValueDisplayProps): JSX.Element {
const { t } = useTranslation(['valueGraph']);
const { containerRef, fontSize } = useResponsiveFontSize();
const { numericValue, prefixUnit, suffixUnit } = useMemo(
() => parseFormattedValue(value),
[value],
);
const { threshold, isConflicting } = useMemo(
() => resolveActiveThreshold(thresholds, rawValue, unit),
[thresholds, rawValue, unit],
);
const isBackground = threshold?.format === 'background';
const textColor = threshold?.format === 'text' ? threshold.color : undefined;
const backgroundColor = isBackground ? threshold?.color : undefined;
return (
<div
ref={containerRef}
className={styles.container}
style={{ backgroundColor }}
>
<div className={styles.textContainer}>
{prefixUnit && (
<ValueUnit
type="prefix"
unit={prefixUnit}
color={textColor}
fontSize={fontSize}
/>
)}
<Typography.Text
className={styles.valueText}
data-testid="number-panel-value"
style={{ color: textColor, fontSize }}
>
{numericValue}
</Typography.Text>
{suffixUnit && (
<ValueUnit
type="suffix"
unit={suffixUnit}
color={textColor}
fontSize={fontSize}
/>
)}
</div>
{isConflicting && (
<div
className={isBackground ? styles.conflictBackground : styles.conflictText}
>
<Tooltip title={t('this_value_satisfies_multiple_thresholds')}>
<CircleAlert
className={styles.conflictIcon}
data-testid="conflicting-thresholds"
size="md"
/>
</Tooltip>
</div>
)}
</div>
);
}
export default ValueDisplay;

View File

@@ -0,0 +1,5 @@
.unit {
margin-left: 4px;
font-weight: 300;
opacity: 0.8;
}

View File

@@ -0,0 +1,31 @@
import { Typography } from '@signozhq/ui/typography';
import styles from './ValueUnit.module.scss';
interface ValueUnitProps {
type: 'prefix' | 'suffix';
unit: string;
/** Text color, set only when a "text" threshold is active. */
color?: string;
fontSize: string;
}
/** A prefix/suffix unit label rendered alongside the numeric value. */
function ValueUnit({
type,
unit,
color,
fontSize,
}: ValueUnitProps): JSX.Element {
return (
<Typography.Text
className={styles.unit}
data-testid={`value-display-${type}-unit`}
style={{ color, fontSize: `calc(${fontSize} * 0.7)` }}
>
{unit}
</Typography.Text>
);
}
export default ValueUnit;

View File

@@ -0,0 +1,13 @@
import { DataSource } from 'types/common/queryBuilder';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
kind: 'signoz/NumberPanel',
displayName: 'Number',
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
};

View File

@@ -0,0 +1,33 @@
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
/**
* Reduces the scalar tables of a V5 response to the single number a
* NumberPanel renders.
*
* V2 always issues `requestType: 'scalar'` for VALUE panels, so the response
* is a scalar table per query (see `prepareScalarTables`). The value is the
* first row's `isValueColumn` cell of the first table that has rows —
* falling back to the row's first cell when no column is marked as the
* value (mirrors the V1 `formatForWeb` fallback read).
*
* Returns `null` when there is no numeric value to show, which the renderer
* maps to the "No Data" state.
*/
export function prepareNumberData(tables: PanelTable[]): number | null {
for (const table of tables) {
if (table.rows.length === 0) {
continue;
}
const row = table.rows[0].data;
const valueColumn = table.columns.find((column) => column.isValueColumn);
const raw = valueColumn
? row[valueColumn.id || valueColumn.name]
: Object.values(row)[0];
const value = Number(raw);
if (Number.isFinite(value)) {
return value;
}
}
return null;
}

View File

@@ -0,0 +1,8 @@
import type { SectionConfig } from '../../types/sections';
// A number panel renders one scalar — no axes, legend, or stacking. Just value
// formatting and thresholds that recolor the value/background.
export const sections: SectionConfig[] = [
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'thresholds', controls: { list: true } },
];

View File

@@ -0,0 +1,54 @@
import {
DashboardtypesComparisonOperatorDTO,
DashboardtypesComparisonThresholdDTO,
DashboardtypesThresholdFormatDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
PanelThreshold,
ThresholdComparisonOperator,
ThresholdDisplayFormat,
} from '../../types/threshold';
// Perses comparison operators → the symbol operators V2 threshold evaluation
// uses.
const OPERATOR_MAP: Record<
DashboardtypesComparisonOperatorDTO,
ThresholdComparisonOperator
> = {
[DashboardtypesComparisonOperatorDTO.above]: '>',
[DashboardtypesComparisonOperatorDTO.below]: '<',
[DashboardtypesComparisonOperatorDTO.above_or_equal]: '>=',
[DashboardtypesComparisonOperatorDTO.below_or_equal]: '<=',
[DashboardtypesComparisonOperatorDTO.equal]: '=',
[DashboardtypesComparisonOperatorDTO.not_equal]: '!=',
};
const FORMAT_MAP: Record<
DashboardtypesThresholdFormatDTO,
ThresholdDisplayFormat
> = {
[DashboardtypesThresholdFormatDTO.text]: 'text',
[DashboardtypesThresholdFormatDTO.background]: 'background',
};
/**
* Maps the panel-spec threshold shape (`ComparisonThresholdDTO`) onto the
* V2-native `PanelThreshold` consumed by `ValueDisplay` / threshold
* evaluation. No dependency on the V1 `ThresholdProps` shape.
*/
export function mapNumberThresholds(
thresholds: DashboardtypesComparisonThresholdDTO[] | null | undefined,
): PanelThreshold[] {
if (!thresholds || thresholds.length === 0) {
return [];
}
return thresholds.map((threshold) => ({
color: threshold.color,
operator: threshold.operator ? OPERATOR_MAP[threshold.operator] : undefined,
value: threshold.value,
unit: threshold.unit,
format: threshold.format ? FORMAT_MAP[threshold.format] : undefined,
}));
}

View File

@@ -0,0 +1,88 @@
import { useCallback, useMemo } from 'react';
import type { DashboardtypesPieChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import Pie from 'container/DashboardContainer/visualization/charts/Pie/Pie';
import type { PieSlice } from 'container/DashboardContainer/visualization/charts/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
import { getScalarResults } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
import PanelStyles from '../../panel.module.scss';
import { PanelRendererProps } from '../../types/rendererProps';
import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../../utils/chartAppearanceMappings';
import { preparePieData } from './prepareData';
function PiePanelRenderer({
panelId,
panel,
data,
onClick,
}: PanelRendererProps<'signoz/PieChartPanel'>): JSX.Element {
const isDarkMode = useIsDarkMode();
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/PieChartPanel'`, so the cast is a
// documented boundary narrowing. Memoized so the `?? {}` fallback doesn't
// produce a fresh object on each render.
const spec = useMemo<DashboardtypesPieChartPanelSpecDTO>(
() => (panel?.spec?.plugin?.spec ?? {}) as DashboardtypesPieChartPanelSpecDTO,
[panel?.spec?.plugin?.spec],
);
const slices = useMemo(
() =>
preparePieData({
tables: prepareScalarTables({
results: getScalarResults(data?.response),
legendMap: data?.legendMap ?? {},
requestPayload: data?.requestPayload,
}),
customColors: spec.legend?.customColors,
isDarkMode,
}),
[
data?.response,
data?.legendMap,
data?.requestPayload,
spec.legend?.customColors,
isDarkMode,
],
);
const decimalPrecision = useMemo(
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
[spec.formatting?.decimalPrecision],
);
const legendPosition = useMemo(
() => resolveLegendPosition(spec.legend?.position),
[spec.legend?.position],
);
const handleSliceClick = useCallback(
(slice: PieSlice) => {
onClick?.({ label: slice.label, value: slice.value });
},
[onClick],
);
return (
<div data-testid="pie-panel-renderer" className={PanelStyles.panelContainer}>
<Pie
data={slices}
yAxisUnit={spec.formatting?.unit}
decimalPrecision={decimalPrecision}
isDarkMode={isDarkMode}
position={legendPosition}
id={panelId}
onSliceClick={handleSliceClick}
data-testid="pie-chart"
/>
</div>
);
}
export default PiePanelRenderer;

View File

@@ -0,0 +1,13 @@
import { DataSource } from 'types/common/queryBuilder';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
kind: 'signoz/PieChartPanel',
displayName: 'Pie Chart',
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
};

View File

@@ -0,0 +1,58 @@
import { themeColors } from 'constants/theme';
import type { PieSlice } from 'container/DashboardContainer/visualization/charts/types';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
export interface PreparePieDataArgs {
/** Scalar tables from the V5 response (see `prepareScalarTables`). */
tables: PanelTable[];
/** Per-label colour overrides from `spec.legend.customColors`. */
customColors?: Record<string, string> | null;
isDarkMode: boolean;
}
/**
* Turns the scalar tables of a V5 response into pie slices: one slice per
* group row. The aggregation column holds the value, the group column(s)
* form the label. Colours honour `customColors` then fall back to a
* deterministic palette colour; non-positive / non-numeric values are
* dropped.
*/
export function preparePieData({
tables,
customColors,
isDarkMode,
}: PreparePieDataArgs): PieSlice[] {
const colorMap = isDarkMode
? themeColors.chartcolors
: themeColors.lightModeColor;
const slices: PieSlice[] = [];
tables.forEach((table) => {
const valueColumn = table.columns.find((column) => column.isValueColumn);
if (!valueColumn) {
return;
}
const valueKey = valueColumn.id || valueColumn.name;
const labelColumns = table.columns.filter((column) => !column.isValueColumn);
table.rows.forEach((row) => {
const value = Number(row.data[valueKey]);
const label =
labelColumns
.map((column) => row.data[column.id || column.name])
.filter((part) => part != null)
.map(String)
.join(', ') ||
table.legend ||
table.queryName ||
'';
const color = customColors?.[label] ?? generateColor(label, colorMap);
slices.push({ label, value, color });
});
});
return slices.filter(
(slice) => Number.isFinite(slice.value) && slice.value > 0,
);
}

View File

@@ -0,0 +1,8 @@
import type { SectionConfig } from '../../types/sections';
// Pie has no axes, thresholds, or stacking — just value formatting and a
// legend. `mode` is omitted: the pie legend is always interactive swatches.
export const sections: SectionConfig[] = [
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'legend', controls: { position: true } },
];

View File

@@ -0,0 +1,180 @@
import { useCallback, useMemo, useRef } from 'react';
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
import {
flattenTimeSeries,
getExecStats,
getTimeSeriesResults,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
import { prepareAlignedData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
import { useTimezone } from 'providers/Timezone';
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import { getTimeRangeFromQueryRangeRequest } from 'utils/getTimeRange';
import { useGroupByPerQuery } from '../../hooks/useGroupByPerQuery';
import PanelStyles from '../../panel.module.scss';
import { PanelRendererProps } from '../../types/rendererProps';
import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../../utils/chartAppearanceMappings';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { buildTimeSeriesConfig } from './buildConfig';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
function TimeSeriesPanelRenderer({
panelId,
panel,
data,
onClick,
onDragSelect,
dashboardPreference,
panelMode,
}: PanelRendererProps<'signoz/TimeSeriesPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/TimeSeriesPanel'`, so the cast is a
// documented boundary narrowing — not a blind assertion. Memoized so the
// `?? {}` fallback doesn't produce a fresh object on each render.
const spec = useMemo<DashboardtypesTimeSeriesPanelSpecDTO>(
() =>
(panel?.spec?.plugin?.spec ?? {}) as DashboardtypesTimeSeriesPanelSpecDTO,
[panel?.spec?.plugin?.spec],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel?.spec?.queries),
[panel?.spec?.queries],
);
// X-scale clamps come from the request that produced the data, so each
// panel pins to the window it actually fetched — important during
// drag-zoom transitions when the time picker has moved but new data
// hasn't arrived yet. Falls back to the global picker inside the helper.
// The generated request DTO is structurally the hand-written V5 request;
// the cast is the documented boundary.
const { minTimeScale, maxTimeScale } = useMemo(() => {
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(
data?.requestPayload as unknown as QueryRangeRequestV5 | undefined,
);
return { minTimeScale: startTime, maxTimeScale: endTime };
}, [data?.requestPayload]);
const groupByPerQuery = useGroupByPerQuery(builderQueries);
const flatSeries = useMemo(
() =>
flattenTimeSeries(
getTimeSeriesResults(data?.response),
data?.legendMap ?? {},
),
[data?.response, data?.legendMap],
);
const config = useMemo(
() =>
buildTimeSeriesConfig({
panelId,
spec,
builderQueries,
series: flatSeries,
stepIntervals: getExecStats(data?.response)?.stepIntervals,
isDarkMode,
timezone,
panelMode,
minTimeScale,
maxTimeScale,
onDragSelect,
}),
[
panelId,
spec,
builderQueries,
flatSeries,
data?.response,
isDarkMode,
timezone,
panelMode,
minTimeScale,
maxTimeScale,
onDragSelect,
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
// Rebuild it on syncMode changes so the new chart instance starts from a
// clean config — otherwise switching to "No Sync" would inherit stale sync
// settings from the previous mode.
dashboardPreference?.syncMode,
],
);
const chartData = useMemo(() => prepareAlignedData(flatSeries), [flatSeries]);
const decimalPrecision = useMemo(
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
[spec.formatting?.decimalPrecision],
);
const legendPosition = useMemo(() => {
return resolveLegendPosition(spec.legend?.position);
}, [spec.legend?.position]);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
<TooltipFooter id={panelId} isPinned={isPinned} dismiss={dismiss} />
),
[panelId],
);
/**
* The uPlot key prop is the only way to force a full teardown and re-mount
* of the chart. By including the syncMode and syncFilterMode in the key,
* we ensure that changes to these preferences trigger a fresh chart instance,
* preventing stale sync settings from being inherited.
*/
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
const handleChartClick = useCallback(
(args: ChartClickData) => {
onClick?.(args);
},
[onClick],
);
return (
<div
ref={graphRef}
data-testid="time-series-renderer"
className={PanelStyles.panelContainer}
>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<TimeSeries
key={key}
config={config}
data={chartData}
legendConfig={{ position: legendPosition }}
groupByPerQuery={groupByPerQuery}
canPinTooltip
timezone={timezone}
yAxisUnit={spec.formatting?.unit}
decimalPrecision={decimalPrecision}
width={containerDimensions.width}
height={containerDimensions.height}
syncMode={dashboardPreference?.syncMode}
syncFilterMode={dashboardPreference?.syncFilterMode}
renderTooltipFooter={renderTooltipFooter}
onClick={handleChartClick}
/>
)}
</div>
);
}
export default TimeSeriesPanelRenderer;

View File

@@ -0,0 +1,159 @@
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
import {
FILL_MODE_MAP,
LINE_INTERPOLATION_MAP,
LINE_STYLE_MAP,
resolveSpanGaps,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/chartAppearanceMappings';
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import {
hasSingleVisiblePoint,
toClickPluginPayload,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import {
DrawStyle,
FillMode,
LineInterpolation,
LineStyle,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import type { BuilderQuery } from 'types/api/v5/queryRange';
const DEFAULT_POINT_SIZE = 5;
export interface BuildTimeSeriesConfigArgs {
panelId: string;
spec: DashboardtypesTimeSeriesPanelSpecDTO;
/**
* Flat list of builder queries on this panel (see `getBuilderQueries`).
* Powers per-query legend resolution; empty for non-builder panels.
*/
builderQueries: BuilderQuery[];
/** Flattened V5 series (see `flattenTimeSeries`). */
series: PanelSeries[];
/** Per-query step intervals from the response exec stats. */
stepIntervals?: Record<string, number>;
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
onDragSelect?: (start: number, end: number) => void;
onClick?: OnClickPluginOpts['onClick'];
minTimeScale?: number;
maxTimeScale?: number;
}
/**
* Builds a fully-wired `UPlotConfigBuilder` for a TimeSeries panel.
*
* Delegates the panel-agnostic scaffolding (scales, thresholds, axes,
* drag-to-zoom, click plugin) to the shared `buildBaseConfig`, then layers
* in the TimeSeries-specific concern: one series per result, with visuals
* resolved from `spec.chartAppearance`.
*/
export function buildTimeSeriesConfig({
panelId,
spec,
builderQueries,
series,
stepIntervals,
isDarkMode,
timezone,
panelMode,
onDragSelect,
onClick,
minTimeScale,
maxTimeScale,
}: BuildTimeSeriesConfigArgs): UPlotConfigBuilder {
const builder = buildBaseConfig({
panelId,
panelType: PANEL_TYPES.TIME_SERIES,
isDarkMode,
timezone,
panelMode,
isLogScale: spec.axes?.isLogScale,
softMin: spec.axes?.softMin ?? undefined,
softMax: spec.axes?.softMax ?? undefined,
formatting: spec.formatting,
thresholds: spec.thresholds,
stepIntervals,
clickPayload: toClickPluginPayload(series),
minTimeScale,
maxTimeScale,
onDragSelect,
onClick,
});
addSeries({ builder, spec, builderQueries, series, isDarkMode });
return builder;
}
interface AddSeriesArgs {
builder: UPlotConfigBuilder;
spec: DashboardtypesTimeSeriesPanelSpecDTO;
builderQueries: BuilderQuery[];
series: PanelSeries[];
isDarkMode: boolean;
}
/**
* Adds one uPlot series per flattened V5 series to the scaffolded builder.
* The visual resolution (line style, interpolation, fill mode, span gaps)
* reads from `spec.chartAppearance`; the label is resolved via the legend
* matrix in `resolveSeriesLabelV5`. Mutates the builder in place.
*
* Order must match `prepareAlignedData` — both iterate the same flat list.
*/
function addSeries({
builder,
spec,
builderQueries,
series,
isDarkMode,
}: AddSeriesArgs): void {
const chartAppearance = spec.chartAppearance;
// `customColors` is nullable on the spec; coerce so `addSeries` always gets
// a defined record (it dereferences keys without a guard).
const colorMapping = spec.legend?.customColors ?? {};
const spanGaps = resolveSpanGaps(chartAppearance?.spanGaps?.fillLessThan);
const lineStyle = chartAppearance?.lineStyle
? LINE_STYLE_MAP[chartAppearance.lineStyle]
: LineStyle.Solid;
const lineInterpolation = chartAppearance?.lineInterpolation
? LINE_INTERPOLATION_MAP[chartAppearance.lineInterpolation]
: LineInterpolation.Spline;
const fillMode = chartAppearance?.fillMode
? FILL_MODE_MAP[chartAppearance.fillMode]
: FillMode.None;
series.forEach((s) => {
const hasSingleValidPoint = hasSingleVisiblePoint(s.values);
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);
builder.addSeries({
scaleKey: 'y',
// A single visible point can't be drawn as a line — degrade to points
// so the user still sees the datum (matches V1 behavior).
drawStyle: hasSingleValidPoint ? DrawStyle.Points : DrawStyle.Line,
label,
colorMapping,
spanGaps,
lineStyle,
lineInterpolation,
showPoints: chartAppearance?.showPoints || hasSingleValidPoint,
pointSize: DEFAULT_POINT_SIZE,
fillMode,
isDarkMode,
metric: s.labels,
});
});
}

View File

@@ -0,0 +1,13 @@
import { DataSource } from 'types/common/queryBuilder';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
kind: 'signoz/TimeSeriesPanel',
displayName: 'Time Series',
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
};

View File

@@ -0,0 +1,15 @@
import type { SectionConfig } from '../../types/sections';
export const sections: SectionConfig[] = [
{
kind: 'formatting',
controls: {
unit: true,
decimals: true,
},
},
{ kind: 'axes', controls: { minMax: true, unit: true, logScale: true } },
{ kind: 'legend', controls: { position: true, mode: true } },
{ kind: 'thresholds', controls: { list: true } },
{ kind: 'chartAppearance', controls: { lineStyle: true, fillOpacity: true } },
];

View File

@@ -0,0 +1,9 @@
.panelContainer {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
height: 100%;
position: relative;
}

View File

@@ -0,0 +1,59 @@
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
/**
* Source-tagged click events. The three uPlot panels share `ChartClickEvent`;
* each non-chart kind carries the context its drill-down needs. The `source`
* tag lets a kind-agnostic consumer (the render boundary, a shared drill-down
* handler) discriminate without assuming a chart shape.
*/
export type ChartClickEvent = ChartClickData;
export type TableClickEvent = {
rowData: Record<string, unknown>;
columnId?: string;
};
export type ListClickEvent = {
rowData: Record<string, unknown>;
};
export type PieClickEvent = { label: string; value: number };
/** Union of every panel click event — switched on by `source` at the boundary. */
export type PanelClickEvent =
| ChartClickEvent
| TableClickEvent
| ListClickEvent
| PieClickEvent;
type DragSelect = (start: number, end: number) => void;
/**
* Per-kind interaction props. Each panel kind exposes ONLY the gestures it
* supports: chart panels get a chart-shaped `onClick`, time-axis charts add
* `onDragSelect`, histograms have no drag-to-zoom, a NumberPanel has no
* interactions at all. Keys mirror `PanelKind`; `PanelRendererProps<K>` in
* rendererProps.ts indexes this map, so a missing kind is a compile error there.
*/
export interface PanelInteractionMap {
'signoz/TimeSeriesPanel': {
onClick?: (event: ChartClickEvent) => void;
onDragSelect?: DragSelect;
};
'signoz/BarChartPanel': {
onClick?: (event: ChartClickEvent) => void;
onDragSelect?: DragSelect;
};
'signoz/HistogramPanel': { onClick?: (event: ChartClickEvent) => void };
'signoz/TablePanel': { onClick?: (event: TableClickEvent) => void };
'signoz/ListPanel': { onClick?: (event: ListClickEvent) => void };
'signoz/PieChartPanel': { onClick?: (event: PieClickEvent) => void };
'signoz/NumberPanel': Record<string, never>;
}
/**
* Widest interaction surface — used where the panel kind is not known
* statically (the registry render boundary; see `getPanelDefinition`). It is
* the structural supertype the per-kind shapes are cast to exactly once.
*/
export interface AnyPanelInteractionProps {
onClick?: (event: PanelClickEvent) => void;
onDragSelect?: DragSelect;
}

View File

@@ -0,0 +1,31 @@
import type { ComponentType } from 'react';
import { DataSource } from 'types/common/queryBuilder';
import type { SectionConfig } from './sections';
import type { AnyPanelInteractionProps } from './interactions';
import type { PanelKind } from './panelKind';
import type { BaseRendererProps, PanelRendererProps } from './rendererProps';
export interface PanelDefinition<K extends PanelKind = PanelKind> {
kind: K;
displayName: string;
Renderer: ComponentType<PanelRendererProps<K>>;
sections: SectionConfig[];
supportedSignals: DataSource[];
}
// Keyed registry that preserves the kind ↔ definition correlation: indexing
// with a literal kind yields that kind's exactly-typed PanelDefinition.
export type PanelRegistry = { [K in PanelKind]?: PanelDefinition<K> };
// A PanelDefinition whose Renderer is widened to the kind-agnostic prop surface.
// At the render boundary the concrete kind isn't known statically (a registry
// lookup returns a union over kinds), so getPanelDefinition resolves to this —
// concentrating the single unavoidable cast in one place instead of leaking it
// to every call site.
export interface RenderablePanelDefinition extends Omit<
PanelDefinition,
'Renderer'
> {
Renderer: ComponentType<BaseRendererProps & AnyPanelInteractionProps>;
}

View File

@@ -0,0 +1,20 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
export type PanelKind =
| 'signoz/TimeSeriesPanel'
| 'signoz/BarChartPanel'
| 'signoz/NumberPanel'
| 'signoz/PieChartPanel'
| 'signoz/TablePanel'
| 'signoz/HistogramPanel'
| 'signoz/ListPanel';
export const PANEL_KIND_TO_PANEL_TYPE: Record<PanelKind, PANEL_TYPES> = {
'signoz/TimeSeriesPanel': PANEL_TYPES.TIME_SERIES,
'signoz/BarChartPanel': PANEL_TYPES.BAR,
'signoz/NumberPanel': PANEL_TYPES.VALUE,
'signoz/PieChartPanel': PANEL_TYPES.PIE,
'signoz/TablePanel': PANEL_TYPES.TABLE,
'signoz/HistogramPanel': PANEL_TYPES.HISTOGRAM,
'signoz/ListPanel': PANEL_TYPES.LIST,
};

View File

@@ -0,0 +1,75 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type {
DashboardCursorSync,
SyncTooltipFilterMode,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import type { PanelInteractionMap } from './interactions';
import type { PanelKind } from './panelKind';
/**
* Dashboard-wide rendering preferences propagated down to every panel renderer
* on the same dashboard. Lets the shell push cross-panel concerns (cursor
* sync, tooltip filter mode, dashboard id for scoped state) without each
* renderer rediscovering them via hooks. All fields are optional — non-
* dashboard render contexts (PanelEditor preview, standalone view) can pass
* an empty object and the renderer will fall back to sensible defaults.
*/
export interface DashboardPreference {
/**
* Cursor-sync mode for the dashboard. Drives the uPlot tooltip plugin so
* hovering one panel highlights the corresponding x on every other panel.
*/
syncMode?: DashboardCursorSync;
/**
* Filter applied to the synced tooltip across panels (e.g. only show series
* whose label matches the hovered series).
*/
syncFilterMode?: SyncTooltipFilterMode;
/**
* Dashboard id — useful for renderers that scope per-dashboard state
* (e.g. pinned-tooltip persistence, drill-down history).
*/
dashboardId?: string;
}
// Kind-agnostic props every renderer receives, regardless of panel kind. The
// kind-specific interaction props (onClick payload, onDragSelect) are layered
// on per-kind by PanelRendererProps<K>.
export interface BaseRendererProps {
panelId: string;
/**
* The whole perses panel — renderers derive their concrete `spec` and the
* perses-shaped `queries` from this. Passing the full panel keeps the prop
* surface stable as new panel-level fields are added to the wire format.
*/
panel: DashboardtypesPanelDTO | undefined;
/** Raw V5 fetch result — response + the request that produced it. */
data?: PanelQueryData;
isLoading: boolean;
error: Error | null;
/** Gate for the drill-down right-click menu. Off by default in V2. */
enableDrillDown?: boolean;
/**
* Render context — varies behavior (e.g. dashboard widget vs. standalone
* full-screen vs. inside the editor). See PanelMode for the contract.
*/
panelMode: PanelMode;
/**
* Dashboard-level preferences that should propagate to every panel
* (cursor sync, tooltip filter mode, dashboard id). The shell owns
* resolving these; the renderer just consumes them.
*/
dashboardPreference?: DashboardPreference;
}
// Renderer props for a specific panel kind: the shared base plus that kind's
// interaction surface (PanelInteractionMap[K]). Each renderer annotates with
// its own kind — e.g. PanelRendererProps<'signoz/TimeSeriesPanel'> — so it can
// only reference the gestures that kind supports. Indexing PanelInteractionMap
// here forces the map to cover every PanelKind. The default K = PanelKind
// yields the widest surface (a union over all kinds).
export type PanelRendererProps<K extends PanelKind = PanelKind> =
BaseRendererProps & PanelInteractionMap[K];

View File

@@ -0,0 +1,55 @@
import {
BarChart,
Columns3,
Hash,
ListEnd,
Palette,
Ruler,
SlidersHorizontal,
} from '@signozhq/icons';
// Derived from an actual icon component so the type stays exact (size is a
// constrained IconSize union, not arbitrary strings) and ForwardRef-compatible.
export type SectionIcon = typeof Hash;
export interface SectionMetadata {
title: string;
icon: SectionIcon;
description?: string;
}
// Per-kind control toggles (type-only — runtime metadata is in SECTIONS).
// Section components type their controls prop via `SectionControls['axes']`.
export type SectionControls = {
formatting: { unit?: boolean; decimals?: boolean };
axes: { minMax?: boolean; unit?: boolean; logScale?: boolean };
legend: { position?: boolean; mode?: boolean };
thresholds: { list?: boolean };
chartAppearance: {
lineStyle?: boolean;
fillOpacity?: boolean;
stacked?: boolean;
};
columnUnits: { perColumnUnit?: boolean };
buckets: { count?: boolean; min?: boolean; max?: boolean };
};
// Source of truth for sections. Its keys define SectionKind; its values are the
// runtime UI metadata (consumed by PanelEditor in 1.8). Adding a new section =
// one entry here + one entry in SectionControls.
export const SECTIONS = {
formatting: { title: 'Formatting', icon: Hash },
axes: { title: 'Axes', icon: Ruler },
legend: { title: 'Legend', icon: ListEnd },
thresholds: { title: 'Thresholds', icon: SlidersHorizontal },
chartAppearance: { title: 'Chart appearance', icon: Palette },
columnUnits: { title: 'Column units', icon: Columns3 },
buckets: { title: 'Buckets', icon: BarChart },
} as const satisfies Record<string, SectionMetadata>;
export type SectionKind = keyof typeof SECTIONS;
// Discriminated union derived from SectionControls — kept in lockstep automatically.
export type SectionConfig = {
[K in SectionKind]: { kind: K; controls: SectionControls[K] };
}[SectionKind];

View File

@@ -0,0 +1,29 @@
/**
* V2-native threshold model.
*
* The panel spec carries thresholds as `DashboardtypesComparisonThresholdDTO`
* (operator/format expressed as `above`/`below`/`text`/`background`). For
* evaluation and rendering we work with the symbol operators and lowercase
* display formats, kept here so V2 panels never reach into the V1
* `container/NewWidget` `ThresholdProps` shape.
*/
/** Comparison operators a threshold can use, as evaluable symbols. */
export type ThresholdComparisonOperator = '>' | '<' | '>=' | '<=' | '=' | '!=';
/** How a matched threshold recolors the panel. */
export type ThresholdDisplayFormat = 'text' | 'background';
/**
* A threshold normalized for evaluation/rendering. `operator`/`format` are
* optional because the spec allows partially-configured thresholds; a
* threshold with no operator never matches.
*/
export interface PanelThreshold {
color: string;
operator?: ThresholdComparisonOperator;
value: number;
/** Unit the threshold value is expressed in; converted to the panel unit before comparison. */
unit?: string;
format?: ThresholdDisplayFormat;
}

View File

@@ -0,0 +1,71 @@
import type { PanelThreshold } from '../../types/threshold';
import {
doesValueMatchThreshold,
resolveActiveThreshold,
} from '../evaluateThresholds';
const threshold = (overrides: Partial<PanelThreshold>): PanelThreshold => ({
color: '#f00',
value: 100,
operator: '>',
...overrides,
});
describe('doesValueMatchThreshold', () => {
it.each([
['>', 150, 100, true],
['>', 50, 100, false],
['<', 50, 100, true],
['>=', 100, 100, true],
['<=', 100, 100, true],
['=', 100, 100, true],
['!=', 150, 100, true],
] as const)('evaluates %s (%d vs %d)', (operator, value, target, expected) => {
expect(
doesValueMatchThreshold(value, threshold({ operator, value: target })),
).toBe(expected);
});
it('never matches a threshold without an operator', () => {
expect(doesValueMatchThreshold(150, threshold({ operator: undefined }))).toBe(
false,
);
});
it('compares the raw value when units are in different categories', () => {
// 'bytes' vs 'ms' belong to different categories, so conversion is invalid
// and the comparison falls back to the raw value (150 > 100).
expect(
doesValueMatchThreshold(150, threshold({ value: 100, unit: 'bytes' }), 'ms'),
).toBe(true);
});
});
describe('resolveActiveThreshold', () => {
it('returns no threshold when none match', () => {
const result = resolveActiveThreshold([threshold({ value: 1000 })], 10);
expect(result.threshold).toBeNull();
expect(result.isConflicting).toBe(false);
});
it('flags a conflict and picks the earliest-declared match', () => {
const first = threshold({ color: '#aaa', operator: '>', value: 0 });
const second = threshold({ color: '#bbb', operator: '>', value: 100 });
const result = resolveActiveThreshold([first, second], 150);
expect(result.isConflicting).toBe(true);
expect(result.threshold).toBe(first);
});
it('returns the single matching threshold without a conflict', () => {
const only = threshold({ color: '#abc', operator: '>', value: 100 });
const result = resolveActiveThreshold(
[only, threshold({ value: 9999 })],
150,
);
expect(result.threshold).toBe(only);
expect(result.isConflicting).toBe(false);
});
});

View File

@@ -0,0 +1,33 @@
import { PrecisionOptionsEnum } from 'components/Graph/types';
import { formatPanelValue } from '../formatPanelValue';
describe('formatPanelValue', () => {
it('applies the configured precision and appends the unit label', () => {
// The unit-aware formatter returns value + label as one string; the
// ValueDisplay splits it into numeric/suffix parts when rendering.
expect(formatPanelValue(295.4299833508185, 'ms', 2)).toBe('295.43 ms');
});
// Regression: precision must apply even with no unit. The old gate
// (`unit ? format() : value.toString()`) dropped precision on unitless
// panels, so decimal-precision changes had no visible effect.
it('applies precision when NO unit is set', () => {
expect(formatPanelValue(3.14159, undefined, 2)).toBe('3.14');
expect(formatPanelValue(3.14159, '', 2)).toBe('3.14');
});
it('honors full precision without a unit', () => {
expect(formatPanelValue(3.14159, undefined, PrecisionOptionsEnum.FULL)).toBe(
'3.14159',
);
});
it('drops the fractional part at precision 0', () => {
expect(formatPanelValue(3.14159, undefined, 0)).toBe('3');
});
it('renders whole numbers without a trailing decimal', () => {
expect(formatPanelValue(5, undefined, 2)).toBe('5');
});
});

View File

@@ -0,0 +1,120 @@
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import type { BuilderQuery } from 'types/api/v5/queryRange';
import { resolveSeriesLabelV5 } from '../resolveSeriesLabel';
// Fixtures cast at the boundary; the v5 BuilderQuery union is too verbose to
// construct field-typed inline.
function builderQuery(spec: Record<string, unknown>): BuilderQuery {
return spec as unknown as BuilderQuery;
}
function panelSeries(overrides: Partial<PanelSeries> = {}): PanelSeries {
return {
queryName: 'A',
legend: '',
labels: { host: 'h1' },
kind: 'series',
values: [],
aggregation: { index: 0, alias: '' },
...overrides,
};
}
describe('resolveSeriesLabelV5', () => {
it('returns baseLabel for panels without builder queries (promql/clickhouse)', () => {
expect(resolveSeriesLabelV5(panelSeries(), [], 'base')).toBe('base');
});
it('returns baseLabel when no query matches the series queryName', () => {
expect(
resolveSeriesLabelV5(
panelSeries({ queryName: 'Z' }),
[builderQuery({ name: 'A' })],
'base',
),
).toBe('base');
});
it('falls back to baseLabel || queryName when the aggregation has no alias/expression (metrics)', () => {
const queries = [
builderQuery({ name: 'A', aggregations: [{ metricName: 'cpu' }] }),
];
expect(resolveSeriesLabelV5(panelSeries(), queries, 'base')).toBe('base');
expect(resolveSeriesLabelV5(panelSeries(), queries, '')).toBe('A');
});
it('single query + groupBy + single aggregation → baseLabel', () => {
const queries = [
builderQuery({
name: 'A',
groupBy: [{ name: 'host' }],
aggregations: [{ expression: 'count()', alias: '' }],
}),
];
expect(resolveSeriesLabelV5(panelSeries(), queries, 'h1')).toBe('h1');
});
it('single query + groupBy + multiple aggregations → "alias-or-expression"-baseLabel', () => {
const queries = [
builderQuery({
name: 'A',
groupBy: [{ name: 'host' }],
aggregations: [
{ expression: 'count()', alias: '' },
{ expression: 'avg(x)', alias: 'mean' },
],
}),
];
expect(
resolveSeriesLabelV5(
panelSeries({ aggregation: { index: 1, alias: 'mean' } }),
queries,
'h1',
),
).toBe('mean-h1');
});
it('single query, no groupBy, single aggregation → alias || legend || expression', () => {
const queries = [
builderQuery({
name: 'A',
legend: 'My legend',
aggregations: [{ expression: 'count()', alias: '' }],
}),
];
expect(resolveSeriesLabelV5(panelSeries({ labels: {} }), queries, 'A')).toBe(
'My legend',
);
});
it('multiple queries, no groupBy, single aggregation → alias || baseLabel', () => {
const queries = [
builderQuery({ name: 'A', aggregations: [{ expression: 'count()' }] }),
builderQuery({ name: 'B', aggregations: [{ expression: 'sum(y)' }] }),
];
expect(
resolveSeriesLabelV5(panelSeries({ labels: {} }), queries, 'base'),
).toBe('base');
});
it('resolves via the aggregation index carried on the series', () => {
const queries = [
builderQuery({
name: 'A',
aggregations: [
{ expression: 'count()', alias: 'hits' },
{ expression: 'avg(x)', alias: 'mean' },
],
}),
];
expect(
resolveSeriesLabelV5(
panelSeries({ labels: {}, aggregation: { index: 1, alias: 'mean' } }),
queries,
'',
),
).toBe('mean');
});
});

View File

@@ -0,0 +1,207 @@
import type {
DashboardtypesPanelFormattingDTO,
DashboardtypesThresholdWithLabelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import onClickPlugin, {
OnClickPluginOpts,
} from 'lib/uPlotLib/plugins/onClickPlugin';
import {
DistributionType,
SelectionPreferencesSource,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { ThresholdsDrawHookOptions } from 'lib/uPlotV2/hooks/types';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
/**
* Inputs for the shared V2 chart pipeline. Mirrors the V1 helper of the same
* name but accepts perses-shaped inputs directly (so callers don't translate
* once per panel). The series-rendering step is panel-specific and lives in
* each panel's `utils.ts` — this helper only wires the scaffolding (scales,
* thresholds, axes, drag-to-zoom, click plugin).
*/
export interface BuildBaseConfigArgs {
panelId: string;
panelType: PANEL_TYPES;
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
/** From `spec.axes` — drives the Y scale and (when log) both scales' base. */
isLogScale?: boolean;
softMin?: number;
softMax?: number;
/** From `spec.formatting.unit` — drives Y axis tick formatting + threshold formatting. */
formatting?: DashboardtypesPanelFormattingDTO;
/** From `spec.thresholds` — perses shape, mapped to the draw-hook shape internally. */
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
/** Per-query step intervals from the response exec stats. */
stepIntervals?: Record<string, number>;
/**
* Tuple-shaped payload for the shared click plugin (see
* `toClickPluginPayload`). Omitted by panels without click interactions.
*/
clickPayload?: MetricRangePayloadProps;
/** Time-range clamps for the X scale (typically from `getTimeRange(apiResponse)`). */
minTimeScale?: number;
maxTimeScale?: number;
/** Optional — histogram and other non-time panels omit drag-to-zoom. */
onDragSelect?: (start: number, end: number) => void;
onClick?: OnClickPluginOpts['onClick'];
}
/**
* Builds the panel-agnostic scaffolding of a uPlot chart: scales, thresholds,
* axes, drag-to-zoom, click plugin. Callers (TimeSeriesPanel, BarPanel, …)
* then call `addSeries`/`addPlugin` on the returned builder for their own
* panel-specific rendering.
*/
export function buildBaseConfig({
panelId,
panelType,
isDarkMode,
timezone,
panelMode,
isLogScale,
softMin,
softMax,
formatting,
thresholds,
stepIntervals,
clickPayload,
minTimeScale,
maxTimeScale,
onDragSelect,
onClick,
}: BuildBaseConfigArgs): UPlotConfigBuilder {
const yAxisUnit = formatting?.unit;
const builder = new UPlotConfigBuilder({
id: panelId,
onDragSelect,
tzDate: makeTzDate(timezone),
shouldSaveSelectionPreference: panelMode === PanelMode.DASHBOARD_VIEW,
selectionPreferencesSource: resolveSelectionPreferencesSource(panelMode),
stepInterval: stepIntervals ? minStepInterval(stepIntervals) : undefined,
});
const thresholdOptions: ThresholdsDrawHookOptions = {
scaleKey: 'y',
thresholds: mapThresholds(thresholds),
yAxisUnit,
};
builder.addThresholds(thresholdOptions);
builder.addScale({
scaleKey: 'x',
time: true,
min: minTimeScale,
max: maxTimeScale,
logBase: isLogScale ? 10 : undefined,
distribution: isLogScale
? DistributionType.Logarithmic
: DistributionType.Linear,
});
builder.addScale({
scaleKey: 'y',
time: false,
min: undefined,
max: undefined,
softMin,
softMax,
thresholds: thresholdOptions,
logBase: isLogScale ? 10 : undefined,
distribution: isLogScale
? DistributionType.Logarithmic
: DistributionType.Linear,
});
if (typeof onClick === 'function') {
builder.addPlugin(onClickPlugin({ onClick, apiResponse: clickPayload }));
}
builder.addAxis({
scaleKey: 'x',
show: true,
side: 2,
isDarkMode,
isLogScale,
panelType,
});
builder.addAxis({
scaleKey: 'y',
show: true,
side: 3,
isDarkMode,
isLogScale,
yAxisUnit,
panelType,
});
return builder;
}
function makeTzDate(
timezone: Timezone,
): ((timestamp: number) => Date) | undefined {
if (!timezone) {
return undefined;
}
return (timestamp: number): Date =>
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value);
}
function resolveSelectionPreferencesSource(
panelMode: PanelMode,
): SelectionPreferencesSource {
return panelMode === PanelMode.DASHBOARD_VIEW ||
panelMode === PanelMode.STANDALONE_VIEW
? SelectionPreferencesSource.LOCAL_STORAGE
: SelectionPreferencesSource.IN_MEMORY;
}
// Perses-shape thresholds → the draw-hook shape uPlotV2 consumes. Exported so
// panels that need to feed the same threshold list elsewhere (e.g. to a series
// `addSeries` thresholds hook) don't have to redo the mapping.
export function mapThresholds(
thresholds: DashboardtypesThresholdWithLabelDTO[] | null | undefined,
): ThresholdsDrawHookOptions['thresholds'] {
if (!thresholds || thresholds.length === 0) {
return [];
}
return thresholds.map((t) => ({
thresholdValue: t.value,
thresholdColor: t.color,
thresholdUnit: t.unit,
thresholdLabel: t.label,
}));
}
/**
* V5 backend reports per-query step intervals; we feed the smallest one through
* to uPlot so the X-axis tick density matches the densest query. An empty map
* yields `Infinity` from `Math.min`, which would corrupt downstream scale math —
* fall back to `undefined` (uPlot's "auto") in that case.
*/
function minStepInterval(
stepIntervals: Record<string, number>,
): number | undefined {
const values = Object.values(stepIntervals);
if (values.length === 0) {
return undefined;
}
const min = Math.min(...values);
return Number.isFinite(min) ? min : undefined;
}

View File

@@ -0,0 +1,108 @@
import {
DashboardtypesFillModeDTO,
DashboardtypesLegendPositionDTO,
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
DashboardtypesPrecisionOptionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import {
FillMode,
LineInterpolation,
LineStyle,
} from 'lib/uPlotV2/config/types';
/**
* Bridges the V2 dashboard wire-format enums (snake_case, generated from Go)
* to the uPlotV2 chart enums (PascalCase). String values diverge between the
* two — don't coerce, map.
*
* Kept as a single source of truth so every panel that reads chart-appearance
* fields stays in sync as either side's enum evolves.
*/
export const LINE_STYLE_MAP: Record<DashboardtypesLineStyleDTO, LineStyle> = {
[DashboardtypesLineStyleDTO.solid]: LineStyle.Solid,
[DashboardtypesLineStyleDTO.dashed]: LineStyle.Dashed,
};
export const LINE_INTERPOLATION_MAP: Record<
DashboardtypesLineInterpolationDTO,
LineInterpolation
> = {
[DashboardtypesLineInterpolationDTO.linear]: LineInterpolation.Linear,
[DashboardtypesLineInterpolationDTO.spline]: LineInterpolation.Spline,
[DashboardtypesLineInterpolationDTO.step_after]: LineInterpolation.StepAfter,
[DashboardtypesLineInterpolationDTO.step_before]: LineInterpolation.StepBefore,
};
export const FILL_MODE_MAP: Record<DashboardtypesFillModeDTO, FillMode> = {
[DashboardtypesFillModeDTO.solid]: FillMode.Solid,
[DashboardtypesFillModeDTO.gradient]: FillMode.Gradient,
[DashboardtypesFillModeDTO.none]: FillMode.None,
};
export const LEGEND_POSITION_MAP: Record<
DashboardtypesLegendPositionDTO,
LegendPosition
> = {
[DashboardtypesLegendPositionDTO.bottom]: LegendPosition.BOTTOM,
[DashboardtypesLegendPositionDTO.right]: LegendPosition.RIGHT,
};
/**
* `spec.formatting.decimalPrecision` is a stringified-digit enum on the wire
* (`'0'``'4'` plus the sentinel `'full'`). The chart consumes a numeric
* `PrecisionOption` (`0``4`) or the same `'full'` sentinel from its own
* enum. Missing / unknown → `undefined` (chart uses its default).
*/
export function resolveDecimalPrecision(
precision: DashboardtypesPrecisionOptionDTO | undefined,
): PrecisionOption | undefined {
if (!precision) {
return undefined;
}
if (precision === DashboardtypesPrecisionOptionDTO.full) {
return PrecisionOptionsEnum.FULL;
}
const parsed = Number(precision);
if (
parsed === 0 ||
parsed === 1 ||
parsed === 2 ||
parsed === 3 ||
parsed === 4
) {
return parsed;
}
return undefined;
}
/**
* `spec.chartAppearance.spanGaps.fillLessThan` is a stringified number on the
* wire. Empty / missing → span all gaps (the chart default). Numeric → forward
* the threshold so uPlot only bridges short runs of nulls.
*/
export function resolveSpanGaps(
fillLessThan: string | undefined,
): boolean | number {
if (!fillLessThan) {
return true;
}
const parsed = Number(fillLessThan);
return Number.isFinite(parsed) ? parsed : true;
}
/**
* Resolves the legend position for a panel. Missing / unknown values fall
* back to `BOTTOM` to match the chart's default and the V1 behavior.
*/
export function resolveLegendPosition(
position: DashboardtypesLegendPositionDTO | undefined,
): LegendPosition {
if (position && position in LEGEND_POSITION_MAP) {
return LEGEND_POSITION_MAP[position];
}
return LegendPosition.BOTTOM;
}

View File

@@ -0,0 +1,139 @@
import {
UniversalUnitToGrafanaUnit,
YAxisCategoryNames,
} from 'components/YAxisUnitSelector/constants';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import { getYAxisCategories } from 'components/YAxisUnitSelector/utils';
import { convertValue } from 'lib/getConvertedValue';
import type {
PanelThreshold,
ThresholdComparisonOperator,
} from '../types/threshold';
/**
* Threshold evaluation for V2 panels — a self-contained port of the V1
* `GridTableComponent`/`ValueGraph` logic that depends only on shared,
* non-V1 primitives (`convertValue`, the Y-axis unit catalog). No imports
* from `container/NewWidget`, `container/GridTableComponent`, or
* `components/ValueGraph`.
*/
/** Resolves which unit category a unit id belongs to, or null if unknown. */
function getCategoryName(unitId: string): YAxisCategoryNames | null {
const categories = getYAxisCategories(YAxisSource.DASHBOARDS);
const foundCategory = categories.find((category) =>
category.units.some((unit) => {
// Category units use universal ids; thresholds/panel units may use
// Grafana-style ids. Match either the universal id directly or its
// mapped Grafana id.
if (unit.id === unitId) {
return true;
}
return UniversalUnitToGrafanaUnit[unit.id] === unitId;
}),
);
return foundCategory ? foundCategory.name : null;
}
/**
* Converts `value` from `fromUnit` to `toUnit`, returning null when the
* conversion is invalid (unknown unit, or units in different categories).
*/
function convertUnit(
value: number,
fromUnit?: string,
toUnit?: string,
): number | null {
if (!fromUnit || !toUnit) {
return null;
}
const fromCategory = getCategoryName(fromUnit);
const toCategory = getCategoryName(toUnit);
if (!fromCategory || !toCategory || fromCategory !== toCategory) {
return null;
}
return convertValue(value, fromUnit, toUnit);
}
function evaluateCondition(
operator: ThresholdComparisonOperator | undefined,
value: number,
thresholdValue: number,
): boolean {
switch (operator) {
case '>':
return value > thresholdValue;
case '<':
return value < thresholdValue;
case '>=':
return value >= thresholdValue;
case '<=':
return value <= thresholdValue;
case '=':
return value === thresholdValue;
case '!=':
return value !== thresholdValue;
default:
return false;
}
}
/**
* Whether `value` (expressed in `panelUnit`) satisfies `threshold`. When the
* threshold declares its own unit, the panel value is converted into that unit
* before comparing; if the conversion is invalid we compare the raw value.
*/
export function doesValueMatchThreshold(
value: number,
threshold: PanelThreshold,
panelUnit?: string,
): boolean {
if (threshold.operator === undefined) {
return false;
}
const convertedValue = convertUnit(value, panelUnit, threshold.unit);
const comparable = convertedValue ?? value;
return evaluateCondition(threshold.operator, comparable, threshold.value);
}
export interface ActiveThreshold {
/** The matched threshold to apply, or null when none match. */
threshold: PanelThreshold | null;
/** True when more than one threshold matched the value. */
isConflicting: boolean;
}
/**
* Resolves the threshold to apply for `value`. Among matching thresholds the
* one declared earliest (lowest index) wins, mirroring V1 precedence; a match
* count greater than one flags a conflict.
*/
export function resolveActiveThreshold(
thresholds: PanelThreshold[],
value: number,
panelUnit?: string,
): ActiveThreshold {
const matching = thresholds.filter((threshold) =>
doesValueMatchThreshold(value, threshold, panelUnit),
);
if (matching.length === 0) {
return { threshold: null, isConflicting: false };
}
const highestPrecedence = matching.reduce((winner, candidate) =>
thresholds.indexOf(candidate) < thresholds.indexOf(winner)
? candidate
: winner,
);
return { threshold: highestPrecedence, isConflicting: matching.length > 1 };
}

View File

@@ -0,0 +1,22 @@
import type { PrecisionOption } from 'components/Graph/types';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
/**
* Formats a scalar for display in a V2 panel, honoring the configured decimal
* precision. The shared, unit-aware `getYAxisFormattedValue` is the single
* formatting helper across V2 panels (number/table/list/pie); this wrapper is
* the only seam through which panels touch it.
*
* Precision is applied REGARDLESS of whether a unit is set. When no unit is
* configured we format through the `'none'` unit, which still respects
* precision — this is the fix for decimal precision being silently dropped on
* unitless panels (the old `unit ? format() : value.toString()` gate threw the
* precision away whenever the unit was empty).
*/
export function formatPanelValue(
value: number,
unit?: string,
precision?: PrecisionOption,
): string {
return getYAxisFormattedValue(String(value), unit || 'none', precision);
}

View File

@@ -0,0 +1,38 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import type { BuilderQuery } from 'types/api/v5/queryRange';
/**
* Flattens a panel's queries into the list of builder queries it contains —
* unwrapping `CompositeQuery` envelopes along the way. Non-builder kinds
* (PromQL, ClickHouseSQL, Formula, TraceOperator) are dropped: they don't
* carry the legend / groupBy / aggregation context downstream code needs.
*
* Returns the generated v5 `BuilderQuery` shape directly — no intermediate
* summary type — so callers consume the same type the wire format defines.
*/
export function getBuilderQueries(
queries: DashboardtypesQueryDTO[] | null | undefined,
): BuilderQuery[] {
if (!queries) {
return [];
}
const flattened: BuilderQuery[] = [];
queries.forEach((envelope) => {
const plugin = envelope?.spec?.plugin;
if (!plugin) {
return;
}
if (plugin.kind === 'signoz/BuilderQuery') {
flattened.push(plugin.spec as BuilderQuery);
return;
}
if (plugin.kind === 'signoz/CompositeQuery') {
(plugin.spec.queries ?? []).forEach((sub) => {
if (sub.type === 'builder_query') {
flattened.push(sub.spec as BuilderQuery);
}
});
}
});
return flattened;
}

View File

@@ -0,0 +1,26 @@
export interface ParsedFormattedValue {
/** The numeric portion (e.g. "295.43", "1.2K"). */
numericValue: string;
/** A leading unit symbol such as a currency prefix, if any. */
prefixUnit: string;
/** A trailing unit label such as "ms" or "MB", if any. */
suffixUnit: string;
}
/**
* Splits a formatted value string (e.g. "$ 1.2K", "295.43 ms") into its
* numeric core and any prefix/suffix unit so each part can be styled
* independently. Falls back to treating the whole string as the numeric value
* when it doesn't match the expected shape.
*/
export function parseFormattedValue(value: string): ParsedFormattedValue {
const matches = value.match(
/^([^\d.]*)?([\d.]+(?:[eE][+-]?[\d]+)?[KMB]?)([^\d.]*)?$/,
);
return {
numericValue: matches?.[2] || value,
prefixUnit: matches?.[1]?.trim() || '',
suffixUnit: matches?.[3]?.trim() || '',
};
}

View File

@@ -0,0 +1,138 @@
import type { BuilderQuery } from 'types/api/v5/queryRange';
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
/**
* Identity of one series for label resolution: which query produced it and
* which of that query's aggregations.
*/
interface SeriesIdentity {
queryName: string;
/** Index into the matched query's aggregation list. */
aggregationIndex: number;
/** Fallback when the base label is empty and the aggregation is bare. */
fallbackName?: string;
}
/** Resolves the display label for one flattened V5 series. */
export function resolveSeriesLabelV5(
series: PanelSeries,
builderQueries: BuilderQuery[],
baseLabel: string,
): string {
return resolveLabel(
{
queryName: series.queryName,
aggregationIndex: series.aggregation.index,
fallbackName: series.queryName,
},
builderQueries,
baseLabel,
);
}
/**
* Applies the V1 legend matrix: `single-vs-many builder queries ×
* with/without groupBy × single-vs-many aggregations`. Returns `baseLabel`
* unchanged for panels without builder queries (PromQL, ClickHouseSQL) and
* for builder series whose aggregation carries no alias/expression — metric
* aggregations don't have those fields, so they naturally short-circuit to
* the base label here.
*/
function resolveLabel(
identity: SeriesIdentity,
builderQueries: BuilderQuery[],
baseLabel: string,
): string {
if (builderQueries.length === 0) {
return baseLabel;
}
const matching = builderQueries.find((q) => q.name === identity.queryName);
if (!matching) {
return baseLabel;
}
const aggIndex = identity.aggregationIndex;
const aggregations = matching.aggregations ?? [];
const aggregation = aggregations[aggIndex];
// `alias` / `expression` exist on Log/Trace aggregations only —
// `MetricAggregation` carries `metricName`/`temporality`/… instead. The
// `in` guards narrow the union without a cast.
const aggregationAlias =
aggregation && 'alias' in aggregation ? (aggregation.alias ?? '') : '';
const aggregationExpression =
aggregation && 'expression' in aggregation
? (aggregation.expression ?? '')
: '';
if (!aggregationAlias && !aggregationExpression) {
return baseLabel || identity.fallbackName || matching.name || '';
}
const ctx: FormatContext = {
aggregationAlias,
aggregationExpression,
baseLabel,
legend: matching.legend ?? '',
hasGroupBy: (matching.groupBy?.length ?? 0) > 0,
singleAggregation: aggregations.length === 1,
};
return builderQueries.length === 1
? formatForSinglePanelQuery(ctx)
: formatForMultiplePanelQueries(ctx);
}
interface FormatContext {
aggregationAlias: string;
aggregationExpression: string;
baseLabel: string;
legend: string;
hasGroupBy: boolean;
singleAggregation: boolean;
}
// Panel has one builder query — ports V1's `getLegendForSingleAggregation`.
function formatForSinglePanelQuery({
aggregationAlias,
aggregationExpression,
baseLabel,
legend,
hasGroupBy,
singleAggregation,
}: FormatContext): string {
if (hasGroupBy) {
if (singleAggregation) {
return baseLabel;
}
return `${aggregationAlias || aggregationExpression}-${baseLabel}`;
}
if (singleAggregation) {
return aggregationAlias || legend || aggregationExpression;
}
return aggregationAlias || aggregationExpression;
}
// Panel has multiple builder queries — ports V1's `getLegendForMultipleAggregations`.
// Differs from the single-query path in two cells: the no-groupBy / single-agg
// cell falls through to `baseLabel` instead of `legend`, and the no-groupBy /
// multi-agg cell prepends the base label.
function formatForMultiplePanelQueries({
aggregationAlias,
aggregationExpression,
baseLabel,
hasGroupBy,
singleAggregation,
}: FormatContext): string {
if (hasGroupBy) {
if (singleAggregation) {
return baseLabel;
}
return `${aggregationAlias || aggregationExpression}-${baseLabel}`;
}
if (singleAggregation) {
return aggregationAlias || baseLabel || aggregationExpression;
}
return `${aggregationAlias || aggregationExpression}-${baseLabel}`;
}

View File

@@ -3,8 +3,8 @@
flex-direction: column;
height: 100%;
width: 100%;
background: var(--bg-ink-400, #0b0c0e);
border: 1px solid var(--l1-border);
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: 4px;
overflow: hidden;
}
@@ -14,7 +14,7 @@
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--l1-border);
border-bottom: 1px solid var(--l2-border);
cursor: grab;
}
@@ -36,6 +36,15 @@
margin-inline-end: 0;
}
// Actions sit inside the drag-handle row but opt out of dragging
// (`panel-no-drag`); reset the grab cursor so the menu reads as clickable.
.actions {
display: flex;
align-items: center;
gap: 4px;
cursor: default;
}
.body {
flex: 1;
display: flex;
@@ -50,3 +59,41 @@
.bodyKind {
margin-bottom: 6px;
}
// Container for the rendered chart — fills the panel below the header and lets
// the chart shrink (min-* 0) so it resizes with the grid cell.
.chartBody {
flex: 1;
display: flex;
min-height: 0;
min-width: 0;
}
// Subtle background-refetch spinner in the header (chart stays mounted).
.refetchIndicator {
color: var(--l2-foreground);
flex-shrink: 0;
}
// Error state — shown only when there's no stale data to fall back to.
.error {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
text-align: center;
}
.errorIcon {
color: var(--bg-cherry-500);
}
.errorMessage {
color: var(--l2-foreground);
font-size: 12px;
max-width: 90%;
overflow-wrap: anywhere;
}

View File

@@ -1,15 +1,16 @@
import { useMemo } from 'react';
import { Badge } from '@signozhq/ui/badge';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { EllipsisVertical } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels';
import { usePanelQuery } from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
import type { Warning } from 'types/api';
import type { DashboardSection } from '../../utils';
import type { DeletePanelArgs } from './hooks/useDeletePanel';
import { usePanelInteractions } from './hooks/usePanelInteractions';
import type { MovePanelArgs } from './hooks/useMovePanelToSection';
import PanelActionsMenu from './PanelActionsMenu/PanelActionsMenu';
import PanelBody from './PanelBody';
import PanelHeader from './PanelHeader';
import styles from './Panel.module.scss';
/** Panel action context — present together only in editable sectioned mode. */
@@ -23,16 +24,18 @@ export interface PanelActionsConfig {
interface PanelProps {
panel: DashboardtypesPanelDTO | undefined;
panelId: string;
/**
* Placeholder: true once this panel's section enters the viewport. The panel
* query-loading implementation (later PR) will consume this to lazily fetch
* data. Currently unused on purpose.
*/
/** True once this panel's section enters the viewport — gates the fetch. */
isVisible?: boolean;
/** Move/delete actions — present only in editable sectioned mode. */
panelActions?: PanelActionsConfig;
}
/**
* A single dashboard panel: chrome (header) + content (body). Thin orchestrator
* — data fetching lives in `usePanelQuery`, cross-panel interactions in
* `usePanelInteractions`, and the loading/error/chart state machine in
* `PanelBody`.
*/
function Panel({
panel,
panelId,
@@ -41,9 +44,22 @@ function Panel({
}: PanelProps): JSX.Element {
const name = panel?.spec?.display?.name || `Panel ${panelId.slice(0, 6)}`;
const description = panel?.spec?.display?.description;
const kind = panel?.spec?.plugin?.kind?.replace(/^signoz\//, '') ?? 'unknown';
const fullKind = panel?.spec?.plugin?.kind;
const kind = fullKind?.replace(/^signoz\//, '') ?? 'unknown';
const queryCount = panel?.spec?.queries?.length ?? 0;
const panelDef = getPanelDefinition(fullKind);
const { data, isLoading, isFetching, error, refetch } = usePanelQuery({
panel,
panelId,
// Lazy: only fetch once the section is on screen (undefined → treat as
// visible) and a renderer exists for the kind.
enabled: !!panelDef && isVisible !== false,
});
const { onDragSelect, dashboardPreference } = usePanelInteractions();
const headerTitle = useMemo(() => {
if (!description) {
return name;
@@ -60,35 +76,30 @@ function Panel({
className={styles.panel}
data-panel-visible={isVisible ? 'true' : 'false'}
>
<div className={cx(styles.header, 'panel-drag-handle')}>
<div className={styles.headerLeft}>
<Typography.Text className={styles.headerTitle}>
{headerTitle}
</Typography.Text>
<Badge className={styles.badge}>{kind}</Badge>
</div>
{panelActions ? (
<PanelActionsMenu
panelId={panelId}
currentLayoutIndex={panelActions.currentLayoutIndex}
sections={panelActions.sections}
onMovePanel={panelActions.onMovePanel}
onDeletePanel={panelActions.onDeletePanel}
/>
) : (
<EllipsisVertical size={14} />
)}
</div>
<div className={styles.body}>
<div>
<div className={styles.bodyKind}>{kind} panel</div>
<div>
{queryCount} {queryCount === 1 ? 'query' : 'queries'} · chart rendering
coming next
</div>
</div>
</div>
<PanelHeader
title={headerTitle}
panelId={panelId}
isFetching={isFetching}
error={error}
// The V5 response `warning` is the same object the legacy chain
// surfaced as `Warning` — passed through untouched; the cast is the
// generated-DTO → hand-written-type boundary.
warning={data.response?.data?.warning as Warning | undefined}
panelActions={panelActions}
/>
<PanelBody
panelDef={panelDef}
panel={panel}
panelId={panelId}
kind={kind}
queryCount={queryCount}
data={data}
isLoading={isLoading}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
dashboardPreference={dashboardPreference}
/>
</div>
);
}

View File

@@ -0,0 +1,112 @@
import { Spin } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { Loader, TriangleAlert } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import styles from './Panel.module.scss';
interface PanelBodyProps {
/** Resolved renderer for the panel kind; undefined when the kind is unknown. */
panelDef: RenderablePanelDefinition | undefined;
panel: DashboardtypesPanelDTO | undefined;
panelId: string;
kind: string;
queryCount: number;
data: PanelQueryData;
isLoading: boolean;
error: Error | null;
refetch: () => void;
onDragSelect: (start: number, end: number) => void;
dashboardPreference: DashboardPreference;
}
/**
* Renders the panel content as an explicit state machine so each state is
* handled deliberately (no implicit fall-through):
*
* unknown-kind → unsupported fallback
* error + no data → error message with retry
* first load (no data) → loading indicator
* otherwise → the kind's renderer (which owns its own "No Data" state, and
* keeps stale data mounted during background refetches)
*/
function PanelBody({
panelDef,
panel,
panelId,
kind,
queryCount,
data,
isLoading,
error,
refetch,
onDragSelect,
dashboardPreference,
}: PanelBodyProps): JSX.Element {
if (!panelDef) {
return (
<div className={styles.body} data-testid="panel-unknown-kind-fallback">
<div>
<div className={styles.bodyKind}>{kind} panel</div>
<div>
{queryCount} {queryCount === 1 ? 'query' : 'queries'} · not yet supported
in V2
</div>
</div>
</div>
);
}
// Surface a hard failure only when there's no (stale) data to show; otherwise
// keep the last-good chart and let the header indicate the refresh.
// react-query keeps the previous response during background refetches, so
// `data.response` presence is the "have something to show" signal.
const hasData = !!data.response;
if (error && !hasData) {
return (
<div className={styles.error} data-testid="panel-error">
<TriangleAlert size={20} className={styles.errorIcon} />
<Typography.Text className={styles.errorMessage}>
{error.message || 'Failed to load panel data'}
</Typography.Text>
<Button variant="outlined" color="secondary" onClick={refetch}>
Retry
</Button>
</div>
);
}
// First load only — background refetches keep the response populated so the
// chart stays mounted instead of blinking.
if (isLoading && !hasData) {
return (
<div className={styles.body} data-testid="panel-loading">
<Spin indicator={<Loader size={14} className="animate-spin" />} />
</div>
);
}
return (
<div className={styles.chartBody}>
<panelDef.Renderer
panelId={panelId}
panel={panel}
data={data}
isLoading={isLoading}
error={error}
onDragSelect={onDragSelect}
panelMode={PanelMode.DASHBOARD_VIEW}
enableDrillDown={false}
dashboardPreference={dashboardPreference}
/>
</div>
);
}
export default PanelBody;

View File

@@ -0,0 +1,81 @@
import { useMemo, type ReactNode } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { Loader } from '@signozhq/icons';
import cx from 'classnames';
import type { Warning } from 'types/api';
import type { PanelActionsConfig } from './Panel';
import PanelActionsMenu from './PanelActionsMenu/PanelActionsMenu';
import PanelStatusPopover from './PanelStatus/PanelStatusPopover';
import {
panelStatusFromError,
panelStatusFromWarning,
} from './PanelStatus/utils';
import styles from './Panel.module.scss';
interface PanelHeaderProps {
title: ReactNode;
panelId: string;
/** Background refresh in flight — shows a subtle spinner without blinking the chart. */
isFetching: boolean;
/** Latest query error, if any — surfaced as a header error indicator. */
error?: Error | null;
/** Non-fatal query warning lifted from the response payload. */
warning?: Warning;
/** Move/delete actions — present only in editable sectioned mode. */
panelActions?: PanelActionsConfig;
}
/** Panel chrome: drag handle, title, refetch + status indicators, actions. */
function PanelHeader({
title,
panelId,
isFetching,
error,
warning,
panelActions,
}: PanelHeaderProps): JSX.Element {
const errorDetail = useMemo(
() => panelStatusFromError(error ?? null),
[error],
);
const warningDetail = useMemo(
() => panelStatusFromWarning(warning),
[warning],
);
return (
<div className={cx(styles.header, 'panel-drag-handle')}>
<div className={styles.headerLeft}>
<Typography.Text className={styles.headerTitle}>{title}</Typography.Text>
{isFetching && (
<Loader
size={12}
className={cx('animate-spin', styles.refetchIndicator)}
data-testid="panel-refetching"
/>
)}
</div>
{/* `panel-no-drag` opts this region out of the grid drag handle so the
actions menu is clickable instead of starting a panel drag. */}
<div className={cx('panel-no-drag', styles.actions)}>
{errorDetail && <PanelStatusPopover variant="error" detail={errorDetail} />}
{warningDetail && (
<PanelStatusPopover variant="warning" detail={warningDetail} />
)}
{panelActions && (
<PanelActionsMenu
panelId={panelId}
currentLayoutIndex={panelActions.currentLayoutIndex}
sections={panelActions.sections}
onMovePanel={panelActions.onMovePanel}
onDeletePanel={panelActions.onDeletePanel}
/>
)}
</div>
</div>
);
}
export default PanelHeader;

View File

@@ -0,0 +1,51 @@
import { BookOpenText } from '@signozhq/icons';
import type { PanelStatusDetail } from './types';
import styles from './PanelStatusPopover.module.scss';
interface PanelStatusContentProps {
detail: PanelStatusDetail;
}
/**
* Popover body for a panel status (error or warning): a code + summary header
* with an optional docs link, followed by any per-item messages. Pure
* presentation — the variant's icon/colour is owned by `PanelStatusPopover`.
*/
function PanelStatusContent({ detail }: PanelStatusContentProps): JSX.Element {
const { code, message, docsUrl, messages } = detail;
return (
<section className={styles.content} data-testid="panel-status-content">
<header className={styles.summary}>
<div className={styles.summaryText}>
<h2 className={styles.code}>{code}</h2>
<p className={styles.message}>{message}</p>
</div>
{docsUrl && (
<a
className={styles.docsLink}
href={docsUrl}
target="_blank"
rel="noreferrer"
data-testid="panel-status-docs"
>
<BookOpenText size={14} />
Open Docs
</a>
)}
</header>
{messages.length > 0 && (
<ul className={styles.messageList}>
{messages.map((m) => (
<li key={m} className={styles.messageItem}>
{m}
</li>
))}
</ul>
)}
</section>
);
}
export default PanelStatusContent;

View File

@@ -0,0 +1,64 @@
.trigger {
display: inline-flex;
align-items: center;
cursor: pointer;
flex-shrink: 0;
}
.content {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 600px;
padding: 12px;
}
.summary {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.summaryText {
min-width: 0;
}
.code {
margin: 0;
font-size: 12px;
font-weight: 600;
color: var(--l2-foreground);
}
.message {
margin: 4px 0 0;
font-size: 12px;
color: var(--l1-foreground);
word-break: break-word;
}
.docsLink {
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
font-size: 12px;
white-space: nowrap;
}
.messageList {
margin: 0;
padding-left: 16px;
max-height: 240px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
}
.messageItem {
font-size: 12px;
color: var(--l1-foreground);
word-break: break-word;
}

View File

@@ -0,0 +1,59 @@
import { useMemo } from 'react';
import { Color } from '@signozhq/design-tokens';
import { CircleX, TriangleAlert } from '@signozhq/icons';
import { Popover } from 'antd';
import PanelStatusContent from './PanelStatusContent';
import type { PanelStatusDetail, PanelStatusVariant } from './types';
import styles from './PanelStatusPopover.module.scss';
const VARIANT_CONFIG: Record<
PanelStatusVariant,
{ color: string; ariaLabel: string }
> = {
error: { color: Color.BG_CHERRY_500, ariaLabel: 'Panel error' },
warning: { color: Color.BG_AMBER_500, ariaLabel: 'Panel warning' },
};
interface PanelStatusPopoverProps {
variant: PanelStatusVariant;
detail: PanelStatusDetail;
}
/**
* Header status indicator: a variant-coloured icon (error → CircleX,
* warning → TriangleAlert) that opens a popover with the status detail. One
* component drives both variants so error and warning surfacing stay in lockstep.
*/
function PanelStatusPopover({
variant,
detail,
}: PanelStatusPopoverProps): JSX.Element {
const { color, ariaLabel } = VARIANT_CONFIG[variant];
const Icon = variant === 'error' ? CircleX : TriangleAlert;
const content = useMemo(
() => <PanelStatusContent detail={detail} />,
[detail],
);
return (
<Popover
content={content}
overlayInnerStyle={{ padding: 0 }}
autoAdjustOverflow
>
{/* Wrapping span gives antd a ref-able, hoverable trigger (icon
components don't forward refs) and a stable testid anchor. */}
<span
className={styles.trigger}
aria-label={ariaLabel}
data-testid={`panel-status-${variant}`}
>
<Icon size={16} color={color} />
</span>
</Popover>
);
}
export default PanelStatusPopover;

View File

@@ -0,0 +1,69 @@
import type { Warning } from 'types/api';
import APIError from 'types/api/error';
import { StatusCodes } from 'http-status-codes';
import { panelStatusFromError, panelStatusFromWarning } from '../utils';
describe('panelStatusFromError', () => {
it('returns null when there is no error', () => {
expect(panelStatusFromError(null)).toBeNull();
});
it('maps a structured APIError to code/message/docs/sub-messages', () => {
const apiError = new APIError({
httpStatusCode: StatusCodes.BAD_REQUEST,
error: {
code: 'invalid_query',
message: 'Query is invalid',
url: 'https://docs/err',
errors: [{ message: 'missing aggregation' }, { message: 'bad filter' }],
},
});
expect(panelStatusFromError(apiError)).toStrictEqual({
code: 'invalid_query',
message: 'Query is invalid',
docsUrl: 'https://docs/err',
messages: ['missing aggregation', 'bad filter'],
});
});
it('falls back to a generic 500 for a plain Error', () => {
expect(panelStatusFromError(new Error('boom'))).toStrictEqual({
code: '500',
message: 'boom',
messages: [],
});
});
it('omits docsUrl when the API error has no url', () => {
const apiError = new APIError({
httpStatusCode: StatusCodes.INTERNAL_SERVER_ERROR,
error: { code: 'x', message: 'y', url: '', errors: [] },
});
expect(panelStatusFromError(apiError)?.docsUrl).toBeUndefined();
});
});
describe('panelStatusFromWarning', () => {
it('returns null when there is no warning', () => {
expect(panelStatusFromWarning(undefined)).toBeNull();
});
it('maps a warning to the normalized status shape', () => {
const warning: Warning = {
code: 'partial_data',
message: 'Some series were dropped',
url: 'https://docs/warn',
warnings: [{ message: 'series A truncated' }],
};
expect(panelStatusFromWarning(warning)).toStrictEqual({
code: 'partial_data',
message: 'Some series were dropped',
docsUrl: 'https://docs/warn',
messages: ['series A truncated'],
});
});
});

View File

@@ -0,0 +1,19 @@
/** Which kind of non-fatal panel status is being surfaced in the header. */
export type PanelStatusVariant = 'error' | 'warning';
/**
* Normalized status shape that both an API error and a query warning adapt into,
* so a single popover can render either. Mirrors the fields the backend supplies
* on its `ErrorV2` / `Warning` envelopes (code + summary + optional docs link +
* per-item messages).
*/
export interface PanelStatusDetail {
/** Short status code (e.g. an error/warning code) shown as the heading. */
code: string;
/** Human-readable summary line. */
message: string;
/** Optional docs link; renders an "Open Docs" action when present. */
docsUrl?: string;
/** Additional per-item messages listed under the summary. */
messages: string[];
}

View File

@@ -0,0 +1,65 @@
import type { Warning } from 'types/api';
import type APIError from 'types/api/error';
import type { PanelStatusDetail } from './types';
const FALLBACK_CODE = '500';
const FALLBACK_MESSAGE = 'Something went wrong';
/**
* Narrows a thrown `Error` to our `APIError` (which carries the structured
* `error.error` envelope). react-query types failures as `Error`, so a runtime
* guard is the typed way to recover the richer shape.
*/
function isAPIError(error: Error): error is APIError {
return (
'error' in error &&
typeof (error as APIError).error === 'object' &&
(error as APIError).error !== null
);
}
/**
* Adapts a query failure into the normalized status shape. Structured
* `APIError`s yield their backend code/message/docs/sub-messages; any other
* `Error` falls back to a generic 500 with its message.
*/
export function panelStatusFromError(
error: Error | null,
): PanelStatusDetail | null {
if (!error) {
return null;
}
if (isAPIError(error)) {
const detail = error.error.error;
return {
code: detail?.code || FALLBACK_CODE,
message: detail?.message || FALLBACK_MESSAGE,
docsUrl: detail?.url || undefined,
messages: (detail?.errors ?? []).map((e) => e.message),
};
}
return {
code: FALLBACK_CODE,
message: error.message || FALLBACK_MESSAGE,
messages: [],
};
}
/** Adapts a query warning into the normalized status shape. */
export function panelStatusFromWarning(
warning: Warning | undefined,
): PanelStatusDetail | null {
if (!warning) {
return null;
}
return {
code: warning.code,
message: warning.message,
docsUrl: warning.url || undefined,
messages: (warning.warnings ?? []).map((w) => w.message),
};
}

View File

@@ -0,0 +1,36 @@
import { render, screen } from '@testing-library/react';
import type { Warning } from 'types/api';
import PanelHeader from '../PanelHeader';
const baseProps = {
title: 'My panel',
kind: 'TimeSeries',
panelId: 'panel-1',
isFetching: false,
};
const warning: Warning = {
code: 'partial_data',
message: 'Some series were dropped',
url: '',
warnings: [],
};
describe('PanelHeader status indicators', () => {
it('shows the error indicator whenever an error is present', () => {
render(<PanelHeader {...baseProps} error={new Error('boom')} />);
expect(screen.getByTestId('panel-status-error')).toBeInTheDocument();
});
it('shows the warning indicator whenever a warning is present', () => {
render(<PanelHeader {...baseProps} warning={warning} />);
expect(screen.getByTestId('panel-status-warning')).toBeInTheDocument();
});
it('renders no status indicators when there is no error or warning', () => {
render(<PanelHeader {...baseProps} />);
expect(screen.queryByTestId('panel-status-error')).not.toBeInTheDocument();
expect(screen.queryByTestId('panel-status-warning')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,68 @@
import { useCallback, useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports -- TODO: migrate global time dispatch off redux
import { useDispatch } from 'react-redux';
import { useLocation, useParams } from 'react-router-dom';
import { QueryParams } from 'constants/query';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { UpdateTimeInterval } from 'store/actions';
export interface PanelInteractions {
/**
* Drag-select a time range on a chart → write the window to the URL + global
* time so every panel re-fetches against the same range.
*/
onDragSelect: (start: number, end: number) => void;
/**
* Dashboard-wide rendering preferences (cursor sync, tooltip filter) keyed
* off the dashboard id from the route.
*/
dashboardPreference: DashboardPreference;
}
/**
* Encapsulates the cross-panel interactions shared by every dashboard-view
* panel: drag-to-zoom time selection and the cursor-sync / tooltip-filter
* preferences. Keeping this out of the `Panel` component keeps the component a
* thin render orchestrator and lets the wiring be unit-tested in isolation.
*/
export function usePanelInteractions(): PanelInteractions {
const dispatch = useDispatch();
const { pathname } = useLocation();
const { safeNavigate } = useSafeNavigate();
const urlQuery = useUrlQuery();
const { dashboardId } = useParams<{ dashboardId: string }>();
const [syncMode] = useDashboardCursorSyncMode(
dashboardId,
PanelMode.DASHBOARD_VIEW,
);
const [syncFilterMode] = useSyncTooltipFilterMode(dashboardId);
const dashboardPreference = useMemo<DashboardPreference>(
() => ({ syncMode, syncFilterMode, dashboardId }),
[syncMode, syncFilterMode, dashboardId],
);
const onDragSelect = useCallback(
(start: number, end: number): void => {
const startTimestamp = Math.trunc(start);
const endTimestamp = Math.trunc(end);
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
safeNavigate(`${pathname}?${urlQuery.toString()}`);
if (startTimestamp !== endTimestamp) {
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
}
},
[dispatch, pathname, safeNavigate, urlQuery],
);
return { onDragSelect, dashboardPreference };
}

View File

@@ -9,4 +9,11 @@
z-index: 2;
user-select: none;
}
:global(.react-resizable-handle) {
&::after {
border-right-color: var(--l2-border) !important;
border-bottom-color: var(--l2-border) !important;
}
}
}

View File

@@ -54,6 +54,7 @@ function SectionGrid({
useCSSTransforms
layout={rglLayout}
draggableHandle=".panel-drag-handle"
draggableCancel=".panel-no-drag"
isDraggable={isEditable}
isResizable={isEditable}
onDragStop={handleLayoutChange}

View File

@@ -0,0 +1,246 @@
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { renderHook } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { usePanelQuery } from '../usePanelQuery';
import { useGetQueryRangeV5 } from '../useGetQueryRangeV5';
jest.mock('react-redux', () => ({
useSelector: jest.fn(),
}));
jest.mock('../useGetQueryRangeV5', () => ({
useGetQueryRangeV5: jest.fn(),
}));
const mockUseSelector = useSelector as unknown as jest.Mock;
const mockUseGetQueryRangeV5 = useGetQueryRangeV5 as unknown as jest.Mock;
// ---- helpers ---------------------------------------------------------------
// Test fixtures are cast at the outer boundary; the perses-generated panel and
// query plugin unions are too verbose to construct field-typed inline.
function panelWith(
panelKind: string,
querySpec: Record<string, unknown>,
): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
plugin: { kind: panelKind, spec: {} },
queries: [
{
kind: 'TimeSeriesQuery',
spec: {
plugin: { kind: 'signoz/BuilderQuery', spec: querySpec },
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
}
function builderPanel(): DashboardtypesPanelDTO {
return panelWith('signoz/TimeSeriesPanel', {
name: 'A',
signal: 'logs',
filter: { expression: '' },
});
}
function emptyPanel(): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
}
// Nanoseconds, as redux globalTime stores them. 1e15 ns = 1e9 ms.
const DEFAULT_GLOBAL_TIME = {
selectedTime: 'GLOBAL_TIME',
minTime: 1_000_000_000_000_000,
maxTime: 2_000_000_000_000_000,
isAutoRefreshDisabled: false,
};
beforeEach(() => {
jest.clearAllMocks();
mockUseSelector.mockImplementation((selector: unknown) => {
// usePanelQuery passes a selector `(state) => state.globalTime`.
return (
selector as (state: { globalTime: typeof DEFAULT_GLOBAL_TIME }) => unknown
)({ globalTime: DEFAULT_GLOBAL_TIME });
});
mockUseGetQueryRangeV5.mockReturnValue({
data: undefined,
isLoading: false,
isFetching: false,
error: null,
});
});
// ---- tests -----------------------------------------------------------------
describe('usePanelQuery', () => {
it('builds the generated V5 request DTO directly from panel.spec.queries', () => {
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
const [{ requestPayload }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(requestPayload.schemaVersion).toBe('v1');
expect(requestPayload.compositeQuery.queries).toStrictEqual([
{
type: 'builder_query',
spec: { name: 'A', signal: 'logs', filter: { expression: '' } },
},
]);
});
it('converts redux nanosecond time to epoch ms on the request', () => {
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
const [{ requestPayload }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(requestPayload.start).toBe(1_000_000_000);
expect(requestPayload.end).toBe(2_000_000_000);
});
it.each([
['signoz/TimeSeriesPanel', 'time_series'],
['signoz/ListPanel', 'raw'],
// HISTOGRAM and BAR panels bin/derive from raw time-series data
// client-side, so the backend must receive `time_series` (V1 parity).
['signoz/HistogramPanel', 'time_series'],
['signoz/BarChartPanel', 'time_series'],
['signoz/NumberPanel', 'scalar'],
['signoz/PieChartPanel', 'scalar'],
])('%s panel sends requestType=%s', (panelKind, requestType) => {
renderHook(() =>
usePanelQuery({
panel: panelWith(panelKind, { name: 'A', signal: 'logs' }),
panelId: 'p1',
}),
);
const [{ requestPayload }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(requestPayload.requestType).toBe(requestType);
});
it('exposes the raw V5 response, request payload, and legend map on data', () => {
const v5Response = { status: 'success', data: { type: 'time_series' } };
mockUseGetQueryRangeV5.mockReturnValue({
data: v5Response,
isLoading: false,
isFetching: false,
error: null,
});
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.data.response).toBe(v5Response);
expect(result.current.data.legendMap).toStrictEqual({ A: '' });
expect(result.current.data.requestPayload?.schemaVersion).toBe('v1');
});
it('exposes an undefined response before data arrives', () => {
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.data.response).toBeUndefined();
});
it('exposes error from the fetch hook', () => {
mockUseGetQueryRangeV5.mockReturnValue({
data: undefined,
isLoading: false,
isFetching: false,
error: new Error('boom'),
});
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.error?.message).toBe('boom');
});
it('combines isLoading and isFetching into a single isLoading flag', () => {
mockUseGetQueryRangeV5.mockReturnValue({
data: undefined,
isLoading: false,
isFetching: true,
error: null,
});
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.isLoading).toBe(true);
});
it('coerces a missing/undefined error to null', () => {
mockUseGetQueryRangeV5.mockReturnValue({
data: undefined,
isLoading: false,
isFetching: false,
error: undefined,
});
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.error).toBeNull();
});
it('passes enabled=false to the fetch hook when the caller disables it', () => {
renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1', enabled: false }),
);
const [{ enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(enabled).toBe(false);
});
it('auto-disables the fetch when the panel has no queries (even with enabled=true)', () => {
renderHook(() =>
usePanelQuery({ panel: emptyPanel(), panelId: 'p1', enabled: true }),
);
const [{ enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(enabled).toBe(false);
});
it('auto-disables the fetch when every metrics query is missing a metric name', () => {
renderHook(() =>
usePanelQuery({
panel: panelWith('signoz/TimeSeriesPanel', {
name: 'A',
signal: 'metrics',
aggregations: [{}],
}),
panelId: 'p1',
}),
);
const [{ enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(enabled).toBe(false);
});
it('composes a react-query cache key that includes panelId, time range, kind, and queries', () => {
const panel = builderPanel();
renderHook(() => usePanelQuery({ panel, panelId: 'p1' }));
const [{ queryKey }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(queryKey).toStrictEqual(
expect.arrayContaining([
'p1',
DEFAULT_GLOBAL_TIME.minTime,
DEFAULT_GLOBAL_TIME.maxTime,
DEFAULT_GLOBAL_TIME.selectedTime,
'signoz/TimeSeriesPanel',
panel.spec?.queries,
]),
);
});
it('builds an empty composite and disables the fetch when panel is undefined (no crash)', () => {
renderHook(() => usePanelQuery({ panel: undefined, panelId: 'p-none' }));
const [{ requestPayload, enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(requestPayload.compositeQuery.queries).toStrictEqual([]);
expect(enabled).toBe(false);
});
});

View File

@@ -0,0 +1,49 @@
import { useQuery, UseQueryResult } from 'react-query';
import { isAxiosError } from 'axios';
import { queryRangeV5 } from 'api/generated/services/querier';
import type {
Querybuildertypesv5QueryRangeRequestDTO,
QueryRangeV5200,
} from 'api/generated/services/sigNoz.schemas';
import { MAX_QUERY_RETRIES } from 'constants/reactQuery';
export interface UseGetQueryRangeV5Args {
requestPayload: Querybuildertypesv5QueryRangeRequestDTO | undefined;
queryKey: unknown[];
enabled: boolean;
}
// 4xx responses are deterministic (bad query, auth) — retrying re-sends a
// request that will fail identically. Same policy as V1's useGetQueryRange.
function retryUnlessClientError(failureCount: number, error: Error): boolean {
if (isAxiosError(error)) {
if (error.code === 'ERR_CANCELED') {
return false;
}
const status = error.response?.status;
if (status && status >= 400 && status < 500) {
return false;
}
}
return failureCount < MAX_QUERY_RETRIES;
}
/**
* Pure-V5 query-range fetch: posts the generated request DTO via the
* generated `queryRangeV5` call and returns the raw generated response —
* no V1 `Query` shape on either leg. Wrapped in `useQuery` (not the
* generated `useQueryRangeV5` mutation hook) because panel fetches need
* caching, `enabled` gating, and refetch semantics.
*/
export function useGetQueryRangeV5({
requestPayload,
queryKey,
enabled,
}: UseGetQueryRangeV5Args): UseQueryResult<QueryRangeV5200, Error> {
return useQuery<QueryRangeV5200, Error>({
queryKey,
queryFn: ({ signal }) => queryRangeV5(requestPayload, signal),
enabled: enabled && !!requestPayload,
retry: retryUnlessClientError,
});
}

View File

@@ -0,0 +1,133 @@
import { useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports -- TODO: migrate global time selector off redux
import { useSelector } from 'react-redux';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import {
buildQueryRangeRequest,
extractLegendMap,
hasRunnableQueries,
} from '../queryV5/buildQueryRangeRequest';
import type { PanelQueryData } from '../queryV5/types';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from '../Panels/types/panelKind';
import { useGetQueryRangeV5 } from './useGetQueryRangeV5';
export interface UsePanelQueryArgs {
panel: DashboardtypesPanelDTO | undefined;
panelId: string;
/**
* Gate the underlying fetch. Defaults to true. PanelV2 sets this false when
* no plugin is registered for the panel's kind so the unknown-kind fallback
* UI doesn't trigger a wasted API call.
*
* The hook *also* auto-disables internally when the panel has no runnable
* queries — callers don't need to compute that themselves.
*/
enabled?: boolean;
}
export interface UsePanelQueryResult {
/** Raw V5 fetch result — response + the request that produced it. */
data: PanelQueryData;
/** Combines `isLoading` (first fetch) and `isFetching` (background refresh). */
isLoading: boolean;
/** Background refresh in flight while data is already present. */
isFetching: boolean;
error: Error | null;
/** Re-run the query (e.g. a retry button on the error state). */
refetch: () => void;
}
/**
* Fetches the query-range data for a V2 panel over the pure-V5 contract.
*
* 1. Request — `buildQueryRangeRequest` assembles the generated
* `Querybuildertypesv5QueryRangeRequestDTO` directly from the panel's
* perses queries (a CompositeQuery plugin already nests the V5
* envelope list). No V1 `Query` intermediary.
* 2. Time + variables — reads the global time selection from Redux
* (variables substitution is intentionally deferred until V2 has its
* own variable plumbing).
* 3. Fetch — `useGetQueryRangeV5` posts via the generated `queryRangeV5`
* call with a react-query cache key composed from panel identity +
* time range + kind + queries.
*
* Renderers consume the raw V5 response through the `queryV5` prep utils
* (`flattenTimeSeries`, `prepareScalarTables`, …).
*
* The hook is consumed today by PanelV2 (renderer dispatch) and will be
* consumed by PanelEditor (1.8) for "preview as you edit."
*/
export function usePanelQuery({
panel,
panelId,
enabled = true,
}: UsePanelQueryArgs): UsePanelQueryResult {
const fullKind = panel?.spec?.plugin?.kind;
const panelType =
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind as PanelKind]) ??
PANEL_TYPES.TIME_SERIES;
const queries = panel?.spec?.queries;
const {
selectedTime: globalSelectedInterval,
maxTime,
minTime,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
// Redux global time is in nanoseconds; the V5 API takes epoch ms.
const startMs = Math.floor(minTime / 1e6);
const endMs = Math.floor(maxTime / 1e6);
const requestPayload = useMemo(
() =>
buildQueryRangeRequest({
queries: queries ?? [],
panelType,
startMs,
endMs,
}),
[queries, panelType, startMs, endMs],
);
const legendMap = useMemo(() => extractLegendMap(queries ?? []), [queries]);
const runnable = useMemo(() => hasRunnableQueries(queries ?? []), [queries]);
const response = useGetQueryRangeV5({
requestPayload,
queryKey: [
REACT_QUERY_KEY.DASHBOARD_GRID_CARD_QUERY_RANGE,
panelId,
minTime,
maxTime,
globalSelectedInterval,
fullKind,
queries,
],
enabled: enabled && runnable,
});
const data = useMemo<PanelQueryData>(
() => ({ response: response.data, requestPayload, legendMap }),
[response.data, requestPayload, legendMap],
);
return {
data,
isLoading: response.isLoading || response.isFetching,
isFetching: response.isFetching,
// Coerce undefined → null so the contract is `Error | null`, not
// `Error | null | undefined`. Consumers can rely on a single
// "no error" sentinel.
error: (response.error as Error | null) ?? null,
refetch: response.refetch,
};
}

View File

@@ -0,0 +1,283 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
buildQueryRangeRequest,
extractLegendMap,
getBarStepIntervalSeconds,
hasRunnableQueries,
panelTypeToRequestType,
toQueryEnvelopes,
} from '../buildQueryRangeRequest';
// Test fixtures are cast at the outer boundary; the perses-generated query
// plugin unions are too verbose to construct field-typed inline.
function bareBuilderQuery(
spec: Record<string, unknown>,
): DashboardtypesQueryDTO[] {
return [
{
kind: 'TimeSeriesQuery',
spec: { plugin: { kind: 'signoz/BuilderQuery', spec } },
},
] as unknown as DashboardtypesQueryDTO[];
}
function compositeQuery(
envelopes: Record<string, unknown>[],
): DashboardtypesQueryDTO[] {
return [
{
kind: 'TimeSeriesQuery',
spec: {
plugin: { kind: 'signoz/CompositeQuery', spec: { queries: envelopes } },
},
},
] as unknown as DashboardtypesQueryDTO[];
}
const HOUR_MS = 60 * 60 * 1000;
const START_MS = 1_700_000_000_000;
describe('panelTypeToRequestType', () => {
it.each([
[PANEL_TYPES.TIME_SERIES, 'time_series'],
// HISTOGRAM and BAR bin client-side from time-series data; sending
// 'distribution' would return a shape the renderers can't bin.
[PANEL_TYPES.BAR, 'time_series'],
[PANEL_TYPES.HISTOGRAM, 'time_series'],
[PANEL_TYPES.TABLE, 'scalar'],
[PANEL_TYPES.PIE, 'scalar'],
[PANEL_TYPES.VALUE, 'scalar'],
[PANEL_TYPES.LIST, 'raw'],
[PANEL_TYPES.TRACE, 'trace'],
])('%s → %s', (panelType, requestType) => {
expect(panelTypeToRequestType(panelType)).toBe(requestType);
});
});
describe('toQueryEnvelopes', () => {
it('wraps a bare BuilderQuery into a single builder_query envelope', () => {
const envelopes = toQueryEnvelopes(
bareBuilderQuery({ name: 'A', signal: 'metrics' }),
);
expect(envelopes).toStrictEqual([
{ type: 'builder_query', spec: { name: 'A', signal: 'metrics' } },
]);
});
it('passes a CompositeQuery envelope list through verbatim', () => {
const subqueries = [
{ type: 'builder_query', spec: { name: 'A' } },
{ type: 'builder_formula', spec: { name: 'F1', expression: 'A*2' } },
];
expect(toQueryEnvelopes(compositeQuery(subqueries))).toStrictEqual(
subqueries,
);
});
it('wraps PromQL and ClickHouse plugins with their envelope types', () => {
const prom = [
{
kind: 'PromQuery',
spec: {
plugin: { kind: 'signoz/PromQLQuery', spec: { name: 'A', query: 'up' } },
},
},
] as unknown as DashboardtypesQueryDTO[];
expect(toQueryEnvelopes(prom)).toStrictEqual([
{ type: 'promql', spec: { name: 'A', query: 'up' } },
]);
const ch = [
{
kind: 'ClickHouseQuery',
spec: {
plugin: {
kind: 'signoz/ClickHouseSQL',
spec: { name: 'A', query: 'SELECT 1' },
},
},
},
] as unknown as DashboardtypesQueryDTO[];
expect(toQueryEnvelopes(ch)).toStrictEqual([
{ type: 'clickhouse_sql', spec: { name: 'A', query: 'SELECT 1' } },
]);
});
it('drops invalid top-level Formula with a warning instead of crashing', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
const formula = [
{
kind: 'TimeSeriesQuery',
spec: {
plugin: { kind: 'signoz/Formula', spec: { name: 'F1', expression: 'A' } },
},
},
] as unknown as DashboardtypesQueryDTO[];
expect(toQueryEnvelopes(formula)).toStrictEqual([]);
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
it('returns empty for missing plugin or empty queries', () => {
expect(toQueryEnvelopes([])).toStrictEqual([]);
expect(
toQueryEnvelopes([
{ kind: 'TimeSeriesQuery', spec: {} },
] as unknown as DashboardtypesQueryDTO[]),
).toStrictEqual([]);
});
});
describe('buildQueryRangeRequest', () => {
it('assembles the full request DTO', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'metrics' }),
panelType: PANEL_TYPES.TIME_SERIES,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
expect(request).toStrictEqual({
schemaVersion: 'v1',
start: START_MS,
end: START_MS + HOUR_MS,
requestType: 'time_series',
compositeQuery: {
queries: [
{ type: 'builder_query', spec: { name: 'A', signal: 'metrics' } },
],
},
formatOptions: { formatTableResultForUI: false, fillGaps: false },
variables: {},
});
});
it('sets formatTableResultForUI only for TABLE panels', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A' }),
panelType: PANEL_TYPES.TABLE,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
expect(request.formatOptions?.formatTableResultForUI).toBe(true);
});
it('injects the range-derived stepInterval into BAR builder queries without one', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'metrics' }),
panelType: PANEL_TYPES.BAR,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
const spec = (request.compositeQuery?.queries?.[0]?.spec ?? {}) as {
stepInterval?: number;
};
expect(spec.stepInterval).toBe(
getBarStepIntervalSeconds(START_MS, START_MS + HOUR_MS),
);
});
it('preserves a user-set stepInterval on BAR builder queries', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', stepInterval: 300 }),
panelType: PANEL_TYPES.BAR,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
const spec = (request.compositeQuery?.queries?.[0]?.spec ?? {}) as {
stepInterval?: number;
};
expect(spec.stepInterval).toBe(300);
});
it('does not touch stepInterval for non-BAR panels', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A' }),
panelType: PANEL_TYPES.TIME_SERIES,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
const spec = (request.compositeQuery?.queries?.[0]?.spec ?? {}) as {
stepInterval?: number;
};
expect(spec.stepInterval).toBeUndefined();
});
});
describe('getBarStepIntervalSeconds', () => {
// V1 parity: getBarStepIntervalPoints in container/GridCardLayout/utils.ts
it.each([
[30, 60],
[60, 60],
[120, 120],
[180, 120],
[300, 180],
])('%s min range → %s s step', (minutes, step) => {
expect(getBarStepIntervalSeconds(0, minutes * 60 * 1000)).toBe(step);
});
it('caps long ranges at ~80 bars, rounded to 5-minute steps', () => {
// 24h = 1440 min → 1440/80 = 18 → rounded up to 20 min → 1200 s
expect(getBarStepIntervalSeconds(0, 24 * HOUR_MS)).toBe(1200);
});
});
describe('extractLegendMap', () => {
it('maps query names to legends across composite subqueries', () => {
const legendMap = extractLegendMap(
compositeQuery([
{ type: 'builder_query', spec: { name: 'A', legend: 'CPU {{host}}' } },
{ type: 'builder_query', spec: { name: 'B' } },
{ type: 'builder_formula', spec: { name: 'F1', legend: 'sum' } },
]),
);
expect(legendMap).toStrictEqual({ A: 'CPU {{host}}', B: '', F1: 'sum' });
});
});
describe('hasRunnableQueries', () => {
it('false when the panel has no queries', () => {
expect(hasRunnableQueries([])).toBe(false);
});
it('true for non-metrics builder queries', () => {
expect(
hasRunnableQueries(bareBuilderQuery({ name: 'A', signal: 'logs' })),
).toBe(true);
});
it('false when every metrics query is missing a metric name', () => {
expect(
hasRunnableQueries(
bareBuilderQuery({
name: 'A',
signal: 'metrics',
aggregations: [{ metricName: ' ' }],
}),
),
).toBe(false);
});
it('true when at least one metrics query has a metric name', () => {
expect(
hasRunnableQueries(
compositeQuery([
{
type: 'builder_query',
spec: { name: 'A', signal: 'metrics', aggregations: [{}] },
},
{
type: 'builder_query',
spec: {
name: 'B',
signal: 'metrics',
aggregations: [{ metricName: 'system_cpu' }],
},
},
]),
),
).toBe(true);
});
});

View File

@@ -0,0 +1,211 @@
import type {
Querybuildertypesv5QueryRangeRequestDTO,
Querybuildertypesv5ScalarDataDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
extractAggregationsPerQuery,
prepareScalarTables,
} from '../prepareScalarTables';
// Test fixtures are cast at the outer boundary; the generated envelope union
// erases spec to unknown anyway.
function requestWith(
envelopes: Record<string, unknown>[],
): Querybuildertypesv5QueryRangeRequestDTO {
return {
schemaVersion: 'v1',
compositeQuery: { queries: envelopes },
} as unknown as Querybuildertypesv5QueryRangeRequestDTO;
}
function scalarResult(
columns: Record<string, unknown>[],
data: unknown[][],
): Querybuildertypesv5ScalarDataDTO {
return { columns, data } as unknown as Querybuildertypesv5ScalarDataDTO;
}
const SINGLE_AGG_REQUEST = requestWith([
{
type: 'builder_query',
spec: {
name: 'A',
aggregations: [{ expression: 'count()', alias: '' }],
},
},
]);
describe('extractAggregationsPerQuery', () => {
it('maps builder query names to their aggregations, ignoring other envelope types', () => {
const request = requestWith([
{
type: 'builder_query',
spec: { name: 'A', aggregations: [{ expression: 'count()' }] },
},
{ type: 'promql', spec: { name: 'P', query: 'up' } },
]);
expect(extractAggregationsPerQuery(request)).toStrictEqual({
A: [{ expression: 'count()' }],
});
});
it('returns empty for an undefined payload', () => {
expect(extractAggregationsPerQuery(undefined)).toStrictEqual({});
});
});
describe('prepareScalarTables', () => {
it('builds keyed rows with group + aggregation columns (V1 getColName/getColId parity)', () => {
const [table] = prepareScalarTables({
results: [
scalarResult(
[
{ name: 'service.name', queryName: 'A', columnType: 'group' },
{
name: '__result_0',
queryName: 'A',
columnType: 'aggregation',
aggregationIndex: 0,
},
],
[
['frontend', 42],
['backend', 7],
],
),
],
legendMap: { A: '' },
requestPayload: SINGLE_AGG_REQUEST,
});
expect(table.queryName).toBe('A');
expect(table.columns).toStrictEqual([
{
name: 'service.name',
queryName: 'A',
isValueColumn: false,
id: 'service.name',
},
// Single aggregation, no alias/legend → expression as display name,
// queryName as id.
{ name: 'count()', queryName: 'A', isValueColumn: true, id: 'A' },
]);
expect(table.rows).toStrictEqual([
{ data: { 'service.name': 'frontend', A: 42 } },
{ data: { 'service.name': 'backend', A: 7 } },
]);
});
it('single aggregation resolves name as alias > legend > expression', () => {
const columns = [
{
name: '__result_0',
queryName: 'A',
columnType: 'aggregation',
aggregationIndex: 0,
},
];
const withAlias = prepareScalarTables({
results: [scalarResult(columns, [])],
legendMap: { A: 'My legend' },
requestPayload: requestWith([
{
type: 'builder_query',
spec: {
name: 'A',
aggregations: [{ expression: 'count()', alias: 'hits' }],
},
},
]),
});
expect(withAlias[0].columns[0].name).toBe('hits');
const withLegend = prepareScalarTables({
results: [scalarResult(columns, [])],
legendMap: { A: 'My legend' },
requestPayload: SINGLE_AGG_REQUEST,
});
expect(withLegend[0].columns[0].name).toBe('My legend');
});
it('multiple aggregations skip the legend and key columns by queryName.expression', () => {
const [table] = prepareScalarTables({
results: [
scalarResult(
[
{
name: '__result_0',
queryName: 'A',
columnType: 'aggregation',
aggregationIndex: 0,
},
{
name: '__result_1',
queryName: 'A',
columnType: 'aggregation',
aggregationIndex: 1,
},
],
[[10, 20]],
),
],
legendMap: { A: 'Ignored legend' },
requestPayload: requestWith([
{
type: 'builder_query',
spec: {
name: 'A',
aggregations: [{ expression: 'count()' }, { expression: 'avg(x)' }],
},
},
]),
});
expect(table.columns.map((col) => col.name)).toStrictEqual([
'count()',
'avg(x)',
]);
expect(table.columns.map((col) => col.id)).toStrictEqual([
'A.count()',
'A.avg(x)',
]);
expect(table.rows).toStrictEqual([
{ data: { 'A.count()': 10, 'A.avg(x)': 20 } },
]);
});
it('one table per scalar result (multi-query separation)', () => {
const tables = prepareScalarTables({
results: [
scalarResult(
[{ name: '__result_0', queryName: 'A', columnType: 'aggregation' }],
[[1]],
),
scalarResult(
[{ name: '__result_0', queryName: 'B', columnType: 'aggregation' }],
[[2]],
),
],
legendMap: {},
requestPayload: requestWith([]),
});
expect(tables.map((t) => t.queryName)).toStrictEqual(['A', 'B']);
});
it('queries without aggregation metadata fall back to legend || queryName', () => {
const [table] = prepareScalarTables({
results: [
scalarResult(
[{ name: '__result_0', queryName: 'A', columnType: 'aggregation' }],
[],
),
],
legendMap: { A: 'Legend' },
requestPayload: requestWith([]),
});
expect(table.columns[0].name).toBe('Legend');
expect(table.columns[0].id).toBe('A');
});
});

View File

@@ -0,0 +1,107 @@
import type { PanelSeries } from '../types';
import {
hasSingleVisiblePoint,
prepareAlignedData,
toClickPluginPayload,
} from '../uplotData';
function makeSeries(
values: { timestamp: number; value: number }[],
overrides: Partial<PanelSeries> = {},
): PanelSeries {
return {
queryName: 'A',
legend: '',
labels: {},
kind: 'series',
values,
aggregation: { index: 0, alias: '' },
...overrides,
};
}
describe('prepareAlignedData', () => {
it('converts ms timestamps to seconds and aligns series on the union x-axis', () => {
const aligned = prepareAlignedData([
makeSeries([
{ timestamp: 1_000, value: 1 },
{ timestamp: 2_000, value: 2 },
]),
makeSeries([
{ timestamp: 2_000, value: 20 },
{ timestamp: 3_000, value: 30 },
]),
]);
expect(aligned).toStrictEqual([
[1, 2, 3],
[1, 2, null],
[null, 20, 30],
]);
});
it('replaces non-finite values with null', () => {
const aligned = prepareAlignedData([
makeSeries([
{ timestamp: 1_000, value: NaN },
{ timestamp: 2_000, value: Infinity },
{ timestamp: 3_000, value: 3 },
]),
]);
expect(aligned[1]).toStrictEqual([null, null, 3]);
});
it('yields an empty y-array for a series with no values (legacy parity)', () => {
const aligned = prepareAlignedData([
makeSeries([{ timestamp: 1_000, value: 1 }]),
makeSeries([]),
]);
expect(aligned[2]).toStrictEqual([]);
});
it('returns a lone timestamp axis for no series', () => {
expect(prepareAlignedData([])).toStrictEqual([[]]);
});
});
describe('hasSingleVisiblePoint', () => {
it('true for zero or one finite point', () => {
expect(hasSingleVisiblePoint([])).toBe(true);
expect(hasSingleVisiblePoint([{ timestamp: 1, value: 5 }])).toBe(true);
expect(
hasSingleVisiblePoint([
{ timestamp: 1, value: NaN },
{ timestamp: 2, value: 5 },
]),
).toBe(true);
});
it('false once two finite points exist', () => {
expect(
hasSingleVisiblePoint([
{ timestamp: 1, value: 1 },
{ timestamp: 2, value: 2 },
]),
).toBe(false);
});
});
describe('toClickPluginPayload', () => {
it('produces the tuple-shaped legacy result the shared click plugin reads', () => {
const payload = toClickPluginPayload([
makeSeries([{ timestamp: 5_000, value: 1.5 }], {
labels: { host: 'h1' },
legend: 'L',
aggregation: { index: 1, alias: 'p99' },
}),
]);
expect(payload.data.result).toStrictEqual([
{
metric: { host: 'h1' },
queryName: 'A',
legend: 'L',
values: [[5, '1.5']],
metaData: { alias: 'p99', index: 1, queryName: 'A' },
},
]);
});
});

View File

@@ -0,0 +1,161 @@
import type { QueryRangeV5200 } from 'api/generated/services/sigNoz.schemas';
import {
flattenTimeSeries,
getExecStats,
getRawResults,
getScalarResults,
getTimeSeriesResults,
} from '../v5ResponseData';
// Test fixtures are cast at the outer boundary; the generated response union
// erases `results` to unknown[] anyway.
function timeSeriesResponse(
results: Record<string, unknown>[],
meta?: Record<string, unknown>,
): QueryRangeV5200 {
return {
status: 'success',
data: { type: 'time_series', data: { results }, meta },
} as unknown as QueryRangeV5200;
}
const SERIES_A = {
queryName: 'A',
aggregations: [
{
index: 0,
alias: 'p99',
meta: { unit: 'ms' },
series: [
{
labels: [{ key: { name: 'host' }, value: 'h1' }],
values: [
{ timestamp: 1000, value: 1.5 },
{ timestamp: 2000, value: 2.5, partial: true },
],
},
],
},
],
};
describe('narrowing accessors', () => {
it('getTimeSeriesResults returns results only for time_series responses', () => {
expect(getTimeSeriesResults(timeSeriesResponse([SERIES_A]))).toHaveLength(1);
expect(
getTimeSeriesResults({
status: 'success',
data: { type: 'scalar', data: { results: [{}] } },
} as unknown as QueryRangeV5200),
).toStrictEqual([]);
expect(getTimeSeriesResults(undefined)).toStrictEqual([]);
});
it('getScalarResults returns results only for scalar responses', () => {
const scalar = {
status: 'success',
data: { type: 'scalar', data: { results: [{ queryName: 'A' }] } },
} as unknown as QueryRangeV5200;
expect(getScalarResults(scalar)).toStrictEqual([{ queryName: 'A' }]);
expect(getScalarResults(timeSeriesResponse([SERIES_A]))).toStrictEqual([]);
});
it('getRawResults accepts both raw and trace responses', () => {
const make = (type: string): QueryRangeV5200 =>
({
status: 'success',
data: { type, data: { results: [{ queryName: 'A', rows: [] }] } },
}) as unknown as QueryRangeV5200;
expect(getRawResults(make('raw'))).toHaveLength(1);
expect(getRawResults(make('trace'))).toHaveLength(1);
expect(getRawResults(make('time_series'))).toStrictEqual([]);
});
it('getExecStats surfaces top-level meta (incl. stepIntervals)', () => {
const response = timeSeriesResponse([], { stepIntervals: { A: 60 } });
expect(getExecStats(response)?.stepIntervals).toStrictEqual({ A: 60 });
});
});
describe('flattenTimeSeries', () => {
it('flattens aggregations × series with numeric ms values and labels record', () => {
const [series] = flattenTimeSeries(
getTimeSeriesResults(timeSeriesResponse([SERIES_A])),
{ A: 'CPU {{host}}' },
);
expect(series).toStrictEqual({
queryName: 'A',
legend: 'CPU {{host}}',
labels: { host: 'h1' },
kind: 'series',
values: [
{ timestamp: 1000, value: 1.5 },
{ timestamp: 2000, value: 2.5, partial: true },
],
aggregation: { index: 0, alias: 'p99', unit: 'ms' },
});
});
it('tags anomaly companion series with their kind', () => {
const result = {
queryName: 'A',
aggregations: [
{
index: 0,
alias: '',
series: [{ labels: [], values: [] }],
predictedSeries: [{ labels: [], values: [] }],
upperBoundSeries: [{ labels: [], values: [] }],
},
],
};
const kinds = flattenTimeSeries(
getTimeSeriesResults(timeSeriesResponse([result])),
{},
).map((s) => s.kind);
expect(kinds.sort()).toStrictEqual(['predicted', 'series', 'upperBound']);
});
// V1 parity: convertV5ResponseToLegacy + GetMetricQueryRange backfill.
it('falls back legend to queryName and mirrors it into labels when the series has no labels', () => {
const result = {
queryName: 'A',
aggregations: [{ index: 0, series: [{ labels: [], values: [] }] }],
};
const [series] = flattenTimeSeries(
getTimeSeriesResults(timeSeriesResponse([result])),
{},
);
expect(series.legend).toBe('A');
expect(series.labels).toStrictEqual({ A: 'A' });
});
it('keeps a user legend without touching labels when labels exist', () => {
const [series] = flattenTimeSeries(
getTimeSeriesResults(timeSeriesResponse([SERIES_A])),
{},
);
expect(series.legend).toBe('');
expect(series.labels).toStrictEqual({ host: 'h1' });
});
it('handles multiple aggregation buckets per query', () => {
const result = {
queryName: 'A',
aggregations: [
{ index: 0, alias: 'avg', series: [{ labels: [], values: [] }] },
{ index: 1, alias: 'max', series: [{ labels: [], values: [] }] },
],
};
const flattened = flattenTimeSeries(
getTimeSeriesResults(timeSeriesResponse([result])),
{},
);
expect(flattened.map((s) => s.aggregation)).toStrictEqual([
{ index: 0, alias: 'avg', unit: undefined },
{ index: 1, alias: 'max', unit: undefined },
]);
});
});

View File

@@ -0,0 +1,245 @@
import type {
DashboardtypesQueryDTO,
Querybuildertypesv5CompositeQueryDTO,
Querybuildertypesv5QueryEnvelopeDTO,
Querybuildertypesv5QueryRangeRequestDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5QueryTypeDTO,
Querybuildertypesv5RequestTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
/**
* Narrow view over the builder-query / promql / clickhouse spec variants of
* the generated query-envelope union. The generated envelope types erase
* `spec` to `unknown` (Orval limitation on the discriminated union), so the
* fields shared by every spec variant are read through this view with a
* localized cast at the envelope boundary.
*/
interface QuerySpecView {
name?: string;
legend?: string;
signal?: string;
stepInterval?: number | string;
aggregations?: { metricName?: string }[];
}
/**
* Maps a V2 panel type to the V5 `requestType` the backend expects.
*
* HISTOGRAM and BAR panels bin/derive from raw time-series data client-side,
* so the backend request type for them is `time_series` — the same effective
* mapping the V1 path produced via `getGraphType` + `mapPanelTypeToRequestType`.
*/
export function panelTypeToRequestType(
panelType: PANEL_TYPES,
): Querybuildertypesv5RequestTypeDTO {
switch (panelType) {
case PANEL_TYPES.TIME_SERIES:
case PANEL_TYPES.BAR:
case PANEL_TYPES.HISTOGRAM:
return Querybuildertypesv5RequestTypeDTO.time_series;
case PANEL_TYPES.TABLE:
case PANEL_TYPES.PIE:
case PANEL_TYPES.VALUE:
return Querybuildertypesv5RequestTypeDTO.scalar;
case PANEL_TYPES.LIST:
return Querybuildertypesv5RequestTypeDTO.raw;
case PANEL_TYPES.TRACE:
return Querybuildertypesv5RequestTypeDTO.trace;
default:
return Querybuildertypesv5RequestTypeDTO.time_series;
}
}
/**
* Unwraps the perses query envelope into the V5 `compositeQuery.queries`
* list. A CompositeQuery plugin already carries the V5 envelope list and is
* passed through verbatim; bare plugins are wrapped into a single envelope.
*
* Top-level Formula and TraceOperator are invalid — they reference builder
* queries by name and can only travel inside a CompositeQuery. Defensive
* read: warn and drop, don't crash dashboard load.
*/
export function toQueryEnvelopes(
queries: DashboardtypesQueryDTO[],
): Querybuildertypesv5QueryEnvelopeDTO[] {
// Backend invariant: panel.queries.length === 1. Only the first entry is
// consumed — extras (a malformed payload) are ignored.
const plugin = queries[0]?.spec?.plugin;
if (!plugin?.spec) {
return [];
}
switch (plugin.kind) {
case 'signoz/CompositeQuery':
return (plugin.spec as Querybuildertypesv5CompositeQueryDTO).queries ?? [];
case 'signoz/BuilderQuery':
return [
{
type: Querybuildertypesv5QueryTypeDTO.builder_query,
spec: plugin.spec,
},
];
case 'signoz/PromQLQuery':
return [{ type: Querybuildertypesv5QueryTypeDTO.promql, spec: plugin.spec }];
case 'signoz/ClickHouseSQL':
return [
{
type: Querybuildertypesv5QueryTypeDTO.clickhouse_sql,
spec: plugin.spec,
},
];
case 'signoz/Formula':
case 'signoz/TraceOperator':
// eslint-disable-next-line no-console
console.warn(
`buildQueryRangeRequest: top-level ${plugin.kind} is invalid ` +
'(formulas and trace operators must travel inside a ' +
'CompositeQuery alongside the builder query they reference). ' +
'Dropping.',
);
return [];
default:
return [];
}
}
/**
* Step interval (seconds) for BAR panels so the bar count stays readable
* (~80 bars max). Duplicated from V1 `getBarStepIntervalPoints`
* (container/GridCardLayout/utils.ts) per the V1/V2 split policy.
*/
export function getBarStepIntervalSeconds(
startMs: number,
endMs: number,
): number {
const durationMs = endMs - startMs;
const durationMin = durationMs / (60 * 1000);
if (durationMin <= 60) {
return 60;
}
if (durationMin <= 180) {
return 120;
}
if (durationMin <= 300) {
return 180;
}
const totalPoints = Math.ceil(durationMs / (1000 * 60));
const interval = Math.ceil(totalPoints / 80);
const roundedInterval = Math.ceil(interval / 5) * 5;
return roundedInterval * 60;
}
// BAR panels: builder queries without a user-set stepInterval get the
// range-derived interval so bars align (V1 parity: `updateBarStepInterval`).
function withBarStepInterval(
envelopes: Querybuildertypesv5QueryEnvelopeDTO[],
startMs: number,
endMs: number,
): Querybuildertypesv5QueryEnvelopeDTO[] {
const stepInterval = getBarStepIntervalSeconds(startMs, endMs);
return envelopes.map((envelope) => {
if (envelope.type !== Querybuildertypesv5QueryTypeDTO.builder_query) {
return envelope;
}
const spec = envelope.spec as QuerySpecView;
if (spec.stepInterval) {
return envelope;
}
return { ...envelope, spec: { ...spec, stepInterval } };
});
}
export interface BuildQueryRangeRequestArgs {
queries: DashboardtypesQueryDTO[];
panelType: PANEL_TYPES;
/** Epoch milliseconds. */
startMs: number;
/** Epoch milliseconds. */
endMs: number;
}
/**
* Builds the generated V5 query-range request DTO directly from the panel's
* perses queries — no V1 `Query` intermediary.
*
* Dashboard variables are intentionally absent (`variables: {}`) until V2
* grows its own variable plumbing; this matches what usePanelQuery sent
* through the V1 path.
*/
export function buildQueryRangeRequest({
queries,
panelType,
startMs,
endMs,
}: BuildQueryRangeRequestArgs): Querybuildertypesv5QueryRangeRequestDTO {
let envelopes = toQueryEnvelopes(queries);
if (panelType === PANEL_TYPES.BAR) {
envelopes = withBarStepInterval(envelopes, startMs, endMs);
}
return {
schemaVersion: 'v1',
start: startMs,
end: endMs,
requestType: panelTypeToRequestType(panelType),
compositeQuery: { queries: envelopes },
formatOptions: {
formatTableResultForUI: panelType === PANEL_TYPES.TABLE,
fillGaps: false,
},
variables: {},
};
}
/**
* queryName → legend for every envelope that carries one. Replaces the
* legendMap `prepareQueryRangePayloadV5` derived from the V1 query; consumed
* by the legacy-response bridge for legend resolution.
*/
export function extractLegendMap(
queries: DashboardtypesQueryDTO[],
): Record<string, string> {
const legendMap: Record<string, string> = {};
toQueryEnvelopes(queries).forEach((envelope) => {
const spec = envelope.spec as QuerySpecView | undefined;
if (spec?.name) {
legendMap[spec.name] = spec.legend ?? '';
}
});
return legendMap;
}
/**
* Fetch gate. False when the panel has no queries, or when every metrics
* builder query is missing a metric name (the V1 path short-circuited those
* to an empty response via `validateMetricNameForMetricsDataSource` to avoid
* a guaranteed 400; here the fetch is skipped outright).
*/
export function hasRunnableQueries(queries: DashboardtypesQueryDTO[]): boolean {
const envelopes = toQueryEnvelopes(queries);
if (envelopes.length === 0) {
return false;
}
const metricsSpecs = envelopes
.filter(
(envelope) =>
envelope.type === Querybuildertypesv5QueryTypeDTO.builder_query,
)
.map((envelope) => envelope.spec as QuerySpecView)
.filter((spec) => spec.signal === 'metrics');
if (metricsSpecs.length === 0) {
return true;
}
return !metricsSpecs.every((spec) => {
const metricName = spec.aggregations?.[0]?.metricName;
return !metricName || metricName.trim() === '';
});
}

View File

@@ -0,0 +1,156 @@
import type {
Querybuildertypesv5ColumnDescriptorDTO,
Querybuildertypesv5QueryRangeRequestDTO,
Querybuildertypesv5ScalarDataDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Querybuildertypesv5QueryTypeDTO } from 'api/generated/services/sigNoz.schemas';
import type { PanelTable, PanelTableColumn } from './types';
/**
* Narrow view over a builder-query aggregation; the generated envelope spec
* is `unknown`, so the fields column naming needs are read through this view
* with a localized cast at the envelope boundary.
*/
interface AggregationView {
alias?: string;
expression?: string;
}
type AggregationsPerQuery = Record<string, AggregationView[]>;
/**
* queryName → aggregations, recovered from the request payload's builder
* envelopes. Column display names depend on the aggregation alias/expression
* the query was sent with (V1 parity: `convertV5ResponseToLegacy` derived the
* same map from `params.compositeQuery`).
*/
export function extractAggregationsPerQuery(
requestPayload: Querybuildertypesv5QueryRangeRequestDTO | undefined,
): AggregationsPerQuery {
const perQuery: AggregationsPerQuery = {};
(requestPayload?.compositeQuery?.queries ?? []).forEach((envelope) => {
if (envelope.type !== Querybuildertypesv5QueryTypeDTO.builder_query) {
return;
}
const spec = envelope.spec as {
name?: string;
aggregations?: AggregationView[];
};
if (spec?.name && spec.aggregations) {
perQuery[spec.name] = spec.aggregations;
}
});
return perQuery;
}
/**
* Column display name. Group columns keep their field name; aggregation
* columns resolve alias > legend > expression > queryName — with the legend
* skipped when the query has multiple aggregations, because one legend can't
* label several value columns. (Port of V1 `getColName`.)
*/
function getColName(
col: Querybuildertypesv5ColumnDescriptorDTO,
legendMap: Record<string, string>,
aggregationsPerQuery: AggregationsPerQuery,
): string {
if (col.columnType === 'group') {
return col.name;
}
const queryName = col.queryName ?? '';
const aggregations = aggregationsPerQuery[queryName];
const aggregation = aggregations?.[col.aggregationIndex ?? 0];
const legend = legendMap[queryName];
const alias = aggregation?.alias;
const expression = aggregation?.expression || '';
const aggregationsCount = aggregations?.length || 0;
if (aggregationsCount > 0) {
if (aggregationsCount === 1) {
return alias || legend || expression || queryName;
}
return alias || expression || queryName;
}
return legend || queryName;
}
/**
* Stable row-data key for a column. Multi-aggregation queries need
* `queryName.expression` so the value columns don't collide. (Port of V1
* `getColId`.)
*/
function getColId(
col: Querybuildertypesv5ColumnDescriptorDTO,
aggregationsPerQuery: AggregationsPerQuery,
): string {
if (col.columnType === 'group') {
return col.name;
}
const queryName = col.queryName ?? '';
const aggregations = aggregationsPerQuery[queryName];
const expression = aggregations?.[col.aggregationIndex ?? 0]?.expression || '';
if ((aggregations?.length || 0) > 1 && expression) {
return `${queryName}.${expression}`;
}
return queryName;
}
export interface PrepareScalarTablesArgs {
results: Querybuildertypesv5ScalarDataDTO[];
legendMap: Record<string, string>;
requestPayload: Querybuildertypesv5QueryRangeRequestDTO | undefined;
}
/**
* Converts V5 scalar results (`{columns, data[][]}`) into the keyed
* table shape Number/Pie/Table panels render: columns with resolved display
* names + `isValueColumn`, rows keyed by column id. (Port of V1
* `convertScalarDataArrayToTable`; the `formatForWeb` variant produced the
* same structure and is collapsed into this one.)
*/
export function prepareScalarTables({
results,
legendMap,
requestPayload,
}: PrepareScalarTablesArgs): PanelTable[] {
const aggregationsPerQuery = extractAggregationsPerQuery(requestPayload);
return results.map((scalarData) => {
if (!scalarData) {
return {
queryName: '',
legend: '',
columns: [],
rows: [],
};
}
const queryName = scalarData.columns?.[0]?.queryName ?? '';
const columns: PanelTableColumn[] = (scalarData.columns ?? []).map((col) => ({
name: getColName(col, legendMap, aggregationsPerQuery),
queryName: col.queryName ?? '',
isValueColumn: col.columnType === 'aggregation',
id: getColId(col, aggregationsPerQuery),
}));
const rows = (scalarData.data ?? []).map((dataRow) => {
const rowData: Record<string, unknown> = {};
columns.forEach((col, colIndex) => {
rowData[col.id || col.name] = dataRow[colIndex];
});
return { data: rowData };
});
return {
queryName,
legend: legendMap[queryName] || '',
columns,
rows,
};
});
}

View File

@@ -0,0 +1,86 @@
import type {
Querybuildertypesv5QueryRangeRequestDTO,
QueryRangeV5200,
} from 'api/generated/services/sigNoz.schemas';
/**
* V5-native shapes the panel renderers consume, produced by the queryV5 prep
* utils from the raw generated query-range response. These replace the legacy
* tuple/`newResult` shapes renderers used to read off the bridged response.
*/
/**
* Raw V5 fetch result threaded from usePanelQuery to the renderers. Carries
* the request alongside the response because column naming (scalar) and the
* X-scale clamps both depend on what was actually sent.
*/
export interface PanelQueryData {
response: QueryRangeV5200 | undefined;
requestPayload: Querybuildertypesv5QueryRangeRequestDTO | undefined;
/** queryName → user legend, from the panel's queries. */
legendMap: Record<string, string>;
}
/** One data point. `timestamp` is epoch milliseconds (V5 wire native). */
export interface PanelSeriesPoint {
timestamp: number;
value: number;
/** True when the bucket at the range edge is incomplete. */
partial?: boolean;
}
/**
* Distinguishes the plain series list from the anomaly-detector companions
* carried on the same aggregation bucket.
*/
export type PanelSeriesKind =
| 'series'
| 'predicted'
| 'upperBound'
| 'lowerBound'
| 'anomalyScores';
/** One flattened time series (one aggregation bucket × one label set). */
export interface PanelSeries {
queryName: string;
/**
* Resolved legend: the user-set legend for the query, falling back to the
* query name when the series carries no labels (V1 parity).
*/
legend: string;
/**
* Label name → value. Empty object when the series has no group-by.
* Values are stringified — uPlot series config and `getLabelName` both
* consume string-valued label records.
*/
labels: Record<string, string>;
values: PanelSeriesPoint[];
kind: PanelSeriesKind;
aggregation: {
index: number;
alias: string;
unit?: string;
};
}
export interface PanelTableColumn {
/** Display name (alias/legend/expression resolution applied). */
name: string;
queryName: string;
/** True for aggregation columns, false for group-by columns. */
isValueColumn: boolean;
/** Key into `PanelTableRow.data`. */
id: string;
}
export interface PanelTableRow {
data: Record<string, unknown>;
}
/** One scalar result rendered as a table (Number/Pie/Table panels). */
export interface PanelTable {
queryName: string;
legend: string;
columns: PanelTableColumn[];
rows: PanelTableRow[];
}

View File

@@ -0,0 +1,104 @@
import type { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import type { QueryData } from 'types/api/widgets/getQuery';
import type uPlot from 'uplot';
import type { PanelSeries, PanelSeriesPoint } from './types';
/**
* uPlot data prep over the flat `PanelSeries[]`, V5-native counterpart of the
* legacy `prepareChartData` chain. uPlot's x values are epoch seconds;
* `PanelSeriesPoint.timestamp` is epoch ms, so the conversion happens here —
* the single seam between wire time and chart time.
*/
function toPlotValue(value: number): number | null {
return Number.isFinite(value) ? value : null;
}
/**
* Aligns all series onto one shared x-axis: the union of every series'
* timestamps, sorted ascending, with `null` filling the slots a series has no
* sample for. A series with no values at all yields an empty array (legacy
* `fillMissingXAxisTimestamps` parity).
*/
export function prepareAlignedData(series: PanelSeries[]): uPlot.AlignedData {
const timestampSet = new Set<number>();
series.forEach((s) => {
s.values.forEach((point) => {
timestampSet.add(Math.floor(point.timestamp / 1000));
});
});
const timestamps = Array.from(timestampSet).sort((a, b) => a - b);
const yValues = series.map((s) => {
if (!s.values.length) {
return [];
}
const valueByTimestamp = new Map<number, number | null>();
s.values.forEach((point) => {
valueByTimestamp.set(
Math.floor(point.timestamp / 1000),
toPlotValue(point.value),
);
});
return timestamps.map((ts) => valueByTimestamp.get(ts) ?? null);
});
return [timestamps, ...yValues] as uPlot.AlignedData;
}
/**
* True when the series has at most one finite point — such a series can't be
* drawn as a line, so renderers degrade it to points (V1
* `hasSingleVisiblePoint` parity).
*/
export function hasSingleVisiblePoint(values: PanelSeriesPoint[]): boolean {
let validPointCount = 0;
for (const point of values) {
if (Number.isFinite(point.value)) {
validPointCount += 1;
if (validPointCount > 1) {
return false;
}
}
}
return true;
}
/**
* LEGACY-SHAPE adapter for the shared `onClickPlugin` (lib/uPlotLib), which
* maps pixel coordinates back to data through the old tuple-shaped
* `payload.data.result`. The plugin is V1-shared and can't take `PanelSeries`;
* this thin mapper is the only place the tuple shape survives in V2. Remove
* when V2 grows its own click plugin.
*/
export function toClickPluginPayload(
series: PanelSeries[],
): MetricRangePayloadProps {
const result = series.map(
(s) =>
({
metric: s.labels,
queryName: s.queryName,
legend: s.legend,
values: s.values.map(
(point) =>
[Math.floor(point.timestamp / 1000), String(point.value)] as [
number,
string,
],
),
metaData: {
alias: s.aggregation.alias,
index: s.aggregation.index,
queryName: s.queryName,
},
}) as unknown as QueryData,
);
// `newResult` is declared required on the legacy type but the click plugin
// never reads it — omit and cast through unknown.
return {
data: { result, resultType: '' },
} as unknown as MetricRangePayloadProps;
}

View File

@@ -0,0 +1,158 @@
import type {
Querybuildertypesv5AggregationBucketDTO,
Querybuildertypesv5ExecStatsDTO,
Querybuildertypesv5RawDataDTO,
Querybuildertypesv5ScalarDataDTO,
Querybuildertypesv5TimeSeriesDataDTO,
Querybuildertypesv5TimeSeriesDTO,
QueryRangeV5200,
} from 'api/generated/services/sigNoz.schemas';
import { isEmpty } from 'lodash-es';
import type { PanelSeries, PanelSeriesKind } from './types';
/**
* The generated `Querybuildertypesv5QueryDataDTO` union erases the `results`
* element type to `unknown[]` (Orval limitation), so each accessor here
* narrows it once — guarded by the response `type` discriminator — to the
* generated per-element DTO. This is the single place that cast lives.
*/
export function getTimeSeriesResults(
response: QueryRangeV5200 | undefined,
): Querybuildertypesv5TimeSeriesDataDTO[] {
if (response?.data?.type !== 'time_series') {
return [];
}
return (response.data.data?.results ??
[]) as Querybuildertypesv5TimeSeriesDataDTO[];
}
export function getScalarResults(
response: QueryRangeV5200 | undefined,
): Querybuildertypesv5ScalarDataDTO[] {
if (response?.data?.type !== 'scalar') {
return [];
}
return (response.data.data?.results ??
[]) as Querybuildertypesv5ScalarDataDTO[];
}
export function getRawResults(
response: QueryRangeV5200 | undefined,
): Querybuildertypesv5RawDataDTO[] {
const data = response?.data;
if (data?.type !== 'raw' && data?.type !== 'trace') {
return [];
}
return (data.data?.results ?? []) as Querybuildertypesv5RawDataDTO[];
}
/** Exec stats (incl. per-query `stepIntervals`) from the response top level. */
export function getExecStats(
response: QueryRangeV5200 | undefined,
): Querybuildertypesv5ExecStatsDTO | undefined {
return response?.data?.meta;
}
// V5 labels are `{key: {name}, value}` pairs; renderers want a flat
// name → value record with string values (uPlot/getLabelName contract).
function labelsToRecord(
series: Querybuildertypesv5TimeSeriesDTO,
): Record<string, string> {
const record: Record<string, string> = {};
(series.labels ?? []).forEach((label) => {
if (label.key?.name) {
record[label.key.name] = String(label.value);
}
});
return record;
}
/**
* Legend/labels backfill, V1 parity (`convertV5ResponseToLegacy` +
* `GetMetricQueryRange`'s post pass): a series with no labels falls back to
* the query name as its legend, and mirrors the name into `labels` so
* downstream label-driven naming has something to show.
*/
function resolveLegendAndLabels(
queryName: string,
labels: Record<string, string>,
legendMap: Record<string, string>,
): { legend: string; labels: Record<string, string> } {
let legend = legendMap[queryName] ?? '';
if (isEmpty(labels)) {
if (!legend) {
legend = queryName;
}
if (legend === queryName) {
return { legend, labels: { [queryName]: queryName } };
}
}
return { legend, labels };
}
const BUCKET_FIELD_TO_KIND: Record<
PanelSeriesKind,
keyof Querybuildertypesv5AggregationBucketDTO
> = {
series: 'series',
predicted: 'predictedSeries',
upperBound: 'upperBoundSeries',
lowerBound: 'lowerBoundSeries',
anomalyScores: 'anomalyScores',
};
/**
* Flattens the V5 time-series result tree
* (`results[].aggregations[].series[]` + anomaly companions) into the flat
* `PanelSeries[]` the chart renderers iterate. Values stay numeric and
* timestamps stay epoch-ms (V5 wire native) — no legacy stringification.
*/
export function flattenTimeSeries(
results: Querybuildertypesv5TimeSeriesDataDTO[],
legendMap: Record<string, string>,
): PanelSeries[] {
const flattened: PanelSeries[] = [];
// Kind-outer iteration so the flat order matches what the legacy chain
// produced (per query: all plain series across aggregations, then the
// anomaly companions) — series order drives uPlot color assignment.
results.forEach((result) => {
const queryName = result.queryName ?? '';
(Object.keys(BUCKET_FIELD_TO_KIND) as PanelSeriesKind[]).forEach((kind) => {
(result.aggregations ?? []).forEach((bucket) => {
const seriesList = bucket[BUCKET_FIELD_TO_KIND[kind]] as
| Querybuildertypesv5TimeSeriesDTO[]
| null
| undefined;
(seriesList ?? []).forEach((series) => {
const { legend, labels } = resolveLegendAndLabels(
queryName,
labelsToRecord(series),
legendMap,
);
flattened.push({
queryName,
legend,
labels,
kind,
values: (series.values ?? []).map((value) => ({
timestamp: value.timestamp ?? 0,
value: value.value ?? 0,
...(value.partial ? { partial: true } : {}),
})),
aggregation: {
index: bucket.index ?? 0,
alias: bucket.alias ?? '',
unit: bucket.meta?.unit,
},
});
});
});
});
});
return flattened;
}

View File

@@ -84,8 +84,8 @@ function DashboardsList(): JSX.Element {
const listParams = useMemo(
() => ({
query: searchString.trim() || undefined,
sort: sortColumn,
order: sortOrder,
sort: sortColumn as DashboardtypesListSortDTO,
order: sortOrder as DashboardtypesListOrderDTO,
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
}),