Compare commits

..

2 Commits

Author SHA1 Message Date
Naman Verma
845c88ec45 Merge branch 'main' into nv/schema-changes 2026-06-15 16:55:36 +05:30
Naman Verma
9b53561c31 fix: change schema properties based on UI integration review 2026-06-15 15:37:29 +05:30
93 changed files with 190 additions and 6176 deletions

View File

@@ -2769,9 +2769,16 @@ components:
type: string
nullable: true
type: object
mode:
$ref: '#/components/schemas/DashboardtypesLegendMode'
position:
$ref: '#/components/schemas/DashboardtypesLegendPosition'
type: object
DashboardtypesLegendMode:
enum:
- list
- table
type: string
DashboardtypesLegendPosition:
enum:
- bottom
@@ -3321,8 +3328,13 @@ components:
DashboardtypesSpanGaps:
properties:
fillLessThan:
description: The maximum gap size to connect when fillOnlyBelow is true.
Gaps larger than this duration are left disconnected.
type: string
fillOnlyBelow:
description: Controls whether lines connect across null values. When false
(default), all gaps are connected. When true, only gaps smaller than fillLessThan
are connected.
type: boolean
type: object
DashboardtypesStorableDashboardData:
@@ -3389,7 +3401,6 @@ components:
required:
- value
- color
- label
type: object
DashboardtypesTimePreference:
enum:

View File

@@ -3208,6 +3208,10 @@ export interface DashboardtypesPanelFormattingDTO {
unit?: string;
}
export enum DashboardtypesLegendModeDTO {
list = 'list',
table = 'table',
}
export enum DashboardtypesLegendPositionDTO {
bottom = 'bottom',
right = 'right',
@@ -3227,6 +3231,7 @@ export interface DashboardtypesLegendDTO {
* @type object,null
*/
customColors?: DashboardtypesLegendDTOCustomColors;
mode?: DashboardtypesLegendModeDTO;
position?: DashboardtypesLegendPositionDTO;
}
@@ -3238,7 +3243,7 @@ export interface DashboardtypesThresholdWithLabelDTO {
/**
* @type string
*/
label: string;
label?: string;
/**
* @type string
*/
@@ -3903,10 +3908,12 @@ export enum DashboardtypesLineStyleDTO {
export interface DashboardtypesSpanGapsDTO {
/**
* @type string
* @description The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected.
*/
fillLessThan?: string;
/**
* @type boolean
* @description Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected.
*/
fillOnlyBelow?: boolean;
}

View File

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

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 { getDonutGeometry, getFillColor } from './utils';
import { getFillColor } from './utils';
/**
* Donut chart rendered with @visx. Splits its area into chart + legend with the
@@ -78,12 +78,16 @@ export default function Pie({
[containerWidth, containerHeight, position, data],
);
// 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],
);
// 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]);
const totalValue = useMemo(
() => visibleData.reduce((sum, slice) => sum + slice.value, 0),

View File

@@ -1,40 +1,11 @@
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,16 +10,6 @@ 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,37 +3,7 @@
* so the renderer stays declarative (per the one-component-per-file rule).
*/
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,
};
}
import { ArcGeometry, ParsedRgb, ScaledFontSizeArgs } from './types';
/**
* Shrinks the centre-total font as the text gets longer so it never overflows
@@ -67,7 +37,7 @@ export function getArcGeometry(
radius: number,
): ArcGeometry {
const angle = (startAngle + endAngle) / 2;
const labelRadius = radius * LABEL_RADIUS_RATIO;
const labelRadius = radius * 1.3;
const lineEndRadius = radius * 1.1;
return {
labelX: Math.sin(angle) * labelRadius,

View File

@@ -1,79 +0,0 @@
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,15 +116,7 @@ export function calculateChartDimensions({
? legendRowCount * legendRowHeight - LEGEND_PADDING
: legendRowHeight;
// 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 maxAllowedLegendHeight = Math.min(2 * legendRowHeight, 80);
const bottomLegendHeight = Math.min(
idealBottomLegendHeight,

View File

@@ -2,7 +2,6 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
createColumnsAndDataSource,
evaluateThresholdWithConvertedValue,
getQueryLegend,
sortFunction,
} from '../utils';
@@ -226,30 +225,3 @@ 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,11 +29,8 @@ 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,8 +6,6 @@ export const operatorOptions: DefaultOptionType[] = [
{ value: '>=', label: '>=' },
{ value: '<', label: '<' },
{ value: '<=', label: '<=' },
{ value: '=', label: '=' },
{ value: '!=', label: '≠' },
];
export const showAsOptions: DefaultOptionType[] = [

View File

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

View File

@@ -1,30 +0,0 @@
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

@@ -1,48 +0,0 @@
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

@@ -1,36 +0,0 @@
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

@@ -1,175 +0,0 @@
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

@@ -1,138 +0,0 @@
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

@@ -1,13 +0,0 @@
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

@@ -1,9 +0,0 @@
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

@@ -1,139 +0,0 @@
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

@@ -1,129 +0,0 @@
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

@@ -1,13 +0,0 @@
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

@@ -1,148 +0,0 @@
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

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

View File

@@ -1,81 +0,0 @@
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

@@ -1,155 +0,0 @@
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

@@ -1,62 +0,0 @@
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

@@ -1,99 +0,0 @@
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

@@ -1,36 +0,0 @@
.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

@@ -1,102 +0,0 @@
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

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

View File

@@ -1,31 +0,0 @@
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

@@ -1,13 +0,0 @@
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

@@ -1,33 +0,0 @@
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

@@ -1,8 +0,0 @@
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

@@ -1,54 +0,0 @@
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

@@ -1,88 +0,0 @@
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

@@ -1,13 +0,0 @@
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

@@ -1,58 +0,0 @@
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

@@ -1,8 +0,0 @@
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

@@ -1,180 +0,0 @@
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

@@ -1,159 +0,0 @@
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

@@ -1,13 +0,0 @@
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

@@ -1,15 +0,0 @@
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

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

View File

@@ -1,59 +0,0 @@
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

@@ -1,31 +0,0 @@
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

@@ -1,20 +0,0 @@
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

@@ -1,75 +0,0 @@
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

@@ -1,55 +0,0 @@
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

@@ -1,29 +0,0 @@
/**
* 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

@@ -1,71 +0,0 @@
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

@@ -1,33 +0,0 @@
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

@@ -1,120 +0,0 @@
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

@@ -1,207 +0,0 @@
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

@@ -1,108 +0,0 @@
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

@@ -1,139 +0,0 @@
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

@@ -1,22 +0,0 @@
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

@@ -1,38 +0,0 @@
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

@@ -1,26 +0,0 @@
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

@@ -1,138 +0,0 @@
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(--l2-background);
border: 1px solid var(--l2-border);
background: var(--bg-ink-400, #0b0c0e);
border: 1px solid var(--l1-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(--l2-border);
border-bottom: 1px solid var(--l1-border);
cursor: grab;
}
@@ -36,15 +36,6 @@
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;
@@ -59,41 +50,3 @@
.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,16 +1,15 @@
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 { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels';
import { usePanelQuery } from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
import type { Warning } from 'types/api';
import cx from 'classnames';
import type { DashboardSection } from '../../utils';
import type { DeletePanelArgs } from './hooks/useDeletePanel';
import { usePanelInteractions } from './hooks/usePanelInteractions';
import type { MovePanelArgs } from './hooks/useMovePanelToSection';
import PanelBody from './PanelBody';
import PanelHeader from './PanelHeader';
import PanelActionsMenu from './PanelActionsMenu/PanelActionsMenu';
import styles from './Panel.module.scss';
/** Panel action context — present together only in editable sectioned mode. */
@@ -24,18 +23,16 @@ export interface PanelActionsConfig {
interface PanelProps {
panel: DashboardtypesPanelDTO | undefined;
panelId: string;
/** True once this panel's section enters the viewport — gates the fetch. */
/**
* 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.
*/
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,
@@ -44,22 +41,9 @@ function Panel({
}: PanelProps): JSX.Element {
const name = panel?.spec?.display?.name || `Panel ${panelId.slice(0, 6)}`;
const description = panel?.spec?.display?.description;
const fullKind = panel?.spec?.plugin?.kind;
const kind = fullKind?.replace(/^signoz\//, '') ?? 'unknown';
const kind = panel?.spec?.plugin?.kind?.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;
@@ -76,30 +60,35 @@ function Panel({
className={styles.panel}
data-panel-visible={isVisible ? 'true' : 'false'}
>
<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 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>
</div>
);
}

View File

@@ -1,112 +0,0 @@
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

@@ -1,81 +0,0 @@
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

@@ -1,51 +0,0 @@
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

@@ -1,64 +0,0 @@
.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

@@ -1,59 +0,0 @@
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

@@ -1,69 +0,0 @@
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

@@ -1,19 +0,0 @@
/** 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

@@ -1,65 +0,0 @@
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

@@ -1,36 +0,0 @@
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

@@ -1,68 +0,0 @@
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,11 +9,4 @@
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,7 +54,6 @@ function SectionGrid({
useCSSTransforms
layout={rglLayout}
draggableHandle=".panel-drag-handle"
draggableCancel=".panel-no-drag"
isDraggable={isEditable}
isResizable={isEditable}
onDragStop={handleLayoutChange}

View File

@@ -1,246 +0,0 @@
// 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

@@ -1,49 +0,0 @@
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

@@ -1,133 +0,0 @@
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

@@ -1,283 +0,0 @@
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

@@ -1,211 +0,0 @@
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

@@ -1,107 +0,0 @@
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

@@ -1,161 +0,0 @@
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

@@ -1,245 +0,0 @@
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

@@ -1,156 +0,0 @@
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

@@ -1,86 +0,0 @@
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

@@ -1,104 +0,0 @@
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

@@ -1,158 +0,0 @@
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 as DashboardtypesListSortDTO,
order: sortOrder as DashboardtypesListOrderDTO,
sort: sortColumn,
order: sortOrder,
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
}),

View File

@@ -38,7 +38,7 @@ func newTestDashboardV2(t *testing.T, orgID valuer.UUID, source Source) *Dashboa
FillMode: FillModeSolid,
SpanGaps: SpanGaps{FillLessThan: valuer.MustParseTextDuration("60s")},
},
Legend: Legend{Position: LegendPositionBottom},
Legend: Legend{Position: LegendPositionBottom, Mode: LegendModeList},
},
},
Queries: []Query{

View File

@@ -569,6 +569,24 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}`,
wantContain: "legend position",
},
{
name: "bad legend mode",
data: `{
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {
"kind": "signoz/BarChartPanel",
"spec": {"legend": {"mode": "grid"}}
}
}
}
},
"layouts": []
}`,
wantContain: "legend mode",
},
{
name: "bad threshold format",
data: `{
@@ -634,6 +652,39 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
// Label on ThresholdWithLabel is optional — the backend never reads it, so a
// threshold with an omitted or empty label must validate cleanly.
func TestThresholdLabelOptional(t *testing.T) {
for _, tt := range []struct {
name string
threshold string
}{
{name: "label omitted", threshold: `{"value": 100, "color": "Red"}`},
{name: "label empty", threshold: `{"value": 100, "color": "Red", "label": ""}`},
} {
t.Run(tt.name, func(t *testing.T) {
data := []byte(`{
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {"thresholds": [` + tt.threshold + `]}},
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
}
},
"layouts": []
}`)
d, err := unmarshalDashboard(data)
require.NoError(t, err, "threshold without a label should validate")
spec := d.Panels["p1"].Spec.Plugin.Spec.(*TimeSeriesPanelSpec)
require.Len(t, spec.Thresholds, 1)
require.Empty(t, spec.Thresholds[0].Label, "label should remain empty")
})
}
}
func TestInvalidatePanelWithoutQueries(t *testing.T) {
data := []byte(`{
"panels": {
@@ -749,11 +800,6 @@ func TestValidateRequiredFields(t *testing.T) {
data: wrapPanel("signoz/TimeSeriesPanel", `{"thresholds": [{"value": 100, "label": "high", "color": ""}]}`),
wantContain: "Color",
},
{
name: "ThresholdWithLabel missing label",
data: wrapPanel("signoz/TimeSeriesPanel", `{"thresholds": [{"value": 100, "color": "Red", "label": ""}]}`),
wantContain: "Label",
},
{
name: "ComparisonThreshold missing value",
data: wrapPanel("signoz/NumberPanel", `{"thresholds": [{"operator": "above", "format": "text", "color": "Red"}]}`),
@@ -811,10 +857,11 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
require.Equal(t, "2", spec.Formatting.DecimalPrecision.ValueOrDefault(), "expected DecimalPrecision default 2")
require.Equal(t, "spline", spec.ChartAppearance.LineInterpolation.ValueOrDefault(), "expected LineInterpolation default spline")
require.Equal(t, "solid", spec.ChartAppearance.LineStyle.ValueOrDefault(), "expected LineStyle default solid")
require.Equal(t, "solid", spec.ChartAppearance.FillMode.ValueOrDefault(), "expected FillMode default solid")
require.Equal(t, "none", spec.ChartAppearance.FillMode.ValueOrDefault(), "expected FillMode default none")
require.False(t, spec.ChartAppearance.SpanGaps.FillOnlyBelow, "expected SpanGaps.FillOnlyBelow default false")
require.Equal(t, "global_time", spec.Visualization.TimePreference.ValueOrDefault(), "expected TimePreference default global_time")
require.Equal(t, "bottom", spec.Legend.Position.ValueOrDefault(), "expected LegendPosition default bottom")
require.Equal(t, "list", spec.Legend.Mode.ValueOrDefault(), "expected LegendMode default list")
// Re-marshal the full dashboard (what we'd store in DB / return in API response)
// and verify the output contains the default values.
@@ -825,9 +872,10 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
"decimalPrecision": `"2"`,
"lineInterpolation": `"spline"`,
"lineStyle": `"solid"`,
"fillMode": `"solid"`,
"fillMode": `"none"`,
"timePreference": `"global_time"`,
"position": `"bottom"`,
"mode": `"list"`,
} {
assert.Contains(t, outputStr, `"`+field+`":`+want, "expected stored/response JSON to contain %s:%s", field, want)
}
@@ -930,7 +978,7 @@ func TestStorageRoundTrip(t *testing.T) {
assert.Equal(t, "2", tsSpec.Formatting.DecimalPrecision.ValueOrDefault())
assert.Equal(t, "spline", tsSpec.ChartAppearance.LineInterpolation.ValueOrDefault())
assert.Equal(t, "solid", tsSpec.ChartAppearance.LineStyle.ValueOrDefault())
assert.Equal(t, "solid", tsSpec.ChartAppearance.FillMode.ValueOrDefault())
assert.Equal(t, "none", tsSpec.ChartAppearance.FillMode.ValueOrDefault())
assert.Equal(t, "global_time", tsSpec.Visualization.TimePreference.ValueOrDefault())
assert.Equal(t, "bottom", tsSpec.Legend.Position.ValueOrDefault())
numSpec := d.Panels["p2"].Spec.Plugin.Spec.(*NumberPanelSpec)
@@ -950,7 +998,7 @@ func TestStorageRoundTrip(t *testing.T) {
assert.Equal(t, "2", tsLoaded.Formatting.DecimalPrecision.ValueOrDefault(), "after load")
assert.Equal(t, "spline", tsLoaded.ChartAppearance.LineInterpolation.ValueOrDefault(), "after load")
assert.Equal(t, "solid", tsLoaded.ChartAppearance.LineStyle.ValueOrDefault(), "after load")
assert.Equal(t, "solid", tsLoaded.ChartAppearance.FillMode.ValueOrDefault(), "after load")
assert.Equal(t, "none", tsLoaded.ChartAppearance.FillMode.ValueOrDefault(), "after load")
assert.Equal(t, "global_time", tsLoaded.Visualization.TimePreference.ValueOrDefault(), "after load")
assert.Equal(t, "bottom", tsLoaded.Legend.Position.ValueOrDefault(), "after load")
numLoaded := loaded.Panels["p2"].Spec.Plugin.Spec.(*NumberPanelSpec)
@@ -966,7 +1014,7 @@ func TestStorageRoundTrip(t *testing.T) {
"decimalPrecision": `"2"`,
"lineInterpolation": `"spline"`,
"lineStyle": `"solid"`,
"fillMode": `"solid"`,
"fillMode": `"none"`,
"timePreference": `"global_time"`,
"position": `"bottom"`,
"format": `"text"`,

View File

@@ -241,6 +241,7 @@ type TableFormatting struct {
type Legend struct {
Position LegendPosition `json:"position"`
Mode LegendMode `json:"mode"`
CustomColors map[string]string `json:"customColors"`
}
@@ -248,7 +249,7 @@ type ThresholdWithLabel struct {
Value float64 `json:"value" validate:"required" required:"true"`
Unit string `json:"unit"`
Color string `json:"color" validate:"required" required:"true"`
Label string `json:"label" validate:"required" required:"true"`
Label string `json:"label"`
}
type ComparisonThreshold struct {
@@ -358,6 +359,47 @@ func (l *LegendPosition) UnmarshalJSON(data []byte) error {
}
}
type LegendMode struct{ valuer.String }
var (
LegendModeList = LegendMode{valuer.NewString("list")} // default
LegendModeTable = LegendMode{valuer.NewString("table")}
)
func (LegendMode) Enum() []any {
return []any{LegendModeList, LegendModeTable}
}
func (m LegendMode) ValueOrDefault() string {
if m.IsZero() {
return LegendModeList.StringValue()
}
return m.StringValue()
}
func (m LegendMode) MarshalJSON() ([]byte, error) {
return json.Marshal(m.ValueOrDefault())
}
func (m *LegendMode) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid legend mode: must be a string, one of `list` or `table`")
}
if v == "" {
*m = LegendModeList
return nil
}
lm := LegendMode{valuer.NewString(v)}
switch lm {
case LegendModeList, LegendModeTable:
*m = lm
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid legend mode %q: must be `list` or `table`", v)
}
}
type ThresholdFormat struct{ valuer.String }
var (
@@ -534,9 +576,9 @@ func (ls *LineStyle) UnmarshalJSON(data []byte) error {
type FillMode struct{ valuer.String }
var (
FillModeSolid = FillMode{valuer.NewString("solid")} // default
FillModeSolid = FillMode{valuer.NewString("solid")}
FillModeGradient = FillMode{valuer.NewString("gradient")}
FillModeNone = FillMode{valuer.NewString("none")}
FillModeNone = FillMode{valuer.NewString("none")} // default
)
func (FillMode) Enum() []any {
@@ -545,7 +587,7 @@ func (FillMode) Enum() []any {
func (fm FillMode) ValueOrDefault() string {
if fm.IsZero() {
return FillModeSolid.StringValue()
return FillModeNone.StringValue()
}
return fm.StringValue()
}
@@ -560,7 +602,7 @@ func (fm *FillMode) UnmarshalJSON(data []byte) error {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid fill mode: must be a string, one of `solid`, `gradient`, or `none`")
}
if v == "" {
*fm = FillModeSolid
*fm = FillModeNone
return nil
}
val := FillMode{valuer.NewString(v)}
@@ -573,12 +615,9 @@ func (fm *FillMode) UnmarshalJSON(data []byte) error {
}
}
// SpanGaps controls whether lines connect across null values.
// When FillOnlyBelow is false (default), all gaps are connected.
// When FillOnlyBelow is true, only gaps smaller than FillLessThan are connected.
type SpanGaps struct {
FillOnlyBelow bool `json:"fillOnlyBelow"`
FillLessThan valuer.TextDuration `json:"fillLessThan"`
FillOnlyBelow bool `json:"fillOnlyBelow" description:"Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected."`
FillLessThan valuer.TextDuration `json:"fillLessThan" description:"The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected."`
}
type PrecisionOption struct{ valuer.String }